Home Assistant Chrome UI testing
SkillWeb & browsingDrive 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.
Account requirements not reviewed. Check the skill instructions before use; ahel provides instructions and does not run this skill.
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 VM | Claude Code sandbox | |
|---|---|---|
| Docker | service already running, needs sudo | start dockerd yourself, already root |
| Browser | /opt/google/chrome/chrome, windowed | Playwright Chromium, headless |
| Python | python3 | .venv/bin/python (system python3 lacks aiohttp) |
| Localhost HTTP | direct | export 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.
| Symptom | Cause | Fix |
|---|---|---|
/json/version connection refused | Flag ignored (default profile) or Chrome started without it | ha_cdp.py ensure |
| Second Chrome command exits immediately; 9222 still closed | ProcessSingleton reused the live instance | Quit those PIDs, then ensure |
| HTTP 9222 works, WebSocket 403 | Client sent Origin; flag missing | Relaunch with --remote-allow-origins=* |
ensure still fails | Stale singleton files in the debug profile | Helper deletes SingletonLock/Cookie/Socket |
ensure reports the wrong chrome_bin | No browser at the paths the helper probes | Set HA_CHROME_BIN to the binary you have |
Everything times out against 127.0.0.1 | Agent HTTPS proxy intercepting localhost | export 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:
dumpbefore every click. Match visible text / aria-label, not pixels.- Several Add / Menu / Delete buttons exist. Pass
--near(MAC, unique_id,Marstek VenusD 1 device, dialog heading) or--nth. If the result isambiguous, dump and retry — do not guess. - Overflow Menu context is the
list-item/ config-entry row. Use--near '<title> 1 device'and--nth 0if the device registry row also matches. - Fill IP/port by field label (
IP address,Port). The helper targetsha-form-string/ha-form-integer(HA 2025wa-input) and types into the focused native input. It must never assignha-form.value(that clobbers the whole form to a scalar, e.g. Port30000wiping the host). - Menu items are
ha-dropdown-item. Radio rows on the picker areha-radio-option(Enter IP/port manually). computerUsemay look at the screen. It must not click HA.xdotoolmay focus the Chrome window only.- 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:
| Field | Value |
|---|---|
| URL | http://127.0.0.1:8123 |
| Username | admin |
| Password | marstek-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
| Path | Use for |
|---|---|
/config/integrations/dashboard | Start here. Discovered cards + Configured list + “+ Add integration” |
/config/integrations/integration/marstek | Only Marstek entries (delete Menu lives here) |
/config/devices/dashboard | Device tiles (SoC/mode live check) |
/config/entities?domain=marstek | Entity states |
/config/logs | UI log (also sudo docker logs marstek-ha-dev) |
/config/automation/dashboard | Automations (singular automation, not automations) |
/config/devices/device/<id> | Device page: mode select, SoC, power, SYS number/switch |
/config/repairs | Connection-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.
| IP | Port | Model | What it proves |
|---|---|---|---|
172.28.0.20 | 30000 | Venus E 145 | Same-port share with C |
172.28.0.25 | 30000 | Venus E 150 | Same-port, SYS/UPS |
172.28.0.22 | 30001 | Venus A 148 | Custom port + PV |
172.28.0.23 | 30002 | Venus D 145 | Custom port + PV |
172.28.0.24 | 30003 | Venus A 149 | Custom port + scaled solar |
172.28.0.26 | 30000 | Venus C 153 | Issue #60 HMG-50 as VenusC; no SYS; no EM server; omitted GetDevice MACs |
172.28.0.27 | 30004 | Venus A 150 | SYS/UPS + PV; firmware 150.9 encodings (#57) |
172.28.0.28 | 30000 | Venus E mini 145 | SYS without 150 gate; slots 0–5; same-port |
172.28.0.29 | 30000 | Venus E 2.0 / HMG-50 153 | GetDevice VenusE; must not add; no EM server |
172.28.0.30–.48 | 30000 | Archived Control extras | VNSE3-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>):
| IP | Firmware | Notes |
|---|---|---|
172.28.0.50 / .51 / .52 | Venus 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–.65 | Other VNSE3-0 / VNSA-0 / VNSD-0 images | --profile firmware-all only |
172.28.0.66 | HMG-50 Venus C 156 (vendor firmware) | Default; drops many requests (#82), config flow may need a retry |
172.28.0.67–.69 | HMG-50 Venus C 153/155, Venus E 2.0 156 | --profile firmware-hmg50 or firmware-all |
172.28.0.70 / .71 | VNSE3-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)
- Open
/config/integrations/dashboard. - Under Discovered, a Marstek card has Add.
dumpandclick Add --near '<unique_id>'(MAC on the flow, not the first Add on the page).- Confirm device has editable IP address and Port. Submit as-is, or
fillthen 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
click 'Add integration'→ wait → searchMarstek.- Wait for the picker (broadcast can take ~10s).
clickEnter IP/port manually (aha-radio-optionon the device picker; or land here when discovery is empty).- Submit the picker, then
fill 'IP address'/fill Port(labels, not pixels) → Submit withpress 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:30000UDP socket bound to 0.0.0.0:30000Invalid device response from 172.28.0.26carryingES.GetStatusNo valid response from device at 172.28.0.20:30000- Coordinator
RecvofMarstek.GetDevicea 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.
- Lower
failure_thresholdto 1 via options (submit allpolling_settings/network_settings/power_settingssections; a partial submit wipes the rest). sudo docker stop marstek-mock-device-4.service marstek request_data_sync(UDP timeouts; do not treat a long wait as a hang).wait-issue --issue-id cannot_connect_<entry_id>. Entities becomeunavailable. Settings sidebar can show a repairs badge. Page:/config/repairs.sudo docker start marstek-mock-device-4.- Coordinator polling clears the issue (
_clear_connection_issueon a good poll).wait-issue --goneandwait-state sensor.venus_d_battery_leveluntil 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
- Discovery Confirm (
async_step_confirm) — same as path 1 above. Proves pooled GetDevice when another device still owns that UDP port. - 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 runK1binfo
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
github.com/taurgis/has-marstek-local-api
Related picks
Skill · wshobson
The pick for Pythonpython-pro
Skill · jeffallan
The pick for Pythondocker-agent-run
Skill · docker
The pick for Dockerdocker-sandbox
Skill · joelhooks
The pick for Dockerhandsontable-playwright-e2e
Skill · handsontable
The pick for End-to-end testingmstar-e2e
Skill · btspoony
The pick for End-to-end testing