Home Assistant Chrome UI testing

SkillWeb & browsing

Drive Docker Home Assistant in Chrome via CDP (not screenshot pixels). Restores Chrome DevTools when /json/version dies (Chrome 136+ default profile, ProcessSingleton). Covers the Cursor cloud VM and the headless Claude Code sandbox, where dockerd must be started by hand and only Playwright's Chromium exists. Use for Marstek config-flow UI tests (discovery, Confirm device, manual IP/port, delete/re-add, disable, connection-loss repairs, Ignore discovery, system options, registry/hide, history, energy, Assist expose, actions, automations) and walkthroughs.

Instructions available. Your AI can read the instructions. Execution depends on the setup they require.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Home Assistant Chrome UI testing skill

What this skill tells your AI

The instructions your AI receives, as published by taurgis/has-marstek-local-api in .agents/skills/homeassistant-chrome-ui-testing/SKILL.md and read by ahel’s review.

Use this when exercising the Marstek custom integration in Chrome against .devcontainer/docker-compose.yml.

Drive the UI with CDP. Do not click screenshot coordinates. Pixel clicks miss Ignore vs Add, Submit, IP focus, overflow Menu, and the wrong Chrome tab. xdotool guessed x,y and computerUse screenshot clicks are forbidden for HA dialogs.

Pick your sandbox

Two environments run this skill and four things differ. Detect, do not assume:

docker info >/dev/null 2>&1 && echo daemon-up || echo daemon-down
command -v google-chrome >/dev/null && echo chrome || echo playwright-chromium
[ -n "$DISPLAY" ] && echo windowed || echo headless
Cursor cloud VMClaude Code sandbox
Dockerservice already running, needs sudostart dockerd yourself, already root
Browser/opt/google/chrome/chrome, windowedPlaywright Chromium, headless
Pythonpython3.venv/bin/python (system python3 lacks aiohttp)
Localhost HTTPdirectexport NO_PROXY='*' no_proxy='*'

ha_cdp.py resolves the browser and headless mode on its own — status and ensure report the chrome_bin and headless they picked. Override with HA_CHROME_BIN / HA_CHROME_HEADLESS. Everything after bring-up is the same on both. Details, including the Docker ICC rules that apply to the Cursor VM only, are in references/SANDBOXES.md.

Restore DevTools first

Both sandboxes run Chrome past Chrome 136 (Chrome 148 on the Cursor VM, Playwright's Chromium 141 in the Claude Code sandbox), so --remote-debugging-port is ignored on the default profile (~/.config/google-chrome). A second launch does not enable CDP either: Chromium ProcessSingleton attaches to the existing process and swallows the new flags. Headless changes none of this.

Always verify, then fix with the helper (do not relaunch by hand unless the helper fails):

# Claude Code sandbox: .venv/bin/python, and export NO_PROXY='*' no_proxy='*' first
python3 .agents/skills/homeassistant-chrome-ui-testing/scripts/ha_cdp.py status
# If ok=false:
python3 .agents/skills/homeassistant-chrome-ui-testing/scripts/ha_cdp.py ensure

ensure quits specific Chrome PIDs (never pkill -f), then launches:

--remote-debugging-port=9222
--remote-allow-origins=*
--user-data-dir=/tmp/chrome-ha-debug
--no-sandbox
--headless=new --disable-gpu   # only when no DISPLAY

Success is GET http://127.0.0.1:9222/json/version returning webSocketDebuggerUrl. If that URL 404s/refuses, CDP is down — do not fall back to pixels.

SymptomCauseFix
/json/version connection refusedFlag ignored (default profile) or Chrome started without itha_cdp.py ensure
Second Chrome command exits immediately; 9222 still closedProcessSingleton reused the live instanceQuit those PIDs, then ensure
HTTP 9222 works, WebSocket 403Client sent Origin; flag missingRelaunch with --remote-allow-origins=*
ensure still failsStale singleton files in the debug profileHelper deletes SingletonLock/Cookie/Socket
ensure reports the wrong chrome_binNo browser at the paths the helper probesSet HA_CHROME_BIN to the binary you have
Everything times out against 127.0.0.1Agent HTTPS proxy intercepting localhostexport NO_PROXY='*' no_proxy='*'

Official notes: Chrome 136 remote-debugging-port, CDP HTTP endpoints. Details in references/CHROME_DEVTOOLS.md.

Drive HA with ha_cdp.py

Script: .agents/skills/homeassistant-chrome-ui-testing/scripts/ha_cdp.py (needs aiohttp).

H=.agents/skills/homeassistant-chrome-ui-testing/scripts/ha_cdp.py
python3 $H dump
python3 $H click Add --near '00:9b:08:a5:aa:39'
python3 $H fill 'IP address' 172.28.0.20
python3 $H fill Port 30000
python3 $H click Submit
python3 $H wait 'already_in_progress' --timeout 8
python3 $H press Escape
python3 $H navigate 'http://127.0.0.1:8123/config/integrations/dashboard'
python3 $H screenshot /tmp/ha.png
python3 $H token
python3 $H entries
python3 $H devices
python3 $H flows
python3 $H wait-flow --unique-id '02:de:ad:be:ef:04' --timeout 700
python3 $H states --prefix venus_c
python3 $H wait-state sensor.venus_c_battery_power --changed --timeout 90
python3 $H service marstek request_data_sync --data '{"device_id":"<id>"}'
python3 $H delete-entry '<entry_id>'
python3 $H click Menu --near 'Marstek VenusD 1 device' --nth 0
python3 $H click Delete
python3 $H click Delete --near 'permanently deleted'
python3 $H device-actions '<device_id>'
python3 $H run-script '{"domain":"marstek","type":"discharge","device_id":"<id>","metadata":{}}'
python3 $H click 'Overflow menu' --near 'Marstek CDP discharge test' --nth 0
python3 $H click 'Run actions'
python3 $H fire-event marstek_cdp_test
python3 $H entities --prefix venus_d
python3 $H device-triggers '<device_id>'
python3 $H diagnostics '<entry_id>'
python3 $H start-reconfigure '<entry_id>'
python3 $H flow-next '<flow_id>' '{"host":"172.28.0.22","port":30001}'
python3 $H start-options '<entry_id>'
python3 $H enable-entity binary_sensor.venus_d_ct_connection
python3 $H upsert-automation marstek_gap_state '{"alias":"...","triggers":[...],"actions":[...]}'
python3 $H notifications
python3 $H disable-entry '<entry_id>'
python3 $H enable-entry '<entry_id>'
python3 $H disable-device '<device_id>'
python3 $H enable-device '<device_id>'
python3 $H disable-entity sensor.venus_d_wifi_signal_strength
python3 $H issues
python3 $H wait-issue --issue-id 'cannot_connect_<entry_id>' --timeout 180
python3 $H start-repair 'cannot_connect_<entry_id>'
python3 $H repair-next '<flow_id>' '{"host":"172.28.0.23","port":30002}'
python3 $H wait-issue --issue-id 'cannot_connect_<entry_id>' --gone --timeout 180
python3 $H get-entry '<entry_id>'
python3 $H update-entry '<entry_id>' --disable-polling true
python3 $H wait-entry '<entry_id>' --state setup_retry --timeout 180
python3 $H ignore-flow '<flow_id>' --title 'Marstek VenusA'
python3 $H ignore-issue 'cannot_connect_<entry_id>'
python3 $H ignore-issue 'cannot_connect_<entry_id>' --unignore
python3 $H rename-device '<device_id>' 'Venus C Garage'
python3 $H set-device-area '<device_id>' living_room
python3 $H create-area Garage
python3 $H create-label battery --color green
python3 $H set-device-labels '<device_id>' battery
python3 $H hide-entity sensor.venus_c_battery_status
python3 $H unhide-entity sensor.venus_c_battery_status
python3 $H expose-entity sensor.venus_c_battery_level
python3 $H history sensor.venus_c_battery_level --hours 2
python3 $H debug-logging --level debug --persistence none
python3 $H energy-validate
python3 $H campaign
python3 $H add-device 172.28.0.20 --port 30000

Rules:

  1. dump before every click. Match visible text / aria-label, not pixels.
  2. Several Add / Menu / Delete buttons exist. Pass --near (MAC, unique_id, Marstek VenusD 1 device, dialog heading) or --nth. If the result is ambiguous, dump and retry — do not guess.
  3. Overflow Menu context is the list-item / config-entry row. Use --near '<title> 1 device' and --nth 0 if the device registry row also matches.
  4. Fill IP/port by field label (IP address, Port). The helper targets ha-form-string / ha-form-integer (HA 2025 wa-input) and types into the focused native input. It must never assign ha-form.value (that clobbers the whole form to a scalar, e.g. Port 30000 wiping the host).
  5. Menu items are ha-dropdown-item. Radio rows on the picker are ha-radio-option (Enter IP/port manually).
  6. computerUse may look at the screen. It must not click HA. xdotool may focus the Chrome window only.
  7. Type HA URLs into navigate (or Chrome’s address bar). Do not walk Overview → Settings → Devices & services unless recording a user-facing demo.

Bring-up

Claude Code sandbox only — no daemon runs at boot, and you are root:

nohup dockerd > /tmp/dockerd.log 2>&1 &
until docker info >/dev/null 2>&1; do sleep 1; done

Then from .devcontainer/ (drop sudo when you are root):

sudo docker compose up -d --build

A bare up starts Home Assistant, the four mocks that have no firmware image and the four default firmware emulators (about half of a 4-CPU sandbox). The other 24 mocks repeat an emulated image and need --profile mocks-all or their name. On a small machine name the services instead: … up -d homeassistant mock-marstek mock-marstek-4 mock-marstek-8 starts three mocks and no emulator, and is enough for same-port pooling plus one unique-port device. Wait until http://127.0.0.1:8123/api/onboarding responds.

Cursor VM only: its iptables-legacy FORWARD policy may drop Docker ICC; if HA cannot ping 172.28.0.26:

sudo iptables-legacy -P FORWARD ACCEPT
sudo iptables-legacy -I FORWARD -i br-+ -j ACCEPT
sudo iptables-legacy -I FORWARD -o br-+ -j ACCEPT

Docker 29 in the Claude Code sandbox routes container-to-container traffic with FORWARD left at DROP. Do not copy those rules there — they fix nothing and mask the real fault.

Mock image must COPY custom_components/marstek/firmware_profile.py and custom_components/marstek/pymarstek/const.py or every mock exits on import.

The integration is bind-mounted at /config/custom_components. Restart marstek-ha-dev after Python edits so HA reimports it.

Login (once)

Fresh marstek-ha-config volume needs onboarding. After that:

FieldValue
URLhttp://127.0.0.1:8123
Usernameadmin
Passwordmarstek-dev

Dismiss the browser “save password” bubble immediately. Skip area assignment.

Reuse a refresh token instead of typing the password. After login, ha_cdp.py token writes /tmp/ha_access_token.txt from the live page (REST tokens expire).

Onboard via REST when the UI wizard would waste recording time (POST /api/onboarding/users, core_config, analytics, then integration with redirect_uri). Required in the headless sandbox, where there is no window to type into.

REST onboarding authenticates your shell, not the tab — ha_cdp.py then fails with Home Assistant is not ready on this tab. Seed the frontend by writing the returned tokens to localStorage.hassTokens over CDP and reloading; see references/SANDBOXES.md.

Fast routes

Base: http://127.0.0.1:8123

PathUse for
/config/integrations/dashboardStart here. Discovered cards + Configured list + “+ Add integration”
/config/integrations/integration/marstekOnly Marstek entries (delete Menu lives here)
/config/devices/dashboardDevice tiles (SoC/mode live check)
/config/entities?domain=marstekEntity states
/config/logsUI log (also sudo docker logs marstek-ha-dev)
/config/automation/dashboardAutomations (singular automation, not automations)
/config/devices/device/<id>Device page: mode select, SoC, power, SYS number/switch
/config/repairsConnection-loss repair issues (cannot_connect_{entry_id})

Mock devices

Only .20, .23, .28 and .46 start by default; the rest need --profile mocks-all or their service name. The Venus A 150 emulator (172.28.0.51:30004) covers a custom port on real firmware by default.

IPPortModelWhat it proves
172.28.0.2030000Venus E 145Same-port share with C
172.28.0.2530000Venus E 150Same-port, SYS/UPS
172.28.0.2230001Venus A 148Custom port + PV
172.28.0.2330002Venus D 145Custom port + PV
172.28.0.2430003Venus A 149Custom port + scaled solar
172.28.0.2630000Venus C 153Issue #60 HMG-50 as VenusC; no SYS; no EM server; omitted GetDevice MACs
172.28.0.2730004Venus A 150SYS/UPS + PV; firmware 150.9 encodings (#57)
172.28.0.2830000Venus E mini 145SYS without 150 gate; slots 0–5; same-port
172.28.0.2930000Venus E 2.0 / HMG-50 153GetDevice VenusE; must not add; no EM server
172.28.0.30–.4830000Archived Control extrasVNSE3-0 144/147/1476/148/149/151, VNSA-0 1487/1508/1509, VNSD-0 147/149/1492/150/151, Venus C 155/156, HMG-50 155/156, Venus E mini VNSEM-0 301

Firmware emulators (vendor Control firmware in Renode, fw-* services, UDP 30000, BLE MAC 02:e0:00:00:00:<last octet>):

IPFirmwareNotes
172.28.0.50 / .51 / .52Venus E 150 / Venus A 150 (2 PV, port 30004) / Venus D 150 (4 PV)Default; healthy ≈100 s after start (docker ps)
172.28.0.53–.65Other VNSE3-0 / VNSA-0 / VNSD-0 images--profile firmware-all only
172.28.0.66HMG-50 Venus C 156 (vendor firmware)Default; drops many requests (#82), config flow may need a retry
172.28.0.67–.69HMG-50 Venus C 153/155, Venus E 2.0 156--profile firmware-hmg50 or firmware-all
172.28.0.70 / .71VNSE3-0 151 / VNSD-0 151 (4 PV)--profile firmware-all only

Discovery reads the paused pooled socket for ports an entry already uses, so emulators on 30000 show up in the picker. If one is missing, it is usually the HMG-50 loss (#82) or an overloaded sandbox, not the port; add it with manual IP. campaign only walks mock-marstek* and starts only Home Assistant and those mocks, so --only also limits what starts. See tools/firmware_emulator/README.md.

Each Renode emulator takes ~0.3-0.6 core in Docker (1-1.5 on the host without --quantum 0.01) and ~550 MB. Run at most nproc of them at once, in named batches (docker compose up -d fw-venus-e-150-ct …, then docker compose rm -sf …), and never --profile firmware-all in a sandbox. See AGENTS.md → Sandbox resource budget.

Unicast check from the HA container (not the VM host):

sudo docker exec -i marstek-ha-dev python3 - <<'PY'
import json, socket
cmd = json.dumps({"id": 1, "method": "Marstek.GetDevice", "params": {"ble_mac": "0"}}).encode()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM); sock.settimeout(3)
sock.bind(("0.0.0.0", 30000))  # firmware replies to the listen port, not the source port
sock.sendto(cmd, ("172.28.0.26", 30000))
print(sock.recvfrom(4096)[0])
PY

Config-flow paths (test all three)

1) Discovered card → Confirm device (scanner)

  1. Open /config/integrations/dashboard.
  2. Under Discovered, a Marstek card has Add.
  3. dump and click Add --near '<unique_id>' (MAC on the flow, not the first Add on the page).
  4. Confirm device has editable IP address and Port. Submit as-is, or fill then Submit.

This is async_step_confirm from SOURCE_INTEGRATION_DISCOVERY. It always unicast-probes with get_device_info. That probe must reuse the pooled UDP client for the target port. Pausing the coordinator listener is not enough (see Same-port GetDevice).

already_in_progress means that MAC already has an open confirm flow — press Escape / click Close, do not start a second manual add for the same device. flows lists those (step_id=confirm).

2) Manual add, no auto-detect

  1. click 'Add integration' → wait → search Marstek.
  2. Wait for the picker (broadcast can take ~10s).
  3. click Enter IP/port manually (a ha-radio-option on the device picker; or land here when discovery is empty).
  4. Submit the picker, then fill 'IP address' / fill Port (labels, not pixels) → Submit with press Enter.

Same unicast probe as Confirm device. Manual does not reuse the cached broadcast result.

3) + Add integration picker

Selecting a listed device creates the entry from discovery data (no extra GetDevice). Fastest happy path when the IP/port in the label is already correct. Broadcast discovery does pause pooled listeners, because it binds its own sockets.

Same-port GetDevice (do not “fix” with pause)

Firmware replies to the device listen port, not the ephemeral source. Per-port sockets are correct for mixed 30000/30001/… installs. Linux SO_REUSEPORT load-balances datagrams across every socket still bound to that port. Pause stops the listener task; it does not unbind.

Wrong path (second bind after pause) live signature:

  • Querying device info from 172.28.0.20:30000
  • UDP socket bound to 0.0.0.0:30000
  • Invalid device response from 172.28.0.26 carrying ES.GetStatus
  • No valid response from device at 172.28.0.20:30000
  • Coordinator Recv of Marstek.GetDevice a few ms later

Right path: get_device_info(..., udp_client=pooled_client). Log: Querying device info from HOST:PORT via pooled UDP client, with no second UDP socket bound line.

Use this when Venus C (172.28.0.26:30000) is already configured and you add Venus E (172.28.0.20:30000) via manual or Confirm device.

If the last entry on that port was deleted, there is no pooled client. Then the log is Querying device info from HOST:PORT without via pooled UDP client. That is expected (unique-port Venus D on 30002 after delete; Venus A on 30001). Same-port re-add while C or another E is still loaded must show via pooled.

Delete, re-add, live updates, actions

Use entries / devices / states / entities before mutating anything. Join key is BLE-MAC on devices[].identifiers. HA 2026.8+ also exposes config_entry_id (one entry per device).

Delete

Prefer REST so the HTTP call stays open until it finishes (config entries; no WebSocket delete):

python3 $H delete-entry '<entry_id>'

UI recipe (when recording): /config/integrations/integration/marstek → overflow Menu --near 'Marstek VenusD 1 device' --nth 0 → Delete (ha-dropdown-item) → confirm Delete --near 'permanently deleted'.

After delete, the scanner should rediscover the MAC. Periodic scan is 10 minutes (SCAN_INTERVAL). Unconfigured discovery is debounced 1 hour only while the MAC stays in _unconfigured_seen; configuring the device prunes that cache, so a delete then next scan can re-create the flow immediately. Poll:

python3 $H wait-flow --unique-id '<ble-mac>' --timeout 700

Then click Add --near '<mac>' on the dashboard. Do not assume 60s.

Disable, connection loss, and repairs

Marstek does not raise ConfigEntryAuthFailed on UDP timeout. Connection loss after setup creates a fixable repair issue (cannot_connect_{entry_id}) once consecutive failures hit failure_threshold (default 3). Setup-time failure raises ConfigEntryNotReady (core retries with backoff) and creates the same issue. Reauth exists in config_flow.py and is unit-tested; live UDP loss uses repairs, not a reauth banner. Official notes: repairs, setup failures, UpdateFailed vs ConfigEntryNotReady.

Disable config entry

WS config_entries/disable (disabled_by is only "user" or null). Unloads async_unload_entry (releases the per-port UDP client when no other loaded entry shares it). Entities leave the state machine. Re-enable sets up the same entry_id and BLE-MAC unique IDs.

python3 $H disable-entry '<entry_id>'
python3 $H states --entity sensor.venus_d_battery_level   # unknown / gone
python3 $H enable-entry '<entry_id>'

UI recipe: /config/integrations/integration/marstek → overflow Menu --near 'Venus D 1 device' --nth 0 → Disable → confirm. Same menu → Enable.

Disable device (keep the config entry)

WS config/device_registry/update disabled_by: user. The entry stays loaded and the coordinator still polls. Entities get disabled_by: device and disappear from the state machine until the device is re-enabled.

Do not set disabled_by=None on a device whose config entry is disabled (HA 2026.8 device registry).

Connection loss (auto-recovery)

Use a unique-port mock (Venus D :30002 / Venus A :30001) so stopping it does not affect the 30000 pool.

  1. Lower failure_threshold to 1 via options (submit all polling_settings / network_settings / power_settings sections; a partial submit wipes the rest).
  2. sudo docker stop marstek-mock-device-4.
  3. service marstek request_data_sync (UDP timeouts; do not treat a long wait as a hang).
  4. wait-issue --issue-id cannot_connect_<entry_id>. Entities become unavailable. Settings sidebar can show a repairs badge. Page: /config/repairs.
  5. sudo docker start marstek-mock-device-4.
  6. Coordinator polling clears the issue (_clear_connection_issue on a good poll). wait-issue --gone and wait-state sensor.venus_d_battery_level until the SoC is numeric again.

The scanner also async_request_scan() at the failure threshold (debounced 30s). If the same BLE-MAC later answers from a new IP, integration discovery updates entry.data[host] and reloads — no user Fix required.

Repair flow (user Fix)

While the issue exists:

python3 $H start-repair 'cannot_connect_<entry_id>'
python3 $H repair-next '<flow_id>' '{"host":"172.28.0.99","port":30002}'   # cannot_connect
python3 $H repair-next '<flow_id>' '{"host":"172.28.0.22","port":30001}'   # unique_id_mismatch (Venus A)
python3 $H repair-next '<flow_id>' '{"host":"172.28.0.23","port":30002}'   # create_entry after mock is up

Repair GetDevice must log via pooled UDP client when another device still owns that port. Unique-port Venus D after disable/unload has no pooled client.

UI: /config/repairs → Fix on “Marstek device not reachable” → IP/port form → Submit.

DHCP async_step_dhcp is the same unique-id updater as the scanner; live DHCP is not exercised in Docker (no DHCP packets). Use scanner rediscovery instead.

Do not enable Bat.GetStatus entities while forcing connection-loss (issue #14).

Re-add

  1. Discovery Confirm (async_step_confirm) — same as path 1 above. Proves pooled GetDevice when another device still owns that UDP port.
  2. Manual IP/port — path 2. Use this after delete when you want to type host/port again without waiting for the scanner.

Entity IDs must come back the same (sensor.venus_d_battery_level, not _2) because unique IDs are BLE-MAC. Check with entities --prefix venus_d. See references/HA_API.md.

Live updates

Mock power/SoC move every coordinator cycle (fast tier default 30s). Do not add per-entity polling.

python3 $H service marstek request_data_sync --data '{"device_id":"<device_id>"}'
python3 $H wait-state sensor.venus_c_battery_power --changed --timeout 90

last_updated changing counts as a change even if the watt value is identical.

Device page + services

On /config/devices/device/<id>: Battery SoC, power, status, operating mode. Venus C / Rev 3.1 Venus E also have Depth of discharge, Bluetooth, Panel LED.

Select entity options: auto, ai, ups (profile-gated). Do not pick manual/passive on the select — those need marstek.set_passive_mode or device actions.

python3 $H service select select_option --data '{"entity_id":"select.venus_c_operating_mode","option":"ai"}'
python3 $H service number set_value --data '{"entity_id":"number.venus_c_depth_of_discharge","value":80}'
python3 $H service switch turn_off --data '{"entity_id":"switch.venus_c_panel_led"}'
python3 $H service marstek set_passive_mode --data '{"device_id":"<id>","power":-400,"duration":90}'

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
35
Forks
5
Last commit
Sep 2026

ahel review

  • K6low
    bundled executables the agent is told to run
  • K1binfo
    installs-packages (in references/SANDBOXES.md)

Automated review, not a security audit. Ruleset v1+k2.

Advanced
Item type
skill
Key
homeassistant-chrome-ui-testing
Source
github.com/taurgis/has-marstek-local-api