From c710070753982c7e69a53c10e148bf704cffb26d Mon Sep 17 00:00:00 2001 From: mhand <11550632+matthewhand@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:18:43 +1000 Subject: [PATCH 01/12] Add BX2 discovery, dual-outlet start, and GATT notes. Accept BX-prefixed aliases, unlock c001 when present, and let start() select outlet 1 or 2. Document the BX2 payload and pitfalls in docs/BX2.md with no device addresses or site-specific names. --- .gitignore | 3 +++ docs/BX2.md | 67 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 docs/BX2.md diff --git a/.gitignore b/.gitignore index dd2b7e6..3ecd5cb 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,6 @@ ENV/ # Visual Studio Code .vscode/ + +# bluez +bluez diff --git a/docs/BX2.md b/docs/BX2.md new file mode 100644 index 0000000..5e8af5c --- /dev/null +++ b/docs/BX2.md @@ -0,0 +1,67 @@ +# Holman BX2 notes + +Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) up with this SDK. Useful before a PR or another integration. + +## Identity + +- Advertised alias starts with `BX`, not `Tap Timer`. Discovery must accept that prefix. +- The unit we tested advertised vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-…`) is listed here but was **not** seen on that BX2. +- Manufacturer company id `0x0374`. BLE address type is **random**. + +## GATT (safe) + +| UUID | Role | +| --- | --- | +| `c521f000-…` | Vendor service | +| `0000c001-…` | Write. Session unlock `AE 8E` (same 2-byte passcode used by BX1 ESPHome adapters). | +| `0000f006-…` | Write. Manual start/stop. | +| `0000f004-…` | Read. 12-byte state. Last byte `01` means running **when the official app started the valve**. | + +`start()` / `stop()` now send the unlock when `c001` is present. + +## Manual payload + +4 bytes on `f006`: + +``` +[zone, 0x00, 0x00, minutes] +``` + +- `zone` is `1` or `2` (outlet). +- `minutes` is `1…255`. +- Stop is `00 00 00 00`. + +This matches the original single-outlet SDK when `zone=1` (`01 00 00 `). + +A 10-byte ESPHome-style pad (`01 00 00 mins` + six zeros) is accepted if written **without** response. A 10-byte write **with** response returned ATT `0x0e` and dropped the link. + +## What not to do + +- **Do not read `0000e002-…`.** That drops the connection. +- While a run is active, a 4-byte `f006` write **with response** often returns ATT `0x0e`. Stop first, or use write-without-response, then start the other zone. +- First LE connect often fails with `le-connection-abort-by-local` / “failed to discover services, device disconnected”. Retry. Two clients (e.g. Home Assistant Bluetooth + `bluetoothctl`) racing the same adapter makes this worse. +- `f004` last byte is **not a reliable “water is flowing” flag** after an SDK write. The official app sets it to `01`. Our 4-byte start can open the valve while last byte stays `00`. Treat a successful write as optimistic; confirm physically if it matters. + +## Other characteristics + +Seen on the same service, not required for manual run: + +- `f003` — identity blob (MAC bytes reversed), readable without unlock. +- `f005`, `e001`, `c002` — read/write. Unlock may be required. Not needed for start/stop. +- `46a60001-ca26-425a-9bc6-d917829d2906` — write + notify. Untouched. + +Reading `f004` without a prior `c001` unlock can return ATT `0x0e` and drop the link. + +## App pairing + +BlueZ `Paired`/`Bonded` can stay **no**. The Holman app still talks to the timer. Multiple phones can start a manual run. `AE 8E` is a session unlock, not exclusive SMP pairing. The printed manual’s “one smartphone” line is about scheduling ownership, not a hard lock on manual GATT writes. + +## CLI + +``` +holmanctl --discover +holmanctl --start AA:BB:CC:DD:EE:FF --minutes 2 --zone 1 +holmanctl --stop AA:BB:CC:DD:EE:FF +``` + +No device addresses, credentials, or site-specific outlet names belong in this tree. From 38370a855c2b646841e3065127ccf74a838f4ce7 Mon Sep 17 00:00:00 2001 From: mhand <11550632+matthewhand@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:19:58 +1000 Subject: [PATCH 02/12] Add BX2 zone start/stop, alias discovery, and unlock. --- README.md | 36 +++++++++++++++++++++++++--- holman/holman.py | 62 +++++++++++++++++++++++++++++++++++++++--------- holmanctl.py | 34 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+), 14 deletions(-) mode change 100755 => 100644 holmanctl.py diff --git a/README.md b/README.md index 3fa413f..0661de6 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # Holman Python SDK -[Holman CO3015](https://www.holmanindustries.com.au/products/bluetooth-tap-timer-co3015/) -and [BTX1](https://www.holmanindustries.com.au/products/btx1-tap-mounted-smart-valve/) are Bluetooth tap timers made by [Holman](https://www.holmanindustries.com.au/). +[Holman CO3015](https://www.holmanindustries.com.au/products/bluetooth-tap-timer-co3015/), +[BTX1](https://www.holmanindustries.com.au/products/btx1-tap-mounted-smart-valve/), +and [BTX2](https://www.holmanindustries.com.au/products/btx2-dual-outlet-bluetooth-tap-timer/) are Bluetooth tap timers made by [Holman](https://www.holmanindustries.com.au/). The Holman Python SDK for Linux allows you to integrate your Holman(s) into any type of Linux application or script that can execute Python code. @@ -97,6 +98,21 @@ manager.start_discovery() manager.run() ``` +### Configuring accepted device aliases + +By default the SDK accepts devices whose advertised alias starts with `Tap` or `BX`. You can override this with the `accepted_alias_prefixes` constructor argument or the `HOLMAN_ACCEPTED_ALIAS_PREFIXES` environment variable (comma-separated). + +```python +manager = holman.TapTimerManager( + adapter_name='hci0', + accepted_alias_prefixes=('Tap', 'BX', 'Holman')) +``` + +```bash +export HOLMAN_ACCEPTED_ALIAS_PREFIXES="Tap,BX,Holman" +sudo holmanctl --discover +``` + ### Connecting to a Holman tap timer and receiving user input events Once `TapTimerManager` has discovered a Holman tap timer you can use the `TapTimer` object(s) that you retrieved from `TapTimerManager.tap_timers()` to connect to it. Alternatively you can create a new instance of `TapTimer` using the name of your Bluetooth adapter (typically `hci0`) and Holman's MAC address. @@ -121,7 +137,21 @@ As with Holman tap timer discovery, remember to start the Bluetooth event loop w ### Start the tap running -Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass this a runtime (in minutes) for how long to run the tap. +Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` or `zone=2`: + +```python +tap_timer.start(runtime=5, zone=1) +tap_timer.stop() +``` + +From the CLI: + +``` +sudo holmanctl --start AA:BB:CC:DD:EE:FF --minutes 5 --zone 1 +sudo holmanctl --stop AA:BB:CC:DD:EE:FF +``` + +See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read). ## Support diff --git a/holman/holman.py b/holman/holman.py index 1d287d1..2c8c28d 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -1,24 +1,46 @@ """ Module for managing Holman Bluetooth tap timers. """ +import os + import gatt +_DEFAULT_ALIAS_PREFIXES = ('Tap', 'BX') + + +def _get_default_alias_prefixes(): + env = os.environ.get('HOLMAN_ACCEPTED_ALIAS_PREFIXES') + if env: + return tuple(p.strip() for p in env.split(',') if p.strip()) + return _DEFAULT_ALIAS_PREFIXES + + class TapTimerManager(gatt.DeviceManager): """ Entry point for managing and discovering Holman ``TapTimer``s. """ - def __init__(self, adapter_name='hci0'): + def __init__(self, adapter_name='hci0', accepted_alias_prefixes=None): """ Instantiates a ``TapTimerManager`` :param adapter_name: name of Bluetooth adapter used by this tap timer manager + :param accepted_alias_prefixes: tuple of alias prefixes to accept + during discovery. Defaults to + ``('Tap', 'BX')`` or the + ``HOLMAN_ACCEPTED_ALIAS_PREFIXES`` + environment variable (comma-separated). """ - super().__init__(adapter_name) + # DeviceManager.__init__ calls update_devices() -> make_device(), + # which reads accepted_alias_prefixes. + if accepted_alias_prefixes is None: + accepted_alias_prefixes = _get_default_alias_prefixes() + self.accepted_alias_prefixes = tuple(accepted_alias_prefixes) self.listener = None self.discovered_tap_timers = {} + super().__init__(adapter_name) def tap_timers(self): """ @@ -33,12 +55,13 @@ def start_discovery(self, service_uuids=None): Assign a `TapTimerManagerListener` to the `listener` attribute to collect discovered Holmans. """ - super().start_discovery(service_uuids=TapTimer.SERVICE_UUIDS) + super().start_discovery() def make_device(self, mac_address): device = gatt.Device( mac_address=mac_address, manager=self, managed=False) - if device.alias() != 'Tap Timer': + alias = device.alias() or '' + if not any(alias.startswith(p) for p in self.accepted_alias_prefixes): return None return TapTimer(mac_address=mac_address, manager=self) @@ -86,14 +109,17 @@ class TapTimer(gatt.Device): :param listener: instance of ``TapTimerListener`` that will be notified with all events """ - HOLMAN_CO3015_SERVICE_UUID = '0a75f000-f9ad-467a-e564-3c19163ad543' - HOLMAN_CO3011_SERVICE_UUID = 'c521f000-0d70-4d4f-8e43-40d84c50ab38' # model BTX1 + HOLMAN_CO3012_SERVICE_UUID = 'aacaebbb-af4b-baf3-7361-989ffeb0b129' # some BTX2 + HOLMAN_CO3011_SERVICE_UUID = 'c521f000-0d70-4d4f-8e43-40d84c50ab38' # BTX1 / BX2 STATE_CHARACTERISTIC_UUID = '0000f004-0000-1000-8000-00805f9b34fb' MANUAL_CHARACTERISTIC_UUID = '0000f006-0000-1000-8000-00805f9b34fb' + AUTH_CHARACTERISTIC_UUID = '0000c001-0000-1000-8000-00805f9b34fb' + AUTH_PAYLOAD = bytes((0xAE, 0x8E)) SERVICE_UUIDS = [ HOLMAN_CO3015_SERVICE_UUID, + HOLMAN_CO3012_SERVICE_UUID, HOLMAN_CO3011_SERVICE_UUID] def __init__(self, mac_address, manager): @@ -112,6 +138,7 @@ def __init__(self, mac_address, manager): self._battery_level = None self._manual_characteristic = None self._state_characteristic = None + self._auth_characteristic = None self._state = bytes([0]) def connect(self): @@ -181,6 +208,9 @@ def services_resolved(self): "Holman GATT characteristic %s missing", self.STATE_CHARACTERISTIC_UUID)) return + self._auth_characteristic = next(( + char for char in holman_service.characteristics + if char.uuid == self.AUTH_CHARACTERISTIC_UUID), None) self._refresh_state() # TODO: Only fire connected event when we read the firmware @@ -207,19 +237,29 @@ def name(self): """The name of the tap timer.""" return str(self.alias()) - def start(self, runtime=1): + def _unlock(self): + """Best-effort BX1/BX2 session unlock on c001 (AE 8E).""" + if self._auth_characteristic: + self._auth_characteristic.write_value(self.AUTH_PAYLOAD) + + def start(self, runtime=1, zone=1): """ - Turn on the tap for ```runtime``` minutes. + Turn on the tap for ``runtime`` minutes. - :param runtime: the number of minutes to run the tap + On BX2, ``zone`` is outlet 1 or 2 and is written as byte0 of the + 4-byte f006 command. BX1 is a single outlet; zone 1 matches the + original SDK ON payload ``01 00 00 ``. """ - runtime = 255 if runtime > 255 else runtime + runtime = 255 if runtime > 255 else max(1, int(runtime)) + zone = max(1, min(int(zone), 2)) + self._unlock() if self._manual_characteristic: - value = bytes([0x01, 0x00, 0x00, runtime]) + value = bytes([zone, 0x00, 0x00, runtime]) self._manual_characteristic.write_value(value) def stop(self): """Turn off the tap.""" + self._unlock() if self._manual_characteristic: value = bytes([0x00, 0x00, 0x00, 0x00]) self._manual_characteristic.write_value(value) diff --git a/holmanctl.py b/holmanctl.py old mode 100755 new mode 100644 index 55b17a1..3322e19 --- a/holmanctl.py +++ b/holmanctl.py @@ -90,6 +90,18 @@ def main(): metavar='address', type=str, help="Disconnect a Holman tap timer with a given MAC address") + arg_commands_group.add_argument( + '--start', + metavar='address', + type=str, + help="Connect and start a zone for --minutes (default zone 1)") + arg_commands_group.add_argument( + '--stop', + metavar='address', + type=str, + help="Connect and send all-off") + arg_parser.add_argument('--minutes', type=int, default=2, help="Minutes for --start") + arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 or 2 (BX2). Default 1") args = arg_parser.parse_args() global tap_timer_manager @@ -114,6 +126,28 @@ def main(): tap_timer = holman.TapTimer(mac_address=args.disconnect, manager=tap_timer_manager) tap_timer.disconnect() return + elif args.start: + tap_timer = holman.TapTimer(mac_address=args.start, manager=tap_timer_manager) + + class StartListener(TapTimerTestListener): + def connect_succeeded(self): + super().connect_succeeded() + print(f"starting zone={args.zone} minutes={args.minutes}") + self.tap_timer.start(runtime=args.minutes, zone=args.zone) + + tap_timer.listener = StartListener(tap_timer=tap_timer) + tap_timer.connect() + elif args.stop: + tap_timer = holman.TapTimer(mac_address=args.stop, manager=tap_timer_manager) + + class StopListener(TapTimerTestListener): + def connect_succeeded(self): + super().connect_succeeded() + print("stopping") + self.tap_timer.stop() + + tap_timer.listener = StopListener(tap_timer=tap_timer) + tap_timer.connect() print("Terminate with Ctrl+C") try: From 22bbf4448f532e7e9c1af0ec7059350029b5324f Mon Sep 17 00:00:00 2001 From: mhand <11550632+matthewhand@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:25:08 +1000 Subject: [PATCH 03/12] Document BX2 dual-outlet behaviour and PR-facing surface. --- docs/BX2.md | 50 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/BX2.md b/docs/BX2.md index 5e8af5c..a316e2d 100644 --- a/docs/BX2.md +++ b/docs/BX2.md @@ -1,23 +1,26 @@ # Holman BX2 notes -Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) up with this SDK. Useful before a PR or another integration. +Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) up with this SDK. Useful before a PR or another integration. No device addresses, credentials, or site-specific outlet names belong in this tree. + +`AE 8E` is a shared session unlock also used by public BX1 ESPHome configs, not a per-device secret. ## Identity - Advertised alias starts with `BX`, not `Tap Timer`. Discovery must accept that prefix. - The unit we tested advertised vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-…`) is listed here but was **not** seen on that BX2. - Manufacturer company id `0x0374`. BLE address type is **random**. +- Scan advertisements often carry the name and company id only. Do **not** require the vendor service UUID in the advert packet; resolve GATT after connect. That is why `TapTimerManager.start_discovery()` no longer passes `service_uuids=`. ## GATT (safe) | UUID | Role | | --- | --- | | `c521f000-…` | Vendor service | -| `0000c001-…` | Write. Session unlock `AE 8E` (same 2-byte passcode used by BX1 ESPHome adapters). | +| `0000c001-…` | Write. Session unlock `AE 8E`. | | `0000f006-…` | Write. Manual start/stop. | | `0000f004-…` | Read. 12-byte state. Last byte `01` means running **when the official app started the valve**. | -`start()` / `stop()` now send the unlock when `c001` is present. +`start()` / `stop()` send the unlock when `c001` is present. ## Manual payload @@ -27,20 +30,28 @@ Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) [zone, 0x00, 0x00, minutes] ``` -- `zone` is `1` or `2` (outlet). +- `zone` is `1` or `2` (physical outlet). `01` is outlet 1, `02` is outlet 2. Confirmed by starting each outlet from the official app and from this SDK, then watching which valve opened. - `minutes` is `1…255`. -- Stop is `00 00 00 00`. +- Stop is `00 00 00 00` (all-off, both outlets). +- Zone `1` matches the original single-outlet SDK ON payload `01 00 00 `. BX1 stays compatible if callers leave `zone` at the default `1`. + +A 10-byte ESPHome-style pad (`01 00 00 mins` + six zeros) is accepted if written **without** response. A 10-byte write **with** response returned ATT `0x0e` and dropped the link. Prefer the 4-byte form. -This matches the original single-outlet SDK when `zone=1` (`01 00 00 `). +If a 4-byte write **with** response fails (ATT `0x0e`), retry **without** response. That is common while a run is already active. -A 10-byte ESPHome-style pad (`01 00 00 mins` + six zeros) is accepted if written **without** response. A 10-byte write **with** response returned ATT `0x0e` and dropped the link. +## Dual outlet behaviour + +- The tap can run **one outlet at a time**. Starting zone 2 while zone 1 is open is not a second concurrent valve. +- To switch outlets: write stop (`00 00 00 00`), then start the other zone. A start-while-running write with response often errors; stop first. +- There is no extra “reset” characteristic required after a manual run. Stop is the all-zero `f006` write. ## What not to do - **Do not read `0000e002-…`.** That drops the connection. -- While a run is active, a 4-byte `f006` write **with response** often returns ATT `0x0e`. Stop first, or use write-without-response, then start the other zone. -- First LE connect often fails with `le-connection-abort-by-local` / “failed to discover services, device disconnected”. Retry. Two clients (e.g. Home Assistant Bluetooth + `bluetoothctl`) racing the same adapter makes this worse. +- Reading `f004` without a prior `c001` unlock can also return ATT `0x0e` and drop the link. +- First LE connect often fails with `le-connection-abort-by-local` / “failed to discover services, device disconnected”. Retry. Two clients (Home Assistant Bluetooth + `bluetoothctl`, or two phones plus the SDK) racing the same adapter makes this worse. - `f004` last byte is **not a reliable “water is flowing” flag** after an SDK write. The official app sets it to `01`. Our 4-byte start can open the valve while last byte stays `00`. Treat a successful write as optimistic; confirm physically if it matters. +- A successful GATT write can still look dry if that outlet’s hose or nozzle is blocked. Confirm water, not only BLE ACKs. We spent a long time permuting payloads before finding a blocked hose. ## Other characteristics @@ -50,11 +61,24 @@ Seen on the same service, not required for manual run: - `f005`, `e001`, `c002` — read/write. Unlock may be required. Not needed for start/stop. - `46a60001-ca26-425a-9bc6-d917829d2906` — write + notify. Untouched. -Reading `f004` without a prior `c001` unlock can return ATT `0x0e` and drop the link. +## App pairing vs session unlock + +BlueZ `Paired`/`Bonded` can stay **no**. The Holman app still talks to the timer. Multiple phones can start a **manual** run at the same time. `AE 8E` is a session unlock, not exclusive SMP pairing. -## App pairing +The printed manual’s “one smartphone” line is about **scheduling ownership**, not a hard lock on manual GATT writes. The physical dial can still disable onboard schedules; that does not block these manual `f006` writes. -BlueZ `Paired`/`Bonded` can stay **no**. The Holman app still talks to the timer. Multiple phones can start a manual run. `AE 8E` is a session unlock, not exclusive SMP pairing. The printed manual’s “one smartphone” line is about scheduling ownership, not a hard lock on manual GATT writes. +## Suggested PR surface + +Keep a future upstream PR to the behaviour change, not this whole note: + +1. Accept `BX*` aliases (and optionally `HOLMAN_ACCEPTED_ALIAS_PREFIXES`). +2. Discover by alias, not advertised service UUID. +3. Unlock `c001` with `AE 8E` when the characteristic exists. +4. `start(runtime, zone=1)` writes `[zone, 0, 0, mins]`; `stop()` writes zeros. +5. CLI `--start` / `--stop` / `--minutes` / `--zone`. +6. README mention of BTX2 / BX2 and a link here. + +Leave Home Assistant bindings, retries, outlet nicknames, and site addresses out of the SDK. ## CLI @@ -63,5 +87,3 @@ holmanctl --discover holmanctl --start AA:BB:CC:DD:EE:FF --minutes 2 --zone 1 holmanctl --stop AA:BB:CC:DD:EE:FF ``` - -No device addresses, credentials, or site-specific outlet names belong in this tree. From 4d68642e700bc8828c8131c170f79238c9c51a27 Mon Sep 17 00:00:00 2001 From: mhand <11550632+matthewhand@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:09:35 +0000 Subject: [PATCH 04/12] Fix BX2 hose start to [0x01, 0x01, 0x00, mins] (byte 1 is the tap). --- README.md | 6 +-- docs/BX2.md | 26 +++++++++---- docs/homeassistant-example.md | 71 +++++++++++++++++++++++++++++++++++ holman/holman.py | 16 +++++--- holmanctl.py | 2 +- 5 files changed, 105 insertions(+), 16 deletions(-) create mode 100644 docs/homeassistant-example.md diff --git a/README.md b/README.md index 0661de6..fa477c1 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ manager.run() ### Configuring accepted device aliases -By default the SDK accepts devices whose advertised alias starts with `Tap` or `BX`. You can override this with the `accepted_alias_prefixes` constructor argument or the `HOLMAN_ACCEPTED_ALIAS_PREFIXES` environment variable (comma-separated). +By default the SDK accepts devices whose advertised alias starts with `Tap` (BX1 / Tap Timer) or `BX` (BX2 and current BX-prefixed units). Override `accepted_alias_prefixes` or set `HOLMAN_ACCEPTED_ALIAS_PREFIXES` (comma-separated) when a future unit such as BX3 advertises a different prefix. ```python manager = holman.TapTimerManager( @@ -137,7 +137,7 @@ As with Holman tap timer discovery, remember to start the Bluetooth event loop w ### Start the tap running -Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` or `zone=2`: +Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` (Grass, `[0x01, 0x00, 0x00, mins]`) or `zone=2` (Hose, `[0x01, 0x01, 0x00, mins]`). Byte 1 is the outlet. Do not put `0x02` in byte 0 or byte 1 — those writes ACK and stay dry. ```python tap_timer.start(runtime=5, zone=1) @@ -151,7 +151,7 @@ sudo holmanctl --start AA:BB:CC:DD:EE:FF --minutes 5 --zone 1 sudo holmanctl --stop AA:BB:CC:DD:EE:FF ``` -See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read). +See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read) and [docs/homeassistant-example.md](docs/homeassistant-example.md) for a generic Home Assistant / Lovelace sketch (zone 1 Grass, zone 2 Hose; no site addresses). ## Support diff --git a/docs/BX2.md b/docs/BX2.md index a316e2d..6678455 100644 --- a/docs/BX2.md +++ b/docs/BX2.md @@ -26,14 +26,26 @@ Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) 4 bytes on `f006`: -``` -[zone, 0x00, 0x00, minutes] -``` + [0x01, tap, 0x00, minutes] + +| Zone | Name | Start write | Hex | +| --- | --- | --- | --- | +| 1 | Grass | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | +| 2 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | +| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` | -- `zone` is `1` or `2` (physical outlet). `01` is outlet 1, `02` is outlet 2. Confirmed by starting each outlet from the official app and from this SDK, then watching which valve opened. +- Byte 0 is on/off (`0x01` start, `0x00` stop). It is **not** the zone. +- Byte 1 is the outlet: `0x00` Grass, `0x01` Hose. - `minutes` is `1…255`. -- Stop is `00 00 00 00` (all-off, both outlets). -- Zone `1` matches the original single-outlet SDK ON payload `01 00 00 `. BX1 stays compatible if callers leave `zone` at the default `1`. +- Stop is all-off (both outlets). +- Zone 1 matches the original single-outlet SDK ON payload `01 00 00 `. BX1 stays compatible if callers leave `zone` at the default `1`. + +Do **not** treat `0x02` as Hose. Two dry-but-ACK writes we hit while mapping the second tap: + +- `[0x02, 0x00, 0x00, mins]` (zone number stuffed into byte 0) +- `[0x01, 0x02, 0x00, mins]` (hex `010200NN`) + +The first wet Hose write was `01010001`. `01020001` was a residual probe and stayed dry. A 10-byte ESPHome-style pad (`01 00 00 mins` + six zeros) is accepted if written **without** response. A 10-byte write **with** response returned ATT `0x0e` and dropped the link. Prefer the 4-byte form. @@ -74,7 +86,7 @@ Keep a future upstream PR to the behaviour change, not this whole note: 1. Accept `BX*` aliases (and optionally `HOLMAN_ACCEPTED_ALIAS_PREFIXES`). 2. Discover by alias, not advertised service UUID. 3. Unlock `c001` with `AE 8E` when the characteristic exists. -4. `start(runtime, zone=1)` writes `[zone, 0, 0, mins]`; `stop()` writes zeros. +4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` Grass / `0x01` Hose); `stop()` writes zeros. 5. CLI `--start` / `--stop` / `--minutes` / `--zone`. 6. README mention of BTX2 / BX2 and a link here. diff --git a/docs/homeassistant-example.md b/docs/homeassistant-example.md new file mode 100644 index 0000000..d171e6b --- /dev/null +++ b/docs/homeassistant-example.md @@ -0,0 +1,71 @@ +# Home Assistant / Lovelace example (generic) + +Zone 1 = Grass. Zone 2 = Hose. One outlet at a time. No site IPs or MACs. + +f006 start bytes used by this SDK (and by a `holman_bt` custom component that +speaks the same GATT): + +| Zone | Name | Start | Hex | +| --- | --- | --- | --- | +| 1 | Grass | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | +| 2 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | +| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` | + +Do not send `0x02` as Hose. `[0x02, 0x00, 0x00, mins]` and +`[0x01, 0x02, 0x00, mins]` both ACK and stay dry. + +## Package snippet + +```yaml +holman_bt: + - mac: AA:BB:CC:DD:EE:FF + name: Holman BX2 + default_runtime: 5 +``` + +## Lovelace cards + +Entities are named from the device name plus Grass / Hose +(`switch.holman_bx2_grass`, `switch.holman_bx2_hose`). + +```yaml +type: vertical-stack +cards: + - type: entities + title: Holman BX2 + entities: + - entity: number.holman_bx2_runtime + name: Minutes + - entity: switch.holman_bx2_grass + name: Grass + icon: mdi:grass + - entity: switch.holman_bx2_hose + name: Hose + icon: mdi:hose + - type: horizontal-stack + cards: + - type: button + name: Start Grass + icon: mdi:grass + tap_action: + action: call-service + service: holman_bt.start + data: + zone: 1 + minutes: 5 + - type: button + name: Start Hose + icon: mdi:hose + tap_action: + action: call-service + service: holman_bt.start + data: + zone: 2 + minutes: 5 + - type: button + name: Stop + icon: mdi:water-off + tap_action: + action: call-service + service: holman_bt.stop +``` diff --git a/holman/holman.py b/holman/holman.py index 2c8c28d..b1314ab 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -243,18 +243,24 @@ def _unlock(self): self._auth_characteristic.write_value(self.AUTH_PAYLOAD) def start(self, runtime=1, zone=1): - """ + ''' Turn on the tap for ``runtime`` minutes. - On BX2, ``zone`` is outlet 1 or 2 and is written as byte0 of the - 4-byte f006 command. BX1 is a single outlet; zone 1 matches the + f006 start is ``[0x01, tap, 0x00, minutes]``. Byte 0 is on/off + (always 0x01 for start). Byte 1 is the outlet: 0x00 Grass + (zone 1), 0x01 Hose (zone 2). Hose is hex ``010100NN``. + + Do not write the zone number into byte 0. ``[0x02, 0x00, 0x00, + mins]`` ACKs and stays dry. ``[0x01, 0x02, 0x00, mins]`` is the + same: ACK, no jet. BX1 is a single outlet; zone 1 matches the original SDK ON payload ``01 00 00 ``. - """ + ''' runtime = 255 if runtime > 255 else max(1, int(runtime)) zone = max(1, min(int(zone), 2)) + tap = 0x00 if zone == 1 else 0x01 self._unlock() if self._manual_characteristic: - value = bytes([zone, 0x00, 0x00, runtime]) + value = bytes([0x01, tap, 0x00, runtime]) self._manual_characteristic.write_value(value) def stop(self): diff --git a/holmanctl.py b/holmanctl.py index 3322e19..77db575 100644 --- a/holmanctl.py +++ b/holmanctl.py @@ -101,7 +101,7 @@ def main(): type=str, help="Connect and send all-off") arg_parser.add_argument('--minutes', type=int, default=2, help="Minutes for --start") - arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 or 2 (BX2). Default 1") + arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 Grass or 2 Hose (BX2). Default 1") args = arg_parser.parse_args() global tap_timer_manager From 68fff654c11f4449979633ec8944a02109c26e69 Mon Sep 17 00:00:00 2001 From: mhand <11550632+matthewhand@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:13:07 +0000 Subject: [PATCH 05/12] Add payload helpers, fail-closed zone guards, debug hex logs, and tests. --- README.md | 9 +++++ holman/__init__.py | 2 + holman/aliases.py | 17 ++++++++ holman/holman.py | 51 +++++++++++------------- holman/payload.py | 60 ++++++++++++++++++++++++++++ holmanctl.py | 19 ++++++++- tests/__init__.py | 0 tests/test_payload.py | 92 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 220 insertions(+), 30 deletions(-) create mode 100644 holman/aliases.py create mode 100644 holman/payload.py create mode 100644 tests/__init__.py create mode 100644 tests/test_payload.py diff --git a/README.md b/README.md index fa477c1..e192005 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,15 @@ From the CLI: ``` sudo holmanctl --start AA:BB:CC:DD:EE:FF --minutes 5 --zone 1 sudo holmanctl --stop AA:BB:CC:DD:EE:FF +sudo holmanctl --debug --start AA:BB:CC:DD:EE:FF --minutes 5 --zone 2 +``` + +`--debug` logs the f006 write as hex (`01010005` for Hose, 5 minutes). Unknown `--zone` values are refused. + +Payload helpers are pure functions (`holman.payload.manual_payload`) and have unit tests that do not need Bluetooth: + +``` +python3 -m unittest discover -s tests -v ``` See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read) and [docs/homeassistant-example.md](docs/homeassistant-example.md) for a generic Home Assistant / Lovelace sketch (zone 1 Grass, zone 2 Hose; no site addresses). diff --git a/holman/__init__.py b/holman/__init__.py index 1456ceb..d795963 100644 --- a/holman/__init__.py +++ b/holman/__init__.py @@ -1 +1,3 @@ from .holman import TapTimerManager, TapTimerManagerListener, TapTimer, TapTimerListener +from .payload import clamp_runtime, manual_payload, tap_for_zone, tap_name +from .aliases import get_default_alias_prefixes diff --git a/holman/aliases.py b/holman/aliases.py new file mode 100644 index 0000000..3e1fa3c --- /dev/null +++ b/holman/aliases.py @@ -0,0 +1,17 @@ +''' +Advertised-name prefixes accepted during discovery. +''' + +import os + +DEFAULT_ALIAS_PREFIXES = ('Tap', 'BX') + + +def get_default_alias_prefixes(): + ''' + Tap (BX1) and BX (BX2) by default. Override for a future BX3. + ''' + env = os.environ.get('HOLMAN_ACCEPTED_ALIAS_PREFIXES') + if env: + return tuple(p.strip() for p in env.split(',') if p.strip()) + return DEFAULT_ALIAS_PREFIXES diff --git a/holman/holman.py b/holman/holman.py index b1314ab..3ea562c 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -1,19 +1,14 @@ """ Module for managing Holman Bluetooth tap timers. """ -import os +import logging import gatt +from .aliases import get_default_alias_prefixes +from .payload import manual_payload, tap_name -_DEFAULT_ALIAS_PREFIXES = ('Tap', 'BX') - - -def _get_default_alias_prefixes(): - env = os.environ.get('HOLMAN_ACCEPTED_ALIAS_PREFIXES') - if env: - return tuple(p.strip() for p in env.split(',') if p.strip()) - return _DEFAULT_ALIAS_PREFIXES +_LOGGER = logging.getLogger(__name__) class TapTimerManager(gatt.DeviceManager): @@ -36,7 +31,7 @@ def __init__(self, adapter_name='hci0', accepted_alias_prefixes=None): # DeviceManager.__init__ calls update_devices() -> make_device(), # which reads accepted_alias_prefixes. if accepted_alias_prefixes is None: - accepted_alias_prefixes = _get_default_alias_prefixes() + accepted_alias_prefixes = get_default_alias_prefixes() self.accepted_alias_prefixes = tuple(accepted_alias_prefixes) self.listener = None self.discovered_tap_timers = {} @@ -246,29 +241,29 @@ def start(self, runtime=1, zone=1): ''' Turn on the tap for ``runtime`` minutes. - f006 start is ``[0x01, tap, 0x00, minutes]``. Byte 0 is on/off - (always 0x01 for start). Byte 1 is the outlet: 0x00 Grass - (zone 1), 0x01 Hose (zone 2). Hose is hex ``010100NN``. - - Do not write the zone number into byte 0. ``[0x02, 0x00, 0x00, - mins]`` ACKs and stays dry. ``[0x01, 0x02, 0x00, mins]`` is the - same: ACK, no jet. BX1 is a single outlet; zone 1 matches the - original SDK ON payload ``01 00 00 ``. + Builds the same 4-byte f006 payload as ``manual_payload``: + Grass ``[0x01, 0x00, 0x00, mins]``, Hose ``[0x01, 0x01, 0x00, mins]``. + Unknown zones raise ``ValueError`` (fail closed). Runtime is + clamped to 1..255. Missing f006 raises ``RuntimeError``. ''' - runtime = 255 if runtime > 255 else max(1, int(runtime)) - zone = max(1, min(int(zone), 2)) - tap = 0x00 if zone == 1 else 0x01 + value = manual_payload(True, runtime, zone) + _LOGGER.debug( + 'f006 write {} zone={} name={}'.format( + value.hex(), zone, tap_name(zone)), + ) self._unlock() - if self._manual_characteristic: - value = bytes([0x01, tap, 0x00, runtime]) - self._manual_characteristic.write_value(value) + if not self._manual_characteristic: + raise RuntimeError('Holman GATT characteristic f006 missing') + self._manual_characteristic.write_value(value) def stop(self): - """Turn off the tap.""" + '''Turn off the tap (all-zero f006).''' + value = manual_payload(False) + _LOGGER.debug('f006 write {} stop'.format(value.hex())) self._unlock() - if self._manual_characteristic: - value = bytes([0x00, 0x00, 0x00, 0x00]) - self._manual_characteristic.write_value(value) + if not self._manual_characteristic: + raise RuntimeError('Holman GATT characteristic f006 missing') + self._manual_characteristic.write_value(value) def characteristic_write_value_succeeded(self, characteristic): self._refresh_state() diff --git a/holman/payload.py b/holman/payload.py new file mode 100644 index 0000000..40a0a04 --- /dev/null +++ b/holman/payload.py @@ -0,0 +1,60 @@ +''' +f006 manual payloads. No BLE imports. + +Byte 0 is on/off. Byte 1 is the outlet. Grass is the original SDK +start [0x01, 0x00, 0x00, minutes]. Hose is [0x01, 0x01, 0x00, minutes] +(hex 010100NN). 0x02 is not Hose: both [0x02, 0x00, 0x00, mins] and +[0x01, 0x02, 0x00, mins] ACK and stay dry. +''' + +CMD_ON = 0x01 +CMD_OFF = 0x00 +PAD_BYTE = 0x00 +TAP_GRASS = 0x00 +TAP_HOSE = 0x01 +TAP_BY_ZONE = {1: TAP_GRASS, 2: TAP_HOSE} +ZONE_NAMES = {1: 'Grass', 2: 'Hose'} +ZONES = (1, 2) +DEFAULT_ZONE = 1 +RUNTIME_MIN = 1 +RUNTIME_MAX = 255 + + +def clamp_runtime(minutes): + return max(RUNTIME_MIN, min(int(minutes), RUNTIME_MAX)) + + +def tap_for_zone(zone): + ''' + Map zone 1/2 to the f006 tap byte. Unknown zones fail closed. + ''' + try: + key = int(zone) + except (TypeError, ValueError) as err: + raise ValueError( + 'unknown Holman zone %r; expected %s' % (zone, ZONES) + ) from err + try: + return TAP_BY_ZONE[key] + except KeyError as err: + raise ValueError( + 'unknown Holman zone %r; expected %s' % (zone, ZONES) + ) from err + + +def tap_name(zone): + try: + return ZONE_NAMES[int(zone)] + except (KeyError, TypeError, ValueError): + return 'zone %s' % zone + + +def manual_payload(on, minutes=5, zone=DEFAULT_ZONE): + ''' + 4-byte f006 write: stop is all-zero; start is [CMD_ON, tap, PAD, mins]. + ''' + if not on: + return bytes([CMD_OFF, CMD_OFF, CMD_OFF, CMD_OFF]) + tap = tap_for_zone(zone) + mins = clamp_runtime(minutes) + return bytes([CMD_ON, tap, PAD_BYTE, mins]) diff --git a/holmanctl.py b/holmanctl.py index 77db575..bc47f6a 100644 --- a/holmanctl.py +++ b/holmanctl.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 +import logging import sys from argparse import ArgumentParser import holman @@ -102,7 +103,12 @@ def main(): help="Connect and send all-off") arg_parser.add_argument('--minutes', type=int, default=2, help="Minutes for --start") arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 Grass or 2 Hose (BX2). Default 1") + arg_parser.add_argument( + '--debug', action='store_true', + help='Log f006 write hex and BLE debug to stderr') args = arg_parser.parse_args() + if args.debug: + logging.basicConfig(level=logging.DEBUG) global tap_timer_manager tap_timer_manager = holman.TapTimerManager(adapter_name=args.adapter) @@ -132,8 +138,17 @@ def main(): class StartListener(TapTimerTestListener): def connect_succeeded(self): super().connect_succeeded() - print(f"starting zone={args.zone} minutes={args.minutes}") - self.tap_timer.start(runtime=args.minutes, zone=args.zone) + from holman.payload import manual_payload, tap_name + try: + payload = manual_payload(True, args.minutes, args.zone) + print( + 'starting zone={} ({}) minutes={} payload={}'.format( + args.zone, tap_name(args.zone), args.minutes, payload.hex())) + self.tap_timer.start(runtime=args.minutes, zone=args.zone) + except ValueError as err: + print('start refused: {}'.format(err)) + tap_timer_manager.stop() + sys.exit(2) tap_timer.listener = StartListener(tap_timer=tap_timer) tap_timer.connect() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_payload.py b/tests/test_payload.py new file mode 100644 index 0000000..16fd632 --- /dev/null +++ b/tests/test_payload.py @@ -0,0 +1,92 @@ +"Shipped f006 payload builder — import the real function, not a copy." + +import importlib.util +import os +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] / 'holman' + + +def _load(name, filename): + spec = importlib.util.spec_from_file_location(name, ROOT / filename) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +payload = _load('holman.payload', 'payload.py') +aliases = _load('holman.aliases', 'aliases.py') +manual_payload = payload.manual_payload +tap_for_zone = payload.tap_for_zone +tap_name = payload.tap_name +clamp_runtime = payload.clamp_runtime +get_default_alias_prefixes = aliases.get_default_alias_prefixes + + +class TestManualPayload(unittest.TestCase): + def test_start_zone_1_grass(self): + self.assertEqual(list(manual_payload(True, 3, 1)), [0x01, 0x00, 0x00, 3]) + + def test_start_zone_2_hose(self): + self.assertEqual(list(manual_payload(True, 1, 2)), [0x01, 0x01, 0x00, 1]) + + def test_stop_all_zero(self): + self.assertEqual(list(manual_payload(False, 10, 2)), [0x00, 0x00, 0x00, 0x00]) + self.assertEqual(list(manual_payload(False, 0, 0)), [0x00, 0x00, 0x00, 0x00]) + + def test_minutes_clamped(self): + self.assertEqual(list(manual_payload(True, 0, 2))[3], 1) + self.assertEqual(list(manual_payload(True, 999, 1))[3], 255) + self.assertEqual(clamp_runtime(0), 1) + self.assertEqual(clamp_runtime(999), 255) + + def test_invalid_zone_fails_closed(self): + with self.assertRaises(ValueError): + manual_payload(True, 5, 3) + with self.assertRaises(ValueError): + manual_payload(True, 5, 0) + with self.assertRaises(ValueError): + tap_for_zone(9) + with self.assertRaises(ValueError): + tap_for_zone('hose') + + def test_never_emits_0x02_as_hose(self): + grass = list(manual_payload(True, 5, 1)) + hose = list(manual_payload(True, 5, 2)) + self.assertNotIn(0x02, grass) + self.assertNotIn(0x02, hose) + self.assertEqual(hose, [0x01, 0x01, 0x00, 5]) + + def test_tap_helpers(self): + self.assertEqual(tap_for_zone(1), 0x00) + self.assertEqual(tap_for_zone(2), 0x01) + self.assertEqual(tap_name(1), 'Grass') + self.assertEqual(tap_name(2), 'Hose') + + +class TestAliasPrefixes(unittest.TestCase): + def test_default_accepts_tap_and_bx(self): + old = os.environ.pop('HOLMAN_ACCEPTED_ALIAS_PREFIXES', None) + try: + self.assertEqual(get_default_alias_prefixes(), ('Tap', 'BX')) + finally: + if old is not None: + os.environ['HOLMAN_ACCEPTED_ALIAS_PREFIXES'] = old + + def test_env_override_for_future_bx3(self): + old = os.environ.get('HOLMAN_ACCEPTED_ALIAS_PREFIXES') + os.environ['HOLMAN_ACCEPTED_ALIAS_PREFIXES'] = 'Tap,BX,BX3' + try: + self.assertEqual(get_default_alias_prefixes(), ('Tap', 'BX', 'BX3')) + finally: + if old is None: + os.environ.pop('HOLMAN_ACCEPTED_ALIAS_PREFIXES', None) + else: + os.environ['HOLMAN_ACCEPTED_ALIAS_PREFIXES'] = old + + +if __name__ == '__main__': + unittest.main() From 7a7ad02a2f25515a6d491db6fb01585824ec5ee0 Mon Sep 17 00:00:00 2001 From: Matthew Hand Date: Sat, 15 Aug 2026 22:23:44 +0000 Subject: [PATCH 06/12] Use generic zone 1 / zone 2 language in BX2 docs and comments. --- README.md | 4 ++-- docs/BX2.md | 31 ++++++++++----------------- docs/homeassistant-example.md | 40 ++++++++++++++++------------------- holman/holman.py | 12 ++++------- holmanctl.py | 2 +- 5 files changed, 36 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index fa477c1..d8cb8de 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ As with Holman tap timer discovery, remember to start the Bluetooth event loop w ### Start the tap running -Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` (Grass, `[0x01, 0x00, 0x00, mins]`) or `zone=2` (Hose, `[0x01, 0x01, 0x00, mins]`). Byte 1 is the outlet. Do not put `0x02` in byte 0 or byte 1 — those writes ACK and stay dry. +Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` (tap 0, `[0x01, 0x00, 0x00, mins]`) or `zone=2` (tap 1, `[0x01, 0x01, 0x00, mins]`). Byte 1 is the outlet. ```python tap_timer.start(runtime=5, zone=1) @@ -151,7 +151,7 @@ sudo holmanctl --start AA:BB:CC:DD:EE:FF --minutes 5 --zone 1 sudo holmanctl --stop AA:BB:CC:DD:EE:FF ``` -See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read) and [docs/homeassistant-example.md](docs/homeassistant-example.md) for a generic Home Assistant / Lovelace sketch (zone 1 Grass, zone 2 Hose; no site addresses). +See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read) and [docs/homeassistant-example.md](docs/homeassistant-example.md) for a generic Home Assistant / Lovelace sketch (zone 1 / zone 2; no site addresses). ## Support diff --git a/docs/BX2.md b/docs/BX2.md index 6678455..59278cd 100644 --- a/docs/BX2.md +++ b/docs/BX2.md @@ -1,13 +1,13 @@ # Holman BX2 notes -Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) up with this SDK. Useful before a PR or another integration. No device addresses, credentials, or site-specific outlet names belong in this tree. +Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with this SDK. No device addresses, credentials, or site-specific outlet names belong in this tree. `AE 8E` is a shared session unlock also used by public BX1 ESPHome configs, not a per-device secret. ## Identity - Advertised alias starts with `BX`, not `Tap Timer`. Discovery must accept that prefix. -- The unit we tested advertised vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-…`) is listed here but was **not** seen on that BX2. +- A BX2 may advertise vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-…`) is listed here but is not required for discovery. - Manufacturer company id `0x0374`. BLE address type is **random**. - Scan advertisements often carry the name and company id only. Do **not** require the vendor service UUID in the advert packet; resolve GATT after connect. That is why `TapTimerManager.start_discovery()` no longer passes `service_uuids=`. @@ -28,25 +28,18 @@ Field notes from bringing a Holman **BX2** (dual outlet, advertised name `BX2`) [0x01, tap, 0x00, minutes] -| Zone | Name | Start write | Hex | +| Zone | Tap | Start write | Hex | | --- | --- | --- | --- | -| 1 | Grass | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | -| 2 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | -| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` | +| 1 | 0 | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | +| 2 | 1 | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | +| — | — | Stop `[0x00, 0x00, 0x00, 0x00]` | `00000000` | -- Byte 0 is on/off (`0x01` start, `0x00` stop). It is **not** the zone. -- Byte 1 is the outlet: `0x00` Grass, `0x01` Hose. +- Byte 0 is on/off (`0x01` start, `0x00` stop). +- Byte 1 is the outlet: tap `0x00` = zone 1, tap `0x01` = zone 2. - `minutes` is `1…255`. - Stop is all-off (both outlets). - Zone 1 matches the original single-outlet SDK ON payload `01 00 00 `. BX1 stays compatible if callers leave `zone` at the default `1`. -Do **not** treat `0x02` as Hose. Two dry-but-ACK writes we hit while mapping the second tap: - -- `[0x02, 0x00, 0x00, mins]` (zone number stuffed into byte 0) -- `[0x01, 0x02, 0x00, mins]` (hex `010200NN`) - -The first wet Hose write was `01010001`. `01020001` was a residual probe and stayed dry. - A 10-byte ESPHome-style pad (`01 00 00 mins` + six zeros) is accepted if written **without** response. A 10-byte write **with** response returned ATT `0x0e` and dropped the link. Prefer the 4-byte form. If a 4-byte write **with** response fails (ATT `0x0e`), retry **without** response. That is common while a run is already active. @@ -62,8 +55,8 @@ If a 4-byte write **with** response fails (ATT `0x0e`), retry **without** respon - **Do not read `0000e002-…`.** That drops the connection. - Reading `f004` without a prior `c001` unlock can also return ATT `0x0e` and drop the link. - First LE connect often fails with `le-connection-abort-by-local` / “failed to discover services, device disconnected”. Retry. Two clients (Home Assistant Bluetooth + `bluetoothctl`, or two phones plus the SDK) racing the same adapter makes this worse. -- `f004` last byte is **not a reliable “water is flowing” flag** after an SDK write. The official app sets it to `01`. Our 4-byte start can open the valve while last byte stays `00`. Treat a successful write as optimistic; confirm physically if it matters. -- A successful GATT write can still look dry if that outlet’s hose or nozzle is blocked. Confirm water, not only BLE ACKs. We spent a long time permuting payloads before finding a blocked hose. +- `f004` last byte is **not a reliable “water is flowing” flag** after an SDK write. The official app sets it to `01`. A 4-byte start can open the valve while last byte stays `00`. Treat a successful write as optimistic; confirm physically if it matters. +- A successful GATT write can still look dry if that outlet is blocked. Confirm water, not only BLE ACKs. ## Other characteristics @@ -81,12 +74,10 @@ The printed manual’s “one smartphone” line is about **scheduling ownership ## Suggested PR surface -Keep a future upstream PR to the behaviour change, not this whole note: - 1. Accept `BX*` aliases (and optionally `HOLMAN_ACCEPTED_ALIAS_PREFIXES`). 2. Discover by alias, not advertised service UUID. 3. Unlock `c001` with `AE 8E` when the characteristic exists. -4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` Grass / `0x01` Hose); `stop()` writes zeros. +4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` zone 1 / `0x01` zone 2); `stop()` writes zeros. 5. CLI `--start` / `--stop` / `--minutes` / `--zone`. 6. README mention of BTX2 / BX2 and a link here. diff --git a/docs/homeassistant-example.md b/docs/homeassistant-example.md index d171e6b..be998ad 100644 --- a/docs/homeassistant-example.md +++ b/docs/homeassistant-example.md @@ -1,18 +1,14 @@ # Home Assistant / Lovelace example (generic) -Zone 1 = Grass. Zone 2 = Hose. One outlet at a time. No site IPs or MACs. +Zone 1 = tap 0. Zone 2 = tap 1. One outlet at a time. No site IPs or MACs. -f006 start bytes used by this SDK (and by a `holman_bt` custom component that -speaks the same GATT): +f006 start bytes used by this SDK: -| Zone | Name | Start | Hex | +| Zone | Tap | Start | Hex | | --- | --- | --- | --- | -| 1 | Grass | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | -| 2 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | -| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` | - -Do not send `0x02` as Hose. `[0x02, 0x00, 0x00, mins]` and -`[0x01, 0x02, 0x00, mins]` both ACK and stay dry. +| 1 | 0 | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | +| 2 | 1 | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | +| — | — | Stop `[0x00, 0x00, 0x00, 0x00]` | `00000000` | ## Package snippet @@ -25,8 +21,8 @@ holman_bt: ## Lovelace cards -Entities are named from the device name plus Grass / Hose -(`switch.holman_bx2_grass`, `switch.holman_bx2_hose`). +Entities are named from the device name plus zone +(`switch.holman_bx2_zone_1`, `switch.holman_bx2_zone_2`). ```yaml type: vertical-stack @@ -36,17 +32,17 @@ cards: entities: - entity: number.holman_bx2_runtime name: Minutes - - entity: switch.holman_bx2_grass - name: Grass - icon: mdi:grass - - entity: switch.holman_bx2_hose - name: Hose - icon: mdi:hose + - entity: switch.holman_bx2_zone_1 + name: Zone 1 + icon: mdi:sprinkler + - entity: switch.holman_bx2_zone_2 + name: Zone 2 + icon: mdi:sprinkler - type: horizontal-stack cards: - type: button - name: Start Grass - icon: mdi:grass + name: Start Zone 1 + icon: mdi:sprinkler tap_action: action: call-service service: holman_bt.start @@ -54,8 +50,8 @@ cards: zone: 1 minutes: 5 - type: button - name: Start Hose - icon: mdi:hose + name: Start Zone 2 + icon: mdi:sprinkler tap_action: action: call-service service: holman_bt.start diff --git a/holman/holman.py b/holman/holman.py index b1314ab..3181c48 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -246,14 +246,10 @@ def start(self, runtime=1, zone=1): ''' Turn on the tap for ``runtime`` minutes. - f006 start is ``[0x01, tap, 0x00, minutes]``. Byte 0 is on/off - (always 0x01 for start). Byte 1 is the outlet: 0x00 Grass - (zone 1), 0x01 Hose (zone 2). Hose is hex ``010100NN``. - - Do not write the zone number into byte 0. ``[0x02, 0x00, 0x00, - mins]`` ACKs and stays dry. ``[0x01, 0x02, 0x00, mins]`` is the - same: ACK, no jet. BX1 is a single outlet; zone 1 matches the - original SDK ON payload ``01 00 00 ``. + f006 start is ``[0x01, tap, 0x00, minutes]``. Byte 0 is on + (0x01). Byte 1 is the outlet: tap 0x00 is zone 1, tap 0x01 is + zone 2 (hex ``010100NN``). Zone 1 matches the original SDK ON + payload ``01 00 00 ``. ''' runtime = 255 if runtime > 255 else max(1, int(runtime)) zone = max(1, min(int(zone), 2)) diff --git a/holmanctl.py b/holmanctl.py index 77db575..3322e19 100644 --- a/holmanctl.py +++ b/holmanctl.py @@ -101,7 +101,7 @@ def main(): type=str, help="Connect and send all-off") arg_parser.add_argument('--minutes', type=int, default=2, help="Minutes for --start") - arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 Grass or 2 Hose (BX2). Default 1") + arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 or 2 (BX2). Default 1") args = arg_parser.parse_args() global tap_timer_manager From 42d9d0ae659e7388c407b2f25446e71bbb26f0c0 Mon Sep 17 00:00:00 2001 From: Matthew Hand <11550632+matthewhand@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:45:53 +0000 Subject: [PATCH 07/12] Name BX2 taps Sprinkler and Hose in docs and comments. --- README.md | 4 ++-- docs/BX2.md | 44 +++++++++++++++++------------------ docs/homeassistant-example.md | 30 ++++++++++++------------ holman/holman.py | 4 ++-- holmanctl.py | 2 +- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/README.md b/README.md index d8cb8de..7da56bb 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ As with Holman tap timer discovery, remember to start the Bluetooth event loop w ### Start the tap running -Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` (tap 0, `[0x01, 0x00, 0x00, mins]`) or `zone=2` (tap 1, `[0x01, 0x01, 0x00, mins]`). Byte 1 is the outlet. +Once a Holman tap timer is connected you can start the tap with `TapTimer.start(runtime=1)`. Pass a runtime in minutes. On dual-outlet BX2/BTX2 units, pass `zone=1` (Sprinkler, tap 0, `[0x01, 0x00, 0x00, mins]`) or `zone=2` (Hose, tap 1, `[0x01, 0x01, 0x00, mins]`). Byte 1 is the outlet. ```python tap_timer.start(runtime=5, zone=1) @@ -151,7 +151,7 @@ sudo holmanctl --start AA:BB:CC:DD:EE:FF --minutes 5 --zone 1 sudo holmanctl --stop AA:BB:CC:DD:EE:FF ``` -See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read) and [docs/homeassistant-example.md](docs/homeassistant-example.md) for a generic Home Assistant / Lovelace sketch (zone 1 / zone 2; no site addresses). +See [docs/BX2.md](docs/BX2.md) for the BX2 GATT notes (zones, unlock, what not to read) and [docs/homeassistant-example.md](docs/homeassistant-example.md) for a generic Home Assistant / Lovelace sketch (Sprinkler / Hose; no site addresses). ## Support diff --git a/docs/BX2.md b/docs/BX2.md index 59278cd..21a33db 100644 --- a/docs/BX2.md +++ b/docs/BX2.md @@ -1,13 +1,13 @@ # Holman BX2 notes -Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with this SDK. No device addresses, credentials, or site-specific outlet names belong in this tree. +Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with this SDK. No device addresses or credentials belong in this tree. `AE 8E` is a shared session unlock also used by public BX1 ESPHome configs, not a per-device secret. ## Identity - Advertised alias starts with `BX`, not `Tap Timer`. Discovery must accept that prefix. -- A BX2 may advertise vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-…`) is listed here but is not required for discovery. +- A BX2 may advertise vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-...`) is listed here but is not required for discovery. - Manufacturer company id `0x0374`. BLE address type is **random**. - Scan advertisements often carry the name and company id only. Do **not** require the vendor service UUID in the advert packet; resolve GATT after connect. That is why `TapTimerManager.start_discovery()` no longer passes `service_uuids=`. @@ -15,10 +15,10 @@ Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with th | UUID | Role | | --- | --- | -| `c521f000-…` | Vendor service | -| `0000c001-…` | Write. Session unlock `AE 8E`. | -| `0000f006-…` | Write. Manual start/stop. | -| `0000f004-…` | Read. 12-byte state. Last byte `01` means running **when the official app started the valve**. | +| `c521f000-...` | Vendor service | +| `0000c001-...` | Write. Session unlock `AE 8E`. | +| `0000f006-...` | Write. Manual start/stop. | +| `0000f004-...` | Read. 12-byte state. Last byte `01` means running **when the official app started the valve**. | `start()` / `stop()` send the unlock when `c001` is present. @@ -28,17 +28,17 @@ Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with th [0x01, tap, 0x00, minutes] -| Zone | Tap | Start write | Hex | +| Tap | Name | Start write | Hex | | --- | --- | --- | --- | -| 1 | 0 | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | -| 2 | 1 | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | -| — | — | Stop `[0x00, 0x00, 0x00, 0x00]` | `00000000` | +| 0 | Sprinkler | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | +| 1 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | +| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` | - Byte 0 is on/off (`0x01` start, `0x00` stop). -- Byte 1 is the outlet: tap `0x00` = zone 1, tap `0x01` = zone 2. -- `minutes` is `1…255`. +- Byte 1 is the outlet: tap `0x00` = Sprinkler, tap `0x01` = Hose. +- `minutes` is `1...255`. - Stop is all-off (both outlets). -- Zone 1 matches the original single-outlet SDK ON payload `01 00 00 `. BX1 stays compatible if callers leave `zone` at the default `1`. +- Sprinkler matches the original single-outlet SDK ON payload `01 00 00 `. BX1 stays compatible if callers leave the default tap 0. A 10-byte ESPHome-style pad (`01 00 00 mins` + six zeros) is accepted if written **without** response. A 10-byte write **with** response returned ATT `0x0e` and dropped the link. Prefer the 4-byte form. @@ -46,16 +46,16 @@ If a 4-byte write **with** response fails (ATT `0x0e`), retry **without** respon ## Dual outlet behaviour -- The tap can run **one outlet at a time**. Starting zone 2 while zone 1 is open is not a second concurrent valve. -- To switch outlets: write stop (`00 00 00 00`), then start the other zone. A start-while-running write with response often errors; stop first. -- There is no extra “reset” characteristic required after a manual run. Stop is the all-zero `f006` write. +- The timer can run **one outlet at a time**. Starting Hose while Sprinkler is open is not a second concurrent valve. +- To switch outlets: write stop (`00 00 00 00`), then start the other tap. A start-while-running write with response often errors; stop first. +- There is no extra reset characteristic required after a manual run. Stop is the all-zero `f006` write. ## What not to do -- **Do not read `0000e002-…`.** That drops the connection. +- **Do not read `0000e002-...`.** That drops the connection. - Reading `f004` without a prior `c001` unlock can also return ATT `0x0e` and drop the link. -- First LE connect often fails with `le-connection-abort-by-local` / “failed to discover services, device disconnected”. Retry. Two clients (Home Assistant Bluetooth + `bluetoothctl`, or two phones plus the SDK) racing the same adapter makes this worse. -- `f004` last byte is **not a reliable “water is flowing” flag** after an SDK write. The official app sets it to `01`. A 4-byte start can open the valve while last byte stays `00`. Treat a successful write as optimistic; confirm physically if it matters. +- First LE connect often fails with `le-connection-abort-by-local` / "failed to discover services, device disconnected". Retry. Two clients racing the same adapter makes this worse. +- `f004` last byte is **not a reliable water-is-flowing flag** after an SDK write. The official app sets it to `01`. A 4-byte start can open the valve while last byte stays `00`. Treat a successful write as optimistic; confirm physically if it matters. - A successful GATT write can still look dry if that outlet is blocked. Confirm water, not only BLE ACKs. ## Other characteristics @@ -70,18 +70,18 @@ Seen on the same service, not required for manual run: BlueZ `Paired`/`Bonded` can stay **no**. The Holman app still talks to the timer. Multiple phones can start a **manual** run at the same time. `AE 8E` is a session unlock, not exclusive SMP pairing. -The printed manual’s “one smartphone” line is about **scheduling ownership**, not a hard lock on manual GATT writes. The physical dial can still disable onboard schedules; that does not block these manual `f006` writes. +The printed manual's "one smartphone" line is about **scheduling ownership**, not a hard lock on manual GATT writes. The physical dial can still disable onboard schedules; that does not block these manual `f006` writes. ## Suggested PR surface 1. Accept `BX*` aliases (and optionally `HOLMAN_ACCEPTED_ALIAS_PREFIXES`). 2. Discover by alias, not advertised service UUID. 3. Unlock `c001` with `AE 8E` when the characteristic exists. -4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` zone 1 / `0x01` zone 2); `stop()` writes zeros. +4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` Sprinkler / `0x01` Hose); `stop()` writes zeros. 5. CLI `--start` / `--stop` / `--minutes` / `--zone`. 6. README mention of BTX2 / BX2 and a link here. -Leave Home Assistant bindings, retries, outlet nicknames, and site addresses out of the SDK. +Leave Home Assistant bindings, retries, and site addresses out of the SDK. ## CLI diff --git a/docs/homeassistant-example.md b/docs/homeassistant-example.md index be998ad..9d7cd01 100644 --- a/docs/homeassistant-example.md +++ b/docs/homeassistant-example.md @@ -1,14 +1,14 @@ # Home Assistant / Lovelace example (generic) -Zone 1 = tap 0. Zone 2 = tap 1. One outlet at a time. No site IPs or MACs. +Sprinkler = tap 0. Hose = tap 1. One outlet at a time. No site IPs or MACs. f006 start bytes used by this SDK: -| Zone | Tap | Start | Hex | +| Tap | Name | Start | Hex | | --- | --- | --- | --- | -| 1 | 0 | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | -| 2 | 1 | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | -| — | — | Stop `[0x00, 0x00, 0x00, 0x00]` | `00000000` | +| 0 | Sprinkler | `[0x01, 0x00, 0x00, minutes]` | `010000NN` | +| 1 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` | +| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` | ## Package snippet @@ -21,8 +21,8 @@ holman_bt: ## Lovelace cards -Entities are named from the device name plus zone -(`switch.holman_bx2_zone_1`, `switch.holman_bx2_zone_2`). +Entities are named from the device name plus the tap +(`switch.holman_bx2_sprinkler`, `switch.holman_bx2_hose`). ```yaml type: vertical-stack @@ -32,16 +32,16 @@ cards: entities: - entity: number.holman_bx2_runtime name: Minutes - - entity: switch.holman_bx2_zone_1 - name: Zone 1 - icon: mdi:sprinkler - - entity: switch.holman_bx2_zone_2 - name: Zone 2 + - entity: switch.holman_bx2_sprinkler + name: Sprinkler icon: mdi:sprinkler + - entity: switch.holman_bx2_hose + name: Hose + icon: mdi:hose - type: horizontal-stack cards: - type: button - name: Start Zone 1 + name: Start Sprinkler icon: mdi:sprinkler tap_action: action: call-service @@ -50,8 +50,8 @@ cards: zone: 1 minutes: 5 - type: button - name: Start Zone 2 - icon: mdi:sprinkler + name: Start Hose + icon: mdi:hose tap_action: action: call-service service: holman_bt.start diff --git a/holman/holman.py b/holman/holman.py index 3181c48..b94dd31 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -247,8 +247,8 @@ def start(self, runtime=1, zone=1): Turn on the tap for ``runtime`` minutes. f006 start is ``[0x01, tap, 0x00, minutes]``. Byte 0 is on - (0x01). Byte 1 is the outlet: tap 0x00 is zone 1, tap 0x01 is - zone 2 (hex ``010100NN``). Zone 1 matches the original SDK ON + (0x01). Byte 1 is the outlet: tap 0x00 is Sprinkler, tap 0x01 is + Hose (hex ``010100NN``). Sprinkler matches the original SDK ON payload ``01 00 00 ``. ''' runtime = 255 if runtime > 255 else max(1, int(runtime)) diff --git a/holmanctl.py b/holmanctl.py index 3322e19..9984651 100644 --- a/holmanctl.py +++ b/holmanctl.py @@ -101,7 +101,7 @@ def main(): type=str, help="Connect and send all-off") arg_parser.add_argument('--minutes', type=int, default=2, help="Minutes for --start") - arg_parser.add_argument('--zone', type=int, default=1, help="Outlet 1 or 2 (BX2). Default 1") + arg_parser.add_argument('--zone', type=int, default=1, help="1 Sprinkler (tap 0) or 2 Hose (tap 1). Default 1") args = arg_parser.parse_args() global tap_timer_manager From 0074b78364f82f27aeb73dd93be3cf5283870c4e Mon Sep 17 00:00:00 2001 From: Matthew Hand <11550632+matthewhand@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:46:17 +0000 Subject: [PATCH 08/12] Match BLE aliases with an exact Tap Timer / BX2 allowlist. --- README.md | 6 +++--- docs/BX2.md | 9 ++++----- holman/holman.py | 37 ++++++++++++++++++++----------------- 3 files changed, 27 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 7da56bb..26c16ae 100644 --- a/README.md +++ b/README.md @@ -100,16 +100,16 @@ manager.run() ### Configuring accepted device aliases -By default the SDK accepts devices whose advertised alias starts with `Tap` (BX1 / Tap Timer) or `BX` (BX2 and current BX-prefixed units). Override `accepted_alias_prefixes` or set `HOLMAN_ACCEPTED_ALIAS_PREFIXES` (comma-separated) when a future unit such as BX3 advertises a different prefix. +By default the SDK accepts devices whose advertised alias is exactly `Tap Timer` (upstream BX1) or `BX2` (this device). Match is membership (`if alias not in self.accepted_aliases: return None`); an empty alias does not match. Pass `accepted_aliases` or set `HOLMAN_ACCEPTED_ALIASES` (comma-separated) to add another exact name, not a prefix. ```python manager = holman.TapTimerManager( adapter_name='hci0', - accepted_alias_prefixes=('Tap', 'BX', 'Holman')) + accepted_aliases=('Tap Timer', 'BX2', 'BX3')) ``` ```bash -export HOLMAN_ACCEPTED_ALIAS_PREFIXES="Tap,BX,Holman" +export HOLMAN_ACCEPTED_ALIASES="BX3" sudo holmanctl --discover ``` diff --git a/docs/BX2.md b/docs/BX2.md index 21a33db..558f030 100644 --- a/docs/BX2.md +++ b/docs/BX2.md @@ -6,10 +6,9 @@ Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with th ## Identity -- Advertised alias starts with `BX`, not `Tap Timer`. Discovery must accept that prefix. -- A BX2 may advertise vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (this repo already labelled that UUID as BTX1 / CO3011). Another BTX2 UUID (`aacaebbb-...`) is listed here but is not required for discovery. +- Advertised alias is exactly `BX2` (bluetoothctl Name/Alias and Bleak `local_name`). Not `Holman BX2` or `BTX2`. Discovery matches exact names `Tap Timer` (BX1) and `BX2`. +- This BX2 advertises vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (already labelled BTX1 / CO3011). Discovery can pass `service_uuids=TapTimer.SERVICE_UUIDS` again because that list includes the on-air UUID. Another BTX2 UUID (`aacaebbb-...`) stays in the list for other units. - Manufacturer company id `0x0374`. BLE address type is **random**. -- Scan advertisements often carry the name and company id only. Do **not** require the vendor service UUID in the advert packet; resolve GATT after connect. That is why `TapTimerManager.start_discovery()` no longer passes `service_uuids=`. ## GATT (safe) @@ -74,8 +73,8 @@ The printed manual's "one smartphone" line is about **scheduling ownership**, no ## Suggested PR surface -1. Accept `BX*` aliases (and optionally `HOLMAN_ACCEPTED_ALIAS_PREFIXES`). -2. Discover by alias, not advertised service UUID. +1. Accept exact aliases `Tap Timer` and `BX2` (optional `HOLMAN_ACCEPTED_ALIASES` adds more exact names). +2. Discover with `service_uuids=TapTimer.SERVICE_UUIDS` (includes the on-air BX2 UUID). 3. Unlock `c001` with `AE 8E` when the characteristic exists. 4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` Sprinkler / `0x01` Hose); `stop()` writes zeros. 5. CLI `--start` / `--stop` / `--minutes` / `--zone`. diff --git a/holman/holman.py b/holman/holman.py index b94dd31..c08d90f 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -6,14 +6,18 @@ import gatt -_DEFAULT_ALIAS_PREFIXES = ('Tap', 'BX') +_DEFAULT_ALIASES = ('Tap Timer', 'BX2') -def _get_default_alias_prefixes(): - env = os.environ.get('HOLMAN_ACCEPTED_ALIAS_PREFIXES') +def _get_default_aliases(): + aliases = list(_DEFAULT_ALIASES) + env = os.environ.get('HOLMAN_ACCEPTED_ALIASES') if env: - return tuple(p.strip() for p in env.split(',') if p.strip()) - return _DEFAULT_ALIAS_PREFIXES + for name in env.split(','): + name = name.strip() + if name and name not in aliases: + aliases.append(name) + return tuple(aliases) class TapTimerManager(gatt.DeviceManager): @@ -21,23 +25,22 @@ class TapTimerManager(gatt.DeviceManager): Entry point for managing and discovering Holman ``TapTimer``s. """ - def __init__(self, adapter_name='hci0', accepted_alias_prefixes=None): + def __init__(self, adapter_name='hci0', accepted_aliases=None): """ Instantiates a ``TapTimerManager`` :param adapter_name: name of Bluetooth adapter used by this tap timer manager - :param accepted_alias_prefixes: tuple of alias prefixes to accept - during discovery. Defaults to - ``('Tap', 'BX')`` or the - ``HOLMAN_ACCEPTED_ALIAS_PREFIXES`` - environment variable (comma-separated). + :param accepted_aliases: exact advertised names to accept during + discovery. Defaults to ``('Tap Timer', 'BX2')`` + plus extra names from ``HOLMAN_ACCEPTED_ALIASES`` + (comma-separated exact strings). """ # DeviceManager.__init__ calls update_devices() -> make_device(), - # which reads accepted_alias_prefixes. - if accepted_alias_prefixes is None: - accepted_alias_prefixes = _get_default_alias_prefixes() - self.accepted_alias_prefixes = tuple(accepted_alias_prefixes) + # which reads accepted_aliases. + if accepted_aliases is None: + accepted_aliases = _get_default_aliases() + self.accepted_aliases = tuple(accepted_aliases) self.listener = None self.discovered_tap_timers = {} super().__init__(adapter_name) @@ -55,13 +58,13 @@ def start_discovery(self, service_uuids=None): Assign a `TapTimerManagerListener` to the `listener` attribute to collect discovered Holmans. """ - super().start_discovery() + super().start_discovery(service_uuids=TapTimer.SERVICE_UUIDS) def make_device(self, mac_address): device = gatt.Device( mac_address=mac_address, manager=self, managed=False) alias = device.alias() or '' - if not any(alias.startswith(p) for p in self.accepted_alias_prefixes): + if alias not in self.accepted_aliases: return None return TapTimer(mac_address=mac_address, manager=self) From 6ffc9e485c5543e8d05a4d23aaabe3ba12ad8320 Mon Sep 17 00:00:00 2001 From: Matthew Hand <11550632+matthewhand@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:49:31 +0000 Subject: [PATCH 09/12] Drop leftover HOLMAN_ACCEPTED_ALIAS_PREFIXES test cleanup. --- tests/test_payload.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/test_payload.py b/tests/test_payload.py index 8f263a8..0b9b5a8 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -66,35 +66,30 @@ def test_tap_helpers(self): class TestAcceptedAliases(unittest.TestCase): def _clear_alias_env(self): - return ( - os.environ.pop('HOLMAN_ACCEPTED_ALIASES', None), - os.environ.pop('HOLMAN_ACCEPTED_ALIAS_PREFIXES', None), - ) + return os.environ.pop('HOLMAN_ACCEPTED_ALIASES', None) - def _restore_alias_env(self, aliases, prefixes): + def _restore_alias_env(self, aliases): if aliases is not None: os.environ['HOLMAN_ACCEPTED_ALIASES'] = aliases - if prefixes is not None: - os.environ['HOLMAN_ACCEPTED_ALIAS_PREFIXES'] = prefixes def test_default_exact_tap_timer_and_bx2(self): - old_aliases, old_prefixes = self._clear_alias_env() + old_aliases = self._clear_alias_env() try: self.assertEqual(get_default_aliases(), ('Tap Timer', 'BX2')) finally: - self._restore_alias_env(old_aliases, old_prefixes) + self._restore_alias_env(old_aliases) def test_env_adds_exact_alias(self): - old_aliases, old_prefixes = self._clear_alias_env() + old_aliases = self._clear_alias_env() os.environ['HOLMAN_ACCEPTED_ALIASES'] = 'BX3' try: self.assertEqual(get_default_aliases(), ('Tap Timer', 'BX2', 'BX3')) finally: os.environ.pop('HOLMAN_ACCEPTED_ALIASES', None) - self._restore_alias_env(old_aliases, old_prefixes) + self._restore_alias_env(old_aliases) def test_unknown_names_fail_closed(self): - old_aliases, old_prefixes = self._clear_alias_env() + old_aliases = self._clear_alias_env() try: accepted = get_default_aliases() self.assertTrue(alias_accepted('Tap Timer', accepted)) @@ -105,7 +100,7 @@ def test_unknown_names_fail_closed(self): self.assertNotIn('BX', accepted) self.assertNotIn('', accepted) finally: - self._restore_alias_env(old_aliases, old_prefixes) + self._restore_alias_env(old_aliases) if __name__ == '__main__': From d63cddbc0c5e06ae335a1466aed7e9f7ff02a19a Mon Sep 17 00:00:00 2001 From: Matthew Hand <11550632+matthewhand@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:59:28 +0000 Subject: [PATCH 10/12] Allow HOLMAN_SERVICE_UUIDS to override BLE service UUID filter. --- README.md | 17 ++++++++++++++++ docs/BX2.md | 4 ++-- holman/holman.py | 51 +++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 65 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 26c16ae..2c560e6 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,23 @@ export HOLMAN_ACCEPTED_ALIASES="BX3" sudo holmanctl --discover ``` +### Configuring accepted BLE service UUIDs + +By default discovery **and** connect-time service pick use the same three known Holman vendor services: CO3015 (`0a75f000-f9ad-467a-e564-3c19163ad543`), CO3012 (`aacaebbb-af4b-baf3-7361-989ffeb0b129`, some BTX2), and CO3011 (`c521f000-0d70-4d4f-8e43-40d84c50ab38`, BTX1 / the on-air BX2 UUID). Named constants stay on `TapTimer`. + +Set `HOLMAN_SERVICE_UUIDS` to a comma-separated list to **replace** that filter (not add to it). Unset = hardcoded defaults; set = exactly those UUIDs. Or pass `service_uuids` to `TapTimerManager` (constructor wins over env). + +```python +manager = holman.TapTimerManager( + adapter_name='hci0', + service_uuids=('c521f000-0d70-4d4f-8e43-40d84c50ab38',)) +``` + +```bash +export HOLMAN_SERVICE_UUIDS="c521f000-0d70-4d4f-8e43-40d84c50ab38" +sudo holmanctl --discover +``` + ### Connecting to a Holman tap timer and receiving user input events Once `TapTimerManager` has discovered a Holman tap timer you can use the `TapTimer` object(s) that you retrieved from `TapTimerManager.tap_timers()` to connect to it. Alternatively you can create a new instance of `TapTimer` using the name of your Bluetooth adapter (typically `hci0`) and Holman's MAC address. diff --git a/docs/BX2.md b/docs/BX2.md index 558f030..fede51e 100644 --- a/docs/BX2.md +++ b/docs/BX2.md @@ -7,7 +7,7 @@ Protocol notes for a Holman **BX2** (dual outlet, advertised name `BX2`) with th ## Identity - Advertised alias is exactly `BX2` (bluetoothctl Name/Alias and Bleak `local_name`). Not `Holman BX2` or `BTX2`. Discovery matches exact names `Tap Timer` (BX1) and `BX2`. -- This BX2 advertises vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (already labelled BTX1 / CO3011). Discovery can pass `service_uuids=TapTimer.SERVICE_UUIDS` again because that list includes the on-air UUID. Another BTX2 UUID (`aacaebbb-...`) stays in the list for other units. +- This BX2 advertises vendor service `c521f000-0d70-4d4f-8e43-40d84c50ab38` (already labelled BTX1 / CO3011). Discovery and connect-time service pick use the same list: CO3015, CO3012, and CO3011 by default (the on-air BX2 UUID is CO3011). Set `HOLMAN_SERVICE_UUIDS` to a comma-separated list to replace that filter, or pass `service_uuids` to `TapTimerManager`. Another BTX2 UUID (`aacaebbb-...`) stays in the default list for other units. - Manufacturer company id `0x0374`. BLE address type is **random**. ## GATT (safe) @@ -74,7 +74,7 @@ The printed manual's "one smartphone" line is about **scheduling ownership**, no ## Suggested PR surface 1. Accept exact aliases `Tap Timer` and `BX2` (optional `HOLMAN_ACCEPTED_ALIASES` adds more exact names). -2. Discover with `service_uuids=TapTimer.SERVICE_UUIDS` (includes the on-air BX2 UUID). +2. Discover and connect with the same service UUID list (defaults include the on-air BX2 UUID CO3011 / `c521f000-...`). `HOLMAN_SERVICE_UUIDS` or `service_uuids=` fully replaces the list when set. 3. Unlock `c001` with `AE 8E` when the characteristic exists. 4. `start(runtime, zone=1)` writes `[0x01, tap, 0, mins]` (tap `0x00` Sprinkler / `0x01` Hose); `stop()` writes zeros. 5. CLI `--start` / `--stop` / `--minutes` / `--zone`. diff --git a/holman/holman.py b/holman/holman.py index c08d90f..573fd50 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -20,12 +20,33 @@ def _get_default_aliases(): return tuple(aliases) +def _get_default_service_uuids(): + ''' + CO3015 / CO3012 / CO3011 unless HOLMAN_SERVICE_UUIDS is set. + + When the env var is set, the comma-separated value fully replaces + the hardcoded defaults (not extras). Unset or blank = defaults. + ''' + env = os.environ.get('HOLMAN_SERVICE_UUIDS') + if env and env.strip(): + uuids = [] + for raw in env.split(','): + raw = raw.strip().lower() + if raw and raw not in uuids: + uuids.append(raw) + if uuids: + return tuple(uuids) + return tuple(TapTimer.SERVICE_UUIDS) + + + class TapTimerManager(gatt.DeviceManager): """ Entry point for managing and discovering Holman ``TapTimer``s. """ - def __init__(self, adapter_name='hci0', accepted_aliases=None): + def __init__(self, adapter_name='hci0', accepted_aliases=None, + service_uuids=None): """ Instantiates a ``TapTimerManager`` @@ -35,12 +56,21 @@ def __init__(self, adapter_name='hci0', accepted_aliases=None): discovery. Defaults to ``('Tap Timer', 'BX2')`` plus extra names from ``HOLMAN_ACCEPTED_ALIASES`` (comma-separated exact strings). + :param service_uuids: vendor service UUIDs for discovery and + connect-time service pick. Defaults to + CO3015 / CO3012 / CO3011. When + ``HOLMAN_SERVICE_UUIDS`` is set, that + comma-separated list fully replaces the + defaults. Constructor wins over env. """ # DeviceManager.__init__ calls update_devices() -> make_device(), - # which reads accepted_aliases. + # which reads accepted_aliases and service_uuids. if accepted_aliases is None: accepted_aliases = _get_default_aliases() self.accepted_aliases = tuple(accepted_aliases) + if service_uuids is None: + service_uuids = _get_default_service_uuids() + self.service_uuids = tuple(u.lower() for u in service_uuids) self.listener = None self.discovered_tap_timers = {} super().__init__(adapter_name) @@ -58,7 +88,7 @@ def start_discovery(self, service_uuids=None): Assign a `TapTimerManagerListener` to the `listener` attribute to collect discovered Holmans. """ - super().start_discovery(service_uuids=TapTimer.SERVICE_UUIDS) + super().start_discovery(service_uuids=list(self.service_uuids)) def make_device(self, mac_address): device = gatt.Device( @@ -125,7 +155,7 @@ class TapTimer(gatt.Device): HOLMAN_CO3012_SERVICE_UUID, HOLMAN_CO3011_SERVICE_UUID] - def __init__(self, mac_address, manager): + def __init__(self, mac_address, manager, service_uuids=None): """ Create an instance with given Bluetooth adapter name and MAC address. @@ -134,9 +164,19 @@ def __init__(self, mac_address, manager): format: ``AA:BB:CC:DD:EE:FF`` :param manager: reference to the `TapTimerManager` that manages this tap timer + :param service_uuids: vendor service UUIDs for connect-time + service pick. Defaults to the manager + list, else the same resolved defaults / + ``HOLMAN_SERVICE_UUIDS`` replace list. """ super().__init__(mac_address=mac_address, manager=manager) + if service_uuids is None: + service_uuids = getattr(manager, 'service_uuids', None) + if service_uuids is None: + service_uuids = _get_default_service_uuids() + self.service_uuids = tuple(u.lower() for u in service_uuids) + self.listener = None self._battery_level = None self._manual_characteristic = None @@ -180,9 +220,10 @@ def disconnect_succeeded(self): def services_resolved(self): super().services_resolved() + accepted = {uuid.lower() for uuid in self.service_uuids} holman_service = next(( service for service in self.services - if service.uuid in self.SERVICE_UUIDS), None) + if service.uuid.lower() in accepted), None) if holman_service is None: if self.listener: # TODO: Use proper exception subclass From 985cf9390edb7c34a6f36bfc646405f58c1f8f3b Mon Sep 17 00:00:00 2001 From: Matthew Hand <11550632+matthewhand@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:01:01 +0000 Subject: [PATCH 11/12] Extract HOLMAN_SERVICE_UUIDS helper so tests can cover the full-replace filter. --- holman/__init__.py | 2 +- holman/aliases.py | 30 ++++++++++++++++++++++++- holman/holman.py | 26 +++------------------- tests/test_payload.py | 51 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 25 deletions(-) diff --git a/holman/__init__.py b/holman/__init__.py index eb97d2c..f5fa261 100644 --- a/holman/__init__.py +++ b/holman/__init__.py @@ -1,3 +1,3 @@ from .holman import TapTimerManager, TapTimerManagerListener, TapTimer, TapTimerListener from .payload import clamp_runtime, manual_payload, tap_for_zone, tap_name -from .aliases import get_default_aliases, alias_accepted +from .aliases import get_default_aliases, alias_accepted, get_default_service_uuids diff --git a/holman/aliases.py b/holman/aliases.py index 9a38f3a..9c85816 100644 --- a/holman/aliases.py +++ b/holman/aliases.py @@ -1,11 +1,20 @@ ''' -Exact advertised names accepted during discovery. +Discovery allowlists: exact advertised names and vendor service UUIDs. ''' import os DEFAULT_ALIASES = ('Tap Timer', 'BX2') +HOLMAN_CO3015_SERVICE_UUID = '0a75f000-f9ad-467a-e564-3c19163ad543' +HOLMAN_CO3012_SERVICE_UUID = 'aacaebbb-af4b-baf3-7361-989ffeb0b129' # some BTX2 +HOLMAN_CO3011_SERVICE_UUID = 'c521f000-0d70-4d4f-8e43-40d84c50ab38' # BTX1 / BX2 +DEFAULT_SERVICE_UUIDS = ( + HOLMAN_CO3015_SERVICE_UUID, + HOLMAN_CO3012_SERVICE_UUID, + HOLMAN_CO3011_SERVICE_UUID, +) + def get_default_aliases(): ''' @@ -30,3 +39,22 @@ def alias_accepted(alias, accepted_aliases=None): if not alias: return False return alias in accepted_aliases + + +def get_default_service_uuids(): + ''' + Default Holman vendor services (CO3015, CO3012, CO3011). + + If ``HOLMAN_SERVICE_UUIDS`` is set, that comma-separated list fully + replaces the defaults (not extras). Unset or blank = defaults. + ''' + env = os.environ.get('HOLMAN_SERVICE_UUIDS') + if env and env.strip(): + uuids = [] + for raw in env.split(','): + raw = raw.strip().lower() + if raw and raw not in uuids: + uuids.append(raw) + if uuids: + return tuple(uuids) + return DEFAULT_SERVICE_UUIDS diff --git a/holman/holman.py b/holman/holman.py index 1075a15..d1d6d95 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -5,32 +5,12 @@ import gatt -from .aliases import get_default_aliases +from .aliases import get_default_aliases, get_default_service_uuids from .payload import manual_payload, tap_name _LOGGER = logging.getLogger(__name__) -def _get_default_service_uuids(): - ''' - CO3015 / CO3012 / CO3011 unless HOLMAN_SERVICE_UUIDS is set. - - When the env var is set, the comma-separated value fully replaces - the hardcoded defaults (not extras). Unset or blank = defaults. - ''' - env = os.environ.get('HOLMAN_SERVICE_UUIDS') - if env and env.strip(): - uuids = [] - for raw in env.split(','): - raw = raw.strip().lower() - if raw and raw not in uuids: - uuids.append(raw) - if uuids: - return tuple(uuids) - return tuple(TapTimer.SERVICE_UUIDS) - - - class TapTimerManager(gatt.DeviceManager): """ Entry point for managing and discovering Holman ``TapTimer``s. @@ -60,7 +40,7 @@ def __init__(self, adapter_name='hci0', accepted_aliases=None, accepted_aliases = get_default_aliases() self.accepted_aliases = tuple(accepted_aliases) if service_uuids is None: - service_uuids = _get_default_service_uuids() + service_uuids = get_default_service_uuids() self.service_uuids = tuple(u.lower() for u in service_uuids) self.listener = None self.discovered_tap_timers = {} @@ -165,7 +145,7 @@ def __init__(self, mac_address, manager, service_uuids=None): if service_uuids is None: service_uuids = getattr(manager, 'service_uuids', None) if service_uuids is None: - service_uuids = _get_default_service_uuids() + service_uuids = get_default_service_uuids() self.service_uuids = tuple(u.lower() for u in service_uuids) self.listener = None diff --git a/tests/test_payload.py b/tests/test_payload.py index 0b9b5a8..562da14 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -25,6 +25,12 @@ def _load(name, filename): clamp_runtime = payload.clamp_runtime get_default_aliases = aliases.get_default_aliases alias_accepted = aliases.alias_accepted +get_default_service_uuids = aliases.get_default_service_uuids +DEFAULT_SERVICE_UUIDS = aliases.DEFAULT_SERVICE_UUIDS +HOLMAN_CO3015_SERVICE_UUID = aliases.HOLMAN_CO3015_SERVICE_UUID +HOLMAN_CO3012_SERVICE_UUID = aliases.HOLMAN_CO3012_SERVICE_UUID +HOLMAN_CO3011_SERVICE_UUID = aliases.HOLMAN_CO3011_SERVICE_UUID +UNKNOWN_SERVICE_UUID = '00000000-0000-0000-0000-000000000000' class TestManualPayload(unittest.TestCase): @@ -103,5 +109,50 @@ def test_unknown_names_fail_closed(self): self._restore_alias_env(old_aliases) +class TestServiceUuids(unittest.TestCase): + def _clear_uuid_env(self): + return os.environ.pop('HOLMAN_SERVICE_UUIDS', None) + + def _restore_uuid_env(self, value): + if value is not None: + os.environ['HOLMAN_SERVICE_UUIDS'] = value + + def test_default_includes_co3011(self): + old = self._clear_uuid_env() + try: + uuids = get_default_service_uuids() + self.assertEqual(uuids, DEFAULT_SERVICE_UUIDS) + self.assertIn(HOLMAN_CO3011_SERVICE_UUID, uuids) + self.assertIn(HOLMAN_CO3015_SERVICE_UUID, uuids) + self.assertIn(HOLMAN_CO3012_SERVICE_UUID, uuids) + finally: + self._restore_uuid_env(old) + + def test_env_replaces_list(self): + old = self._clear_uuid_env() + os.environ['HOLMAN_SERVICE_UUIDS'] = ( + UNKNOWN_SERVICE_UUID + ', ' + HOLMAN_CO3011_SERVICE_UUID) + try: + uuids = get_default_service_uuids() + self.assertEqual( + uuids, (UNKNOWN_SERVICE_UUID, HOLMAN_CO3011_SERVICE_UUID)) + self.assertNotIn(HOLMAN_CO3015_SERVICE_UUID, uuids) + self.assertNotIn(HOLMAN_CO3012_SERVICE_UUID, uuids) + finally: + os.environ.pop('HOLMAN_SERVICE_UUIDS', None) + self._restore_uuid_env(old) + + def test_unknown_uuid_not_in_default_unless_env_set(self): + old = self._clear_uuid_env() + try: + self.assertNotIn(UNKNOWN_SERVICE_UUID, get_default_service_uuids()) + os.environ['HOLMAN_SERVICE_UUIDS'] = UNKNOWN_SERVICE_UUID + self.assertEqual(get_default_service_uuids(), (UNKNOWN_SERVICE_UUID,)) + self.assertNotIn(HOLMAN_CO3011_SERVICE_UUID, get_default_service_uuids()) + finally: + os.environ.pop('HOLMAN_SERVICE_UUIDS', None) + self._restore_uuid_env(old) + + if __name__ == '__main__': unittest.main() From e3b3add2245cb24e750685cf1c8edd647c0dd397 Mon Sep 17 00:00:00 2001 From: Matthew Hand <11550632+matthewhand@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:20:01 +0000 Subject: [PATCH 12/12] Prove alias and service UUID env overrides, including constructor wins. --- holman/aliases.py | 18 ++++++++++ holman/holman.py | 15 +++----- tests/test_payload.py | 84 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 10 deletions(-) diff --git a/holman/aliases.py b/holman/aliases.py index 9c85816..6b0fc35 100644 --- a/holman/aliases.py +++ b/holman/aliases.py @@ -58,3 +58,21 @@ def get_default_service_uuids(): if uuids: return tuple(uuids) return DEFAULT_SERVICE_UUIDS + + +def resolve_aliases(accepted_aliases=None): + ''' + Constructor list wins; otherwise ``get_default_aliases()`` (env extras). + ''' + if accepted_aliases is not None: + return tuple(accepted_aliases) + return get_default_aliases() + + +def resolve_service_uuids(service_uuids=None): + ''' + Constructor list wins (lowercased); otherwise ``get_default_service_uuids()``. + ''' + if service_uuids is not None: + return tuple(u.lower() for u in service_uuids) + return get_default_service_uuids() diff --git a/holman/holman.py b/holman/holman.py index d1d6d95..011148d 100644 --- a/holman/holman.py +++ b/holman/holman.py @@ -5,7 +5,7 @@ import gatt -from .aliases import get_default_aliases, get_default_service_uuids +from .aliases import resolve_aliases, resolve_service_uuids from .payload import manual_payload, tap_name _LOGGER = logging.getLogger(__name__) @@ -36,12 +36,9 @@ def __init__(self, adapter_name='hci0', accepted_aliases=None, """ # DeviceManager.__init__ calls update_devices() -> make_device(), # which reads accepted_aliases and service_uuids. - if accepted_aliases is None: - accepted_aliases = get_default_aliases() - self.accepted_aliases = tuple(accepted_aliases) - if service_uuids is None: - service_uuids = get_default_service_uuids() - self.service_uuids = tuple(u.lower() for u in service_uuids) + # Constructor list wins over HOLMAN_ACCEPTED_ALIASES / HOLMAN_SERVICE_UUIDS. + self.accepted_aliases = resolve_aliases(accepted_aliases) + self.service_uuids = resolve_service_uuids(service_uuids) self.listener = None self.discovered_tap_timers = {} super().__init__(adapter_name) @@ -144,9 +141,7 @@ def __init__(self, mac_address, manager, service_uuids=None): if service_uuids is None: service_uuids = getattr(manager, 'service_uuids', None) - if service_uuids is None: - service_uuids = get_default_service_uuids() - self.service_uuids = tuple(u.lower() for u in service_uuids) + self.service_uuids = resolve_service_uuids(service_uuids) self.listener = None self._battery_level = None diff --git a/tests/test_payload.py b/tests/test_payload.py index 562da14..7d3e07f 100644 --- a/tests/test_payload.py +++ b/tests/test_payload.py @@ -26,6 +26,8 @@ def _load(name, filename): get_default_aliases = aliases.get_default_aliases alias_accepted = aliases.alias_accepted get_default_service_uuids = aliases.get_default_service_uuids +resolve_aliases = aliases.resolve_aliases +resolve_service_uuids = aliases.resolve_service_uuids DEFAULT_SERVICE_UUIDS = aliases.DEFAULT_SERVICE_UUIDS HOLMAN_CO3015_SERVICE_UUID = aliases.HOLMAN_CO3015_SERVICE_UUID HOLMAN_CO3012_SERVICE_UUID = aliases.HOLMAN_CO3012_SERVICE_UUID @@ -154,5 +156,87 @@ def test_unknown_uuid_not_in_default_unless_env_set(self): self._restore_uuid_env(old) +class TestResolveAliasesAndUuids(unittest.TestCase): + def _clear_envs(self): + return ( + os.environ.pop('HOLMAN_ACCEPTED_ALIASES', None), + os.environ.pop('HOLMAN_SERVICE_UUIDS', None), + ) + + def _restore_envs(self, aliases, uuids): + if aliases is not None: + os.environ['HOLMAN_ACCEPTED_ALIASES'] = aliases + if uuids is not None: + os.environ['HOLMAN_SERVICE_UUIDS'] = uuids + + def test_unset_env_resolve_defaults(self): + old_aliases, old_uuids = self._clear_envs() + try: + self.assertEqual(resolve_aliases(), ('Tap Timer', 'BX2')) + self.assertEqual(resolve_aliases(None), ('Tap Timer', 'BX2')) + uuids = resolve_service_uuids() + self.assertEqual(uuids, DEFAULT_SERVICE_UUIDS) + self.assertIn(HOLMAN_CO3011_SERVICE_UUID, uuids) + finally: + self._restore_envs(old_aliases, old_uuids) + + def test_env_appends_exact_alias_via_resolve(self): + old_aliases, old_uuids = self._clear_envs() + os.environ['HOLMAN_ACCEPTED_ALIASES'] = 'BX3' + try: + self.assertEqual(resolve_aliases(), ('Tap Timer', 'BX2', 'BX3')) + finally: + os.environ.pop('HOLMAN_ACCEPTED_ALIASES', None) + self._restore_envs(old_aliases, old_uuids) + + def test_env_full_replaces_service_uuids_via_resolve(self): + old_aliases, old_uuids = self._clear_envs() + os.environ['HOLMAN_SERVICE_UUIDS'] = UNKNOWN_SERVICE_UUID + try: + uuids = resolve_service_uuids() + self.assertEqual(uuids, (UNKNOWN_SERVICE_UUID,)) + self.assertNotIn(HOLMAN_CO3015_SERVICE_UUID, uuids) + self.assertNotIn(HOLMAN_CO3012_SERVICE_UUID, uuids) + self.assertNotIn(HOLMAN_CO3011_SERVICE_UUID, uuids) + finally: + os.environ.pop('HOLMAN_SERVICE_UUIDS', None) + self._restore_envs(old_aliases, old_uuids) + + def test_resolve_aliases_constructor_wins_over_env(self): + old_aliases, old_uuids = self._clear_envs() + os.environ['HOLMAN_ACCEPTED_ALIASES'] = 'Nope' + try: + self.assertEqual(resolve_aliases(('BX2',)), ('BX2',)) + self.assertNotIn('Nope', resolve_aliases(('BX2',))) + finally: + os.environ.pop('HOLMAN_ACCEPTED_ALIASES', None) + self._restore_envs(old_aliases, old_uuids) + + def test_resolve_service_uuids_constructor_wins_over_env(self): + old_aliases, old_uuids = self._clear_envs() + os.environ['HOLMAN_SERVICE_UUIDS'] = UNKNOWN_SERVICE_UUID + try: + self.assertEqual( + resolve_service_uuids((HOLMAN_CO3011_SERVICE_UUID,)), + (HOLMAN_CO3011_SERVICE_UUID,)) + self.assertNotIn( + UNKNOWN_SERVICE_UUID, + resolve_service_uuids((HOLMAN_CO3011_SERVICE_UUID,))) + finally: + os.environ.pop('HOLMAN_SERVICE_UUIDS', None) + self._restore_envs(old_aliases, old_uuids) + + def test_device_data_bx2_and_co3011(self): + old_aliases, old_uuids = self._clear_envs() + try: + self.assertTrue(alias_accepted('BX2')) + self.assertEqual( + HOLMAN_CO3011_SERVICE_UUID, + 'c521f000-0d70-4d4f-8e43-40d84c50ab38') + self.assertIn(HOLMAN_CO3011_SERVICE_UUID, resolve_service_uuids()) + finally: + self._restore_envs(old_aliases, old_uuids) + + if __name__ == '__main__': unittest.main()