Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
push:
branches: [master]
pull_request:

jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
- run: uv sync --locked
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run pyrefly check

test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.9", "3.14"]
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
with:
python-version: ${{ matrix.python-version }}
- run: uv sync --locked
- run: uv run pytest

build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: astral-sh/setup-uv@v6
- run: uv build
112 changes: 112 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# holman-linux-python

Python SDK and `holmanctl` CLI for controlling Holman Bluetooth tap timers (CO3015, BTX1/CO3011) on Linux via BlueZ.

## Tech Stack
- Python 3, built on the `gatt` library (which wraps BlueZ's D-Bus API)
- `python3-dbus` comes from apt, not PyPI, and is not in `dependencies`
- Managed with uv; `pyproject.toml` (hatchling backend) is the only packaging config; published to PyPI as `holman`

## Layout
- `holman/holman.py` — the whole SDK: `TapTimerManager` (discovery), `TapTimer` (connection + control), and the two listener base classes
- `holman/__init__.py` — public API re-exports; update it when adding public classes
- `holmanctl.py` — demo/test CLI, installed as the `holmanctl` console script
- `tests/` — pytest suite; `conftest.py` swaps `gatt` for an in-memory fake (real `gatt` calls `sys.exit` on import without dbus)
- `typings/gatt/` — pyrefly stub pinning `gatt` to its BlueZ backend (it picks a backend at runtime, which the checker can't narrow)

## Development
- Before finishing a change, run `uv run pytest`, `uv run ruff check .`, `uv run ruff format --check .` and `uv run pyrefly check`
- CI (`.github/workflows/ci.yml`) runs the same checks, with pytest on Python 3.9 and 3.14; keep `requires-python` and that matrix in sync
- Ruff formats the Python snippets in `README.md` too
- Tests run with `--import-mode=importlib`, so share test helpers as fixtures in `conftest.py`, not by importing modules from `tests/`
- Tests cover logic only; when adding a `gatt` API call, add it to the fakes in `tests/conftest.py`
- Real verification needs hardware: a tap timer in range, `bluetoothd` running, and root (`sudo holmanctl --discover`, `--connect <MAC>`)
- Event-driven: nothing happens until `TapTimerManager.run()` starts the GLib loop; results arrive through listener callbacks, not return values

## Device Protocol
- Supported models are identified by GATT service UUID — `holman/holman.py:90-95`. A new model means adding its UUID to `SERVICE_UUIDS`
- Discovery also filters on the BLE alias `'Tap Timer'` — `holman/holman.py:41`
- Start/stop write a 4-byte value to the manual characteristic (byte 0 = on/off, byte 3 = minutes, capped at 255) — `holman/holman.py:228-243`
- On/off state is the last byte of the state characteristic — `holman/holman.py:217-221`
- Battery level is not decoded yet (`battery_level()` always returns `None`)

## Releasing
- Bump `version` in `pyproject.toml`, then `uv lock`, `uv build` and `uv publish`

## Python Version

Python 3.9 minimum (`requires-python` in pyproject.toml; ruff and pyrefly also target 3.9). Use the newest syntax 3.9 supports:
- `list[str]` instead of `List[str]`
- `dict[str, Any]` instead of `Dict[str, Any]`
- `tuple[int, str]` instead of `Tuple[int, str]`
- `X | None` instead of `Optional[X]` only in annotations, with `from __future__ import annotations` at the top of the module; `|` between types fails at runtime on 3.9 anywhere else (e.g. `isinstance`, aliases)
- No `match` statements (3.10) or `type X = ...` aliases (3.12); use `if`/`elif` and plain `X = ...` alias assignments (`typing.TypeAlias` is 3.10+, and `typing_extensions` isn't a dependency)

## Manage packages

This project uses uv. Do not use pip, pip-tools, poetry, or conda.

- Add runtime dependency: `uv add <package>` (writes to `[project.dependencies]`)
- Add dev dependency: `uv add --dev <package>` (writes to `[dependency-groups]` per PEP 735)
- Remove dependency: `uv remove <package>`
- Sync environment from lockfile: `uv sync`
- Regenerate lockfile from constraints: `uv lock`
- Upgrade locked versions: `uv lock --upgrade`
- Commit `uv.lock` to version control (current uv guidance is to commit it for applications, CLIs, and libraries)

## Run code

Always use `uv run` to execute Python code and tools. Never call `python`, `pytest`, `ruff`, or other tools directly. They may not resolve to the project's virtual environment.

- Run a script: `uv run python script.py`
- Run a module: `uv run python -m module_name`
- Run a tool: `uv run pytest`, `uv run ruff check .`
- One-off tool (not a project dependency): `uvx <tool>`

## Test code

- Framework: pytest
- Run tests: `uv run pytest`
- Test files go in `tests/` at the project root
- Test file naming: `test_*.py`
- Test function naming: `test_*`
- No `__init__.py` needed in `tests/`
- Coverage: `uv add --dev coverage`, then `uv run coverage run -m pytest` and `uv run coverage report`

## Lint and format code

- Tool: ruff (handles both linting and formatting)
- Lint: `uv run ruff check .`
- Lint and auto-fix: `uv run ruff check --fix .`
- Format: `uv run ruff format .`
- Check formatting: `uv run ruff format --check .`
- Configuration lives in `pyproject.toml` under `[tool.ruff]`

## Check types

- Tool: pyrefly (preferred), ty, or mypy
- Run: `uv run pyrefly check`, `uv run ty check`, or `uv run mypy .`
- Configuration lives in `pyproject.toml` under `[tool.pyrefly]`, `[tool.ty]`, or `[tool.mypy]`

## Avoid these practices

- Do not create or activate virtual environments manually. uv manages `.venv/` automatically.
- Do not install packages globally or with `pip install`.
- Do not create `requirements.txt` for dependency management. Use `pyproject.toml` and `uv.lock`.
- Do not run `python setup.py` commands.
- Do not add dependencies to pyproject.toml by hand. Use `uv add`.
- If you must edit pyproject.toml directly, write dev dependencies under `[dependency-groups]` (PEP 735), not the legacy `[tool.uv.dev-dependencies]` table.

## Development Standards

### Git Workflow
- Branch names: `feature/description` or `fix/description`
- Commit messages: Follow conventional commits
- PR required before merge
- All CI/CD checks must pass
- Minimum 1 approval required

### Testing Requirements
- Minimum 80% code coverage
- All critical paths must have tests

35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ and [BTX1](https://www.holmanindustries.com.au/products/btx1-tap-mounted-smart-v
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.

## Prerequisites
The Holman SDK requires [Python 3.4+](https://www.python.org) and a recent installation of [BlueZ](http://www.bluez.org/). It is tested to work fine with BlueZ 5.44, slightly older versions should however work, too.
The Holman SDK requires [Python 3.9+](https://www.python.org) and a recent installation of [BlueZ](http://www.bluez.org/). It is tested to work fine with BlueZ 5.44, slightly older versions should however work, too.

## Installation
These instructions assume a Debian-based Linux.
Expand Down Expand Up @@ -87,11 +87,13 @@ Please note that communication with your Bluetooth adapter happens over BlueZ's
```python
import holman


class TapTimerManagerPrintListener(holman.TapTimerManagerListener):
def tap_timer_discovered(self, tap_timer):
print("Discovered Holman tap_timer", tap_timer.mac_address)

manager = holman.TapTimerManager(adapter_name='hci0')

manager = holman.TapTimerManager(adapter_name="hci0")
manager.listener = TapTimerManagerPrintListener()
manager.start_discovery()
manager.run()
Expand All @@ -108,10 +110,11 @@ The following example connects to a Holman tap timer manually:
```python
import holman

manager = holman.TapTimerManager(adapter_name='hci0')
manager = holman.TapTimerManager(adapter_name="hci0")

tap_timer = holman.TapTimer(mac_address='AA:BB:CC:DD:EE:FF', manager=manager)
tap_timer.listener = holman.TapTimerListener() # Use an instance of your own holman.TapTimerListener subclass
tap_timer = holman.TapTimer(mac_address="AA:BB:CC:DD:EE:FF", manager=manager)
# Use an instance of your own holman.TapTimerListener subclass
tap_timer.listener = holman.TapTimerListener()
tap_timer.connect()

manager.run()
Expand All @@ -123,6 +126,28 @@ As with Holman tap timer discovery, remember to start the Bluetooth event loop w

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.

## Development

The project uses [uv](https://docs.astral.sh/uv/) for environment and dependency management.

```
uv sync # create .venv with the SDK and dev tools
uv run pytest # run the tests (no Bluetooth hardware needed)
uv run ruff check . # lint
uv run ruff format . # format
uv run pyrefly check # type check
uv build # build sdist and wheel into dist/
```

The tests replace the `gatt` library with an in-memory fake (`tests/conftest.py`), so they don't need `python3-dbus` or `bluetoothd`. Testing against a real tap timer still requires the system setup above; to use the apt-installed `python3-dbus` and `python3-gi` from the uv environment, create it on the system interpreter they were built for, then run `holmanctl` from it:

```
sudo apt-get install python3-dbus python3-gi
uv venv --system-site-packages --python /usr/bin/python3
uv sync
sudo .venv/bin/holmanctl --discover
```

## Support

Please open an issue.
Expand Down
9 changes: 8 additions & 1 deletion holman/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
from .holman import TapTimerManager, TapTimerManagerListener, TapTimer, TapTimerListener
from .holman import TapTimer, TapTimerListener, TapTimerManager, TapTimerManagerListener

__all__ = [
"TapTimer",
"TapTimerListener",
"TapTimerManager",
"TapTimerManagerListener",
]
85 changes: 52 additions & 33 deletions holman/holman.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""
Module for managing Holman Bluetooth tap timers.
"""

import gatt


Expand All @@ -9,7 +10,7 @@ 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"):
"""
Instantiates a ``TapTimerManager``

Expand All @@ -36,9 +37,8 @@ def start_discovery(self, service_uuids=None):
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)
if device.alias() != 'Tap Timer':
device = gatt.Device(mac_address=mac_address, manager=self, managed=False)
if device.alias() != "Tap Timer":
return None
return TapTimer(mac_address=mac_address, manager=self)

Expand All @@ -51,7 +51,7 @@ def device_discovered(self, device):
self.listener.tap_timer_discovered(device)


class TapTimerManagerListener(object):
class TapTimerManagerListener:
"""
Base class for receiving discovery events from ``TapTimerManager``.

Expand Down Expand Up @@ -87,14 +87,12 @@ class TapTimer(gatt.Device):
notified with all events
"""

HOLMAN_CO3015_SERVICE_UUID = '0a75f000-f9ad-467a-e564-3c19163ad543'
HOLMAN_CO3011_SERVICE_UUID = 'c521f000-0d70-4d4f-8e43-40d84c50ab38' # model BTX1
STATE_CHARACTERISTIC_UUID = '0000f004-0000-1000-8000-00805f9b34fb'
MANUAL_CHARACTERISTIC_UUID = '0000f006-0000-1000-8000-00805f9b34fb'
HOLMAN_CO3015_SERVICE_UUID = "0a75f000-f9ad-467a-e564-3c19163ad543"
HOLMAN_CO3011_SERVICE_UUID = "c521f000-0d70-4d4f-8e43-40d84c50ab38" # model BTX1
STATE_CHARACTERISTIC_UUID = "0000f004-0000-1000-8000-00805f9b34fb"
MANUAL_CHARACTERISTIC_UUID = "0000f006-0000-1000-8000-00805f9b34fb"

SERVICE_UUIDS = [
HOLMAN_CO3015_SERVICE_UUID,
HOLMAN_CO3011_SERVICE_UUID]
SERVICE_UUIDS = [HOLMAN_CO3015_SERVICE_UUID, HOLMAN_CO3011_SERVICE_UUID]

def __init__(self, mac_address, manager):
"""
Expand Down Expand Up @@ -150,36 +148,56 @@ def disconnect_succeeded(self):
def services_resolved(self):
super().services_resolved()

holman_service = next((
service for service in self.services
if service.uuid in self.SERVICE_UUIDS), None)
holman_service = next(
(
service
for service in self.services
if service.uuid in self.SERVICE_UUIDS
),
None,
)
if holman_service is None:
if self.listener:
# TODO: Use proper exception subclass
self.listener.connect_failed(
Exception("Holman GATT service missing"))
self.listener.connect_failed(Exception("Holman GATT service missing"))
return

self._manual_characteristic = next((
char for char in holman_service.characteristics
if char.uuid == self.MANUAL_CHARACTERISTIC_UUID), None)
self._manual_characteristic = next(
(
char
for char in holman_service.characteristics
if char.uuid == self.MANUAL_CHARACTERISTIC_UUID
),
None,
)
if self._manual_characteristic is None:
# TODO: Use proper exception subclass
self.listener.connect_failed(
Exception(
"Holman GATT characteristic %s missing",
self.MANUAL_CHARACTERISTIC_UUID))
if self.listener:
# TODO: Use proper exception subclass
self.listener.connect_failed(
Exception(
f"Holman GATT characteristic "
f"{self.MANUAL_CHARACTERISTIC_UUID} missing"
)
)
return

self._state_characteristic = next((
char for char in holman_service.characteristics
if char.uuid == self.STATE_CHARACTERISTIC_UUID), None)
self._state_characteristic = next(
(
char
for char in holman_service.characteristics
if char.uuid == self.STATE_CHARACTERISTIC_UUID
),
None,
)
if self._state_characteristic is None:
# TODO: Use proper exception subclass
self.listener.connect_failed(
Exception(
"Holman GATT characteristic %s missing",
self.STATE_CHARACTERISTIC_UUID))
if self.listener:
# TODO: Use proper exception subclass
self.listener.connect_failed(
Exception(
f"Holman GATT characteristic "
f"{self.STATE_CHARACTERISTIC_UUID} missing"
)
)
return
self._refresh_state()

Expand Down Expand Up @@ -236,6 +254,7 @@ class TapTimerListener:
Base class of listeners for a ``TapTimer`` with empty handler
implementations.
"""

def started_connecting(self):
pass

Expand Down
Loading
Loading