Skip to content

Pure-Python local control, replacing the bundled Go binaries (3.0.0) - #23

Merged
arevindh merged 6 commits into
mainfrom
feature/pure-python-local-control
Sep 13, 2026
Merged

arevindh merged 6 commits into
mainfrom
feature/pure-python-local-control

Conversation

@arevindh

@arevindh arevindh commented Sep 13, 2026 •

Copy link
Copy Markdown
Owner

Commands used to be sent by shelling out to a bundled Go CLI, shipped as five
compiled binaries totalling 34.4 MB whose source lived in a separate private
repo. HACS users were installing and executing code they had no way to audit.

That is now about 70 lines of pure Python using nothing outside the standard
library, with output verified identical to the binary it replaces.

Added

  • mDNS discovery, including picking up a new address when DHCP moves a device
  • RSSI, SSID and IP diagnostic sensors. RSSI is disabled by default because it
    changes on nearly every poll and would fill the recorder database
  • Optimistic entity state, so a toggle shows immediately instead of after the
    queue delay plus a poll
  • Command spacing as a configurable option

Fixed

  • Unreachable devices now go unavailable instead of serving their last known
    state indefinitely
  • The coordinator built a second set of hubs without the configured timeout, so
    request_timeout never applied to polling at all. Hubs are built once and
    shared, and the default is now 5s to match what polling has actually been using
  • Failed commands raise instead of logging silently
  • A race in the command queue: deduplication replaced the deque while the worker
    held a reference across its spacing sleep, so operating a second switch on the
    same device moments later dropped the command
  • The device registry rejected firmware as an int, which stops working in
    Home Assistant 2026.12
  • _register_devices ran on every poll rather than once
  • The API token was logged at info level during device lookup

Command spacing

A device accepts at most one command per second, and that limit is per device
rather than per relay, so commands are queued and spaced. Reads are unrestricted
and are not queued.

Upgrading

In place. Entity unique IDs are unchanged, so existing entities, history and
automations survive. Entries created before 3.0.0 adopt their chip id as
unique_id, without which discovery would offer them again and duplicate them.

Two changes are visible: unreachable devices now show as unavailable rather than
stale, and failed commands report an error. Both surface problems that were
previously hidden.

Verification

73 tests against Home Assistant 2026.9.2, with full coverage of the config flow.
Verified end to end on a WIFI_2SWITCH_V1 running firmware 82, and on a live
upgrade from 2.6.0 with three existing devices.

Commands were previously sent by shelling out to a bundled Go CLI, shipped as
five compiled binaries (34.4 MB) whose source lived in a separate private repo.
Those are removed. crypto.py produces the same XXTEA auth token in pure Python
using only the standard library.

Output is byte-identical to the retired tinxy-cli, verified across 319 cases
covering key lengths either side of the 16-byte boundary and timestamps either
side of the 4-byte block boundary. The wire format was captured from the real
binary and matched exactly: POST /toggle with password, action, relayNumber and
optional brightness, brightness omitted when action is 0.

Added:
- mDNS discovery (async_step_zeroconf), including DHCP address tracking
- RSSI, SSID and IP diagnostic sensors; RSSI disabled by default because it
  changes on nearly every poll and fills the recorder database
- optimistic entity state, so a toggle shows immediately instead of after the
  queue delay plus a poll
- command spacing as a configurable option

Fixed:
- unreachable devices now go unavailable instead of serving their last known
  state forever; the coordinator raises UpdateFailed rather than swallowing
- the coordinator built a second set of hubs without the configured timeout, so
  request_timeout never applied to polling. Hubs are now built once and shared.
  DEFAULT_REQUEST_TIMEOUT is 5 to match the value polling has actually been
  running at, since 3 was only ever applied to commands
- failed commands raise HomeAssistantError instead of logging silently
- a race in the command queue: dedup replaced the deque while the worker held a
  reference across its rate-limit sleep, so operating a second switch on the
  same device mid-sleep popped from an orphaned queue and dropped the command
- superseded commands no longer surface as user-facing errors
- device registry rejected firmware as an int; coerced to str at the source,
  which HA 2026.12 will require
- _register_devices ran on every poll instead of once
- the API token was logged at info level during device lookup

Command spacing is 1 second and cannot be set lower. The auth token encrypts a
whole-second timestamp and the device rejects any timestamp it has already seen,
so two commands in the same second cannot both be authenticated. This is per
device, not per relay. Dating the timestamp forward appears to work but sets the
device's high-water mark, locking out honestly-dated commands until real time
catches up: sending now+30 made a device reject normal commands for 30 seconds.
Reads are unauthenticated and unaffected, so polling stays fast.

Entries created before 3.0.0 adopt their chip id as unique_id so discovery
recognises them instead of offering duplicates. Entity unique_ids are unchanged,
so existing entities, history and automations survive the upgrade.

Verified on Home Assistant 2026.9.2 against WIFI_2SWITCH_V1 firmware 82:
discovery, entity creation, relay control and diagnostics, with no errors.
manifest.json was missing `requirements` entirely, which hassfest treats as a
required key. It is empty on purpose: nothing outside the standard library and
Home Assistant's own dependencies is needed.

Also dropped the empty homekit and ssdp discovery blocks, which matched nothing,
and corrected hacs.json, which still advertised switch alone while the
integration now provides fan, lock and sensor as well.
Three schema violations, none of which surfaced until CI ran the real
validators for the first time.

manifest.json carried a `description` key, which is not part of the manifest
schema. strings.json carried a `config.options` block holding two token-choice
labels, which is not part of the translation schema either; those strings were
never rendered, since the choose_token step supplies its own labels through
vol.In. Both predate this branch.

hacs.json declared `domains`, which HACS no longer accepts at all. This branch
had widened it from switch alone to the four platforms the integration now
provides, which is what drew the validator's attention to a key that should not
have been there in the first place.
`homeassistant` is not part of the manifest schema, so hassfest rejects it. The
minimum version belongs in hacs.json, which already declared 2025.4.0, and that
is the copy HACS actually enforces. Nothing reads the manifest key, so this
changes no behaviour. Pre-existing.
domain and name first, then alphabetical. iot_class sorted after issue_tracker
and version after zeroconf.
Matches the pre-release tag, so HACS and the version Home Assistant reports
agree. Bumped to 3.0.0 when the beta is done.
@arevindh
arevindh merged commit 067a297 into main Sep 13, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant