Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -102,3 +102,6 @@ ENV/

# Visual Studio Code
.vscode/

# bluez
bluez
62 changes: 59 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand Down Expand Up @@ -97,6 +98,38 @@ manager.start_discovery()
manager.run()
```

### Configuring accepted device aliases

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_aliases=('Tap Timer', 'BX2', 'BX3'))
```

```bash
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.
Expand All @@ -121,7 +154,30 @@ 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` (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)
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
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 (Sprinkler / Hose; no site addresses).

## Support

Expand Down
91 changes: 91 additions & 0 deletions docs/BX2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Holman BX2 notes

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 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 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)

| 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**. |

`start()` / `stop()` send the unlock when `c001` is present.

## Manual payload

4 bytes on `f006`:

[0x01, tap, 0x00, minutes]

| Tap | Name | Start write | Hex |
| --- | --- | --- | --- |
| 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` = Sprinkler, tap `0x01` = Hose.
- `minutes` is `1...255`.
- Stop is all-off (both outlets).
- Sprinkler matches the original single-outlet SDK ON payload `01 00 00 <mins>`. 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.

If a 4-byte write **with** response fails (ATT `0x0e`), retry **without** response. That is common while a run is already active.

## Dual outlet behaviour

- 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.
- 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 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

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.

## 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.

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 exact aliases `Tap Timer` and `BX2` (optional `HOLMAN_ACCEPTED_ALIASES` adds more exact names).
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`.
6. README mention of BTX2 / BX2 and a link here.

Leave Home Assistant bindings, retries, and site addresses out of the SDK.

## CLI

```
holmanctl --discover
holmanctl --start AA:BB:CC:DD:EE:FF --minutes 2 --zone 1
holmanctl --stop AA:BB:CC:DD:EE:FF
```
67 changes: 67 additions & 0 deletions docs/homeassistant-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Home Assistant / Lovelace example (generic)

Sprinkler = tap 0. Hose = tap 1. One outlet at a time. No site IPs or MACs.

f006 start bytes used by this SDK:

| Tap | Name | Start | Hex |
| --- | --- | --- | --- |
| 0 | Sprinkler | `[0x01, 0x00, 0x00, minutes]` | `010000NN` |
| 1 | Hose | `[0x01, 0x01, 0x00, minutes]` | `010100NN` |
| — | Stop | `[0x00, 0x00, 0x00, 0x00]` | `00000000` |

## 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 the tap
(`switch.holman_bx2_sprinkler`, `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_sprinkler
name: Sprinkler
icon: mdi:sprinkler
- entity: switch.holman_bx2_hose
name: Hose
icon: mdi:hose
- type: horizontal-stack
cards:
- type: button
name: Start Sprinkler
icon: mdi:sprinkler
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
```
2 changes: 2 additions & 0 deletions holman/__init__.py
Original file line number Diff line number Diff line change
@@ -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_aliases, alias_accepted, get_default_service_uuids
78 changes: 78 additions & 0 deletions holman/aliases.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
'''
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():
'''
Exact ``Tap Timer`` (BX1) and ``BX2`` by default. Env adds more exact names.
'''
aliases = list(DEFAULT_ALIASES)
env = os.environ.get('HOLMAN_ACCEPTED_ALIASES')
if env:
for name in env.split(','):
name = name.strip()
if name and name not in aliases:
aliases.append(name)
return tuple(aliases)


def alias_accepted(alias, accepted_aliases=None):
'''
True when ``alias`` is a non-empty exact member of the allowlist.
'''
if accepted_aliases is None:
accepted_aliases = get_default_aliases()
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


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()
Loading