aosmith-ble 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. aosmith_ble-0.1.0/.github/ISSUE_TEMPLATE/config.yml +1 -0
  2. aosmith_ble-0.1.0/.github/ISSUE_TEMPLATE/device-support.yml +44 -0
  3. aosmith_ble-0.1.0/.github/workflows/ci.yml +33 -0
  4. aosmith_ble-0.1.0/.github/workflows/docs.yml +29 -0
  5. aosmith_ble-0.1.0/.github/workflows/release.yml +44 -0
  6. aosmith_ble-0.1.0/.gitignore +8 -0
  7. aosmith_ble-0.1.0/.pre-commit-config.yaml +39 -0
  8. aosmith_ble-0.1.0/CHANGELOG.md +47 -0
  9. aosmith_ble-0.1.0/CLAUDE.md +64 -0
  10. aosmith_ble-0.1.0/LICENSE +21 -0
  11. aosmith_ble-0.1.0/PKG-INFO +89 -0
  12. aosmith_ble-0.1.0/README.md +73 -0
  13. aosmith_ble-0.1.0/docs/contributing/does-my-heater-work.md +51 -0
  14. aosmith_ble-0.1.0/docs/development/design-principles.md +14 -0
  15. aosmith_ble-0.1.0/docs/development/handoff.md +30 -0
  16. aosmith_ble-0.1.0/docs/development/index.md +8 -0
  17. aosmith_ble-0.1.0/docs/development/releasing.md +16 -0
  18. aosmith_ble-0.1.0/docs/development/test-vectors.md +19 -0
  19. aosmith_ble-0.1.0/docs/hardware/hot-water-availability.md +11 -0
  20. aosmith_ble-0.1.0/docs/hardware/maintenance-display.md +48 -0
  21. aosmith_ble-0.1.0/docs/hardware/operating-limits.md +21 -0
  22. aosmith_ble-0.1.0/docs/index.md +36 -0
  23. aosmith_ble-0.1.0/docs/protocol/block-11.md +104 -0
  24. aosmith_ble-0.1.0/docs/protocol/blocks.md +171 -0
  25. aosmith_ble-0.1.0/docs/protocol/commands.md +152 -0
  26. aosmith_ble-0.1.0/docs/protocol/connection.md +83 -0
  27. aosmith_ble-0.1.0/docs/protocol/crc.md +34 -0
  28. aosmith_ble-0.1.0/docs/protocol/faults.md +68 -0
  29. aosmith_ble-0.1.0/docs/protocol/handshake.md +155 -0
  30. aosmith_ble-0.1.0/docs/protocol/reference.md +56 -0
  31. aosmith_ble-0.1.0/docs/research/captures.md +74 -0
  32. aosmith_ble-0.1.0/docs/research/index.md +21 -0
  33. aosmith_ble-0.1.0/docs/research/methodology.md +8 -0
  34. aosmith_ble-0.1.0/docs/research/open-questions.md +25 -0
  35. aosmith_ble-0.1.0/docs/research/sources.md +32 -0
  36. aosmith_ble-0.1.0/docs/research/timed-modes-experiment.md +54 -0
  37. aosmith_ble-0.1.0/docs/tested-devices.md +30 -0
  38. aosmith_ble-0.1.0/pyproject.toml +36 -0
  39. aosmith_ble-0.1.0/scripts/check_identifiers.py +80 -0
  40. aosmith_ble-0.1.0/src/aosmith_ble/__init__.py +59 -0
  41. aosmith_ble-0.1.0/src/aosmith_ble/client.py +775 -0
  42. aosmith_ble-0.1.0/src/aosmith_ble/codecs.py +128 -0
  43. aosmith_ble-0.1.0/src/aosmith_ble/const.py +123 -0
  44. aosmith_ble-0.1.0/src/aosmith_ble/crc.py +283 -0
  45. aosmith_ble-0.1.0/src/aosmith_ble/diagnostics.py +113 -0
  46. aosmith_ble-0.1.0/src/aosmith_ble/exceptions.py +76 -0
  47. aosmith_ble-0.1.0/src/aosmith_ble/models.py +64 -0
  48. aosmith_ble-0.1.0/src/aosmith_ble/profiles/__init__.py +316 -0
  49. aosmith_ble-0.1.0/src/aosmith_ble/profiles/bundled.py +105 -0
  50. aosmith_ble-0.1.0/src/aosmith_ble/protocol.py +230 -0
  51. aosmith_ble-0.1.0/src/aosmith_ble/py.typed +0 -0
  52. aosmith_ble-0.1.0/tests/fake_transport.py +43 -0
  53. aosmith_ble-0.1.0/tests/fixtures/frames.json +50 -0
  54. aosmith_ble-0.1.0/tests/test_client_diagnose.py +460 -0
  55. aosmith_ble-0.1.0/tests/test_client_hooks.py +260 -0
  56. aosmith_ble-0.1.0/tests/test_client_lifecycle.py +65 -0
  57. aosmith_ble-0.1.0/tests/test_client_session.py +445 -0
  58. aosmith_ble-0.1.0/tests/test_client_state.py +289 -0
  59. aosmith_ble-0.1.0/tests/test_client_writes.py +523 -0
  60. aosmith_ble-0.1.0/tests/test_codecs.py +82 -0
  61. aosmith_ble-0.1.0/tests/test_const_and_models.py +92 -0
  62. aosmith_ble-0.1.0/tests/test_crc.py +41 -0
  63. aosmith_ble-0.1.0/tests/test_diagnostics.py +122 -0
  64. aosmith_ble-0.1.0/tests/test_docs_tested_devices.py +59 -0
  65. aosmith_ble-0.1.0/tests/test_exceptions.py +37 -0
  66. aosmith_ble-0.1.0/tests/test_models.py +31 -0
  67. aosmith_ble-0.1.0/tests/test_profiles.py +673 -0
  68. aosmith_ble-0.1.0/tests/test_profiles_bundled.py +99 -0
  69. aosmith_ble-0.1.0/tests/test_protocol_ascii_and_at.py +32 -0
  70. aosmith_ble-0.1.0/tests/test_protocol_mode.py +40 -0
  71. aosmith_ble-0.1.0/tests/test_protocol_read_failure_forms.py +63 -0
  72. aosmith_ble-0.1.0/tests/test_protocol_read_write.py +106 -0
  73. aosmith_ble-0.1.0/tests/test_protocol_session.py +106 -0
  74. aosmith_ble-0.1.0/tests/test_protocol_temperature.py +30 -0
  75. aosmith_ble-0.1.0/tests/test_scaffold.py +70 -0
  76. aosmith_ble-0.1.0/uv.lock +846 -0
  77. aosmith_ble-0.1.0/zensical.toml +132 -0
@@ -0,0 +1 @@
1
+ blank_issues_enabled: true
@@ -0,0 +1,44 @@
1
+ name: Device support request
2
+ description: Ask for support for a heater that connect() refuses, using the diagnostic report.
3
+ title: "Device support: <model> firmware <x.y>"
4
+ labels: ["device-support"]
5
+ body:
6
+ - type: markdown
7
+ attributes:
8
+ value: |
9
+ Thanks for helping. This only covers **read-only** support to start with. Nothing here
10
+ promises support for a model, and write support needs an observed write for the exact
11
+ model and firmware.
12
+
13
+ Run `AOSmithBLEClient.async_diagnose()` and paste `report.to_markdown()` below (it is
14
+ redacted by default; never use `redact=False`). See
15
+ "Does my heater work?" in the documentation for the steps.
16
+ - type: input
17
+ id: model
18
+ attributes:
19
+ label: Model on the rating plate
20
+ description: For example the model line, not the serial number.
21
+ validations:
22
+ required: true
23
+ - type: textarea
24
+ id: report
25
+ attributes:
26
+ label: Diagnostic report
27
+ description: Paste the output of `report.to_markdown()`.
28
+ render: markdown
29
+ validations:
30
+ required: true
31
+ - type: textarea
32
+ id: notes
33
+ attributes:
34
+ label: Anything else
35
+ description: Does the official app work with this heater? Did the Bluetooth button press change anything?
36
+ - type: checkboxes
37
+ id: checks
38
+ attributes:
39
+ label: Before submitting
40
+ options:
41
+ - label: My report has no Bluetooth address, no 18-character `02iQk...` value and no full serial number.
42
+ required: true
43
+ - label: I understand the diagnostic call only reads, and that support is not guaranteed.
44
+ required: true
@@ -0,0 +1,33 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches:
5
+ - main
6
+ - develop
7
+ pull_request:
8
+ permissions:
9
+ contents: read
10
+ jobs:
11
+ test:
12
+ runs-on: ubuntu-latest
13
+ strategy:
14
+ fail-fast: false
15
+ matrix:
16
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
17
+ steps:
18
+ - uses: actions/checkout@v7
19
+ - uses: astral-sh/setup-uv@v10.2.0
20
+ - run: uv sync --python ${{ matrix.python-version }}
21
+ - run: uv run pytest -q
22
+ lint:
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - uses: actions/checkout@v7
26
+ - uses: astral-sh/setup-uv@v10.2.0
27
+ - run: uvx prek run --all-files
28
+ docs:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v7
32
+ - uses: astral-sh/setup-uv@v10.2.0
33
+ - run: uvx zensical build --clean --strict
@@ -0,0 +1,29 @@
1
+ name: Documentation
2
+ on:
3
+ push:
4
+ branches:
5
+ - master
6
+ - main
7
+ permissions:
8
+ contents: read
9
+ pages: write
10
+ id-token: write
11
+ jobs:
12
+ deploy:
13
+ environment:
14
+ name: github-pages
15
+ url: ${{ steps.deployment.outputs.page_url }}
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/configure-pages@v6
19
+ - uses: actions/checkout@v7
20
+ - uses: actions/setup-python@v6
21
+ with:
22
+ python-version: 3.x
23
+ - run: pip install zensical
24
+ - run: zensical build --clean --strict
25
+ - uses: actions/upload-pages-artifact@v5
26
+ with:
27
+ path: site
28
+ - uses: actions/deploy-pages@v5
29
+ id: deployment
@@ -0,0 +1,44 @@
1
+ name: Release
2
+ on:
3
+ push:
4
+ tags:
5
+ - "v*"
6
+ permissions:
7
+ contents: read
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v7
13
+ - uses: astral-sh/setup-uv@v10.2.0
14
+ - name: Tag must match the pyproject version
15
+ run: |
16
+ version="$(uv version --short)"
17
+ if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
18
+ echo "tag ${GITHUB_REF_NAME} does not match pyproject version ${version}" >&2
19
+ exit 1
20
+ fi
21
+ - run: uv sync
22
+ - run: uv run pytest -q
23
+ - run: uv build
24
+ - name: Smoke test the built wheel
25
+ run: uv run --isolated --no-project --with dist/*.whl python -I -c "import aosmith_ble; print(aosmith_ble.__version__)"
26
+ - uses: actions/upload-artifact@v7.0.2
27
+ with:
28
+ name: dist
29
+ path: dist/
30
+ if-no-files-found: error
31
+ publish:
32
+ needs: build
33
+ runs-on: ubuntu-latest
34
+ environment:
35
+ name: pypi
36
+ url: https://pypi.org/p/aosmith-ble
37
+ permissions:
38
+ id-token: write
39
+ steps:
40
+ - uses: actions/download-artifact@v8.0.2
41
+ with:
42
+ name: dist
43
+ path: dist/
44
+ - uses: pypa/gh-action-pypi-publish@v1.14.2
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .venv/
8
+ site/
@@ -0,0 +1,39 @@
1
+ # Run with prek (https://github.com/j178/prek): `prek install`, then `prek run --all-files`.
2
+ # prek reads this file and is compatible with the pre-commit config format.
3
+ repos:
4
+ - repo: https://github.com/pre-commit/pre-commit-hooks
5
+ rev: v6.0.0
6
+ hooks:
7
+ - id: check-yaml
8
+ - id: check-toml
9
+ - id: check-json
10
+ - id: check-case-conflict
11
+ - id: detect-private-key
12
+ - id: end-of-file-fixer
13
+ exclude: "^LICENSE$"
14
+ - id: mixed-line-ending
15
+ args: ["--fix=lf"]
16
+ - id: trailing-whitespace
17
+ - repo: https://github.com/codespell-project/codespell
18
+ rev: v2.4.2
19
+ hooks:
20
+ - id: codespell
21
+ additional_dependencies: [tomli]
22
+ exclude: "^(uv.lock|tests/fixtures/.*)$"
23
+ - repo: https://github.com/astral-sh/ruff-pre-commit
24
+ rev: v0.15.6
25
+ hooks:
26
+ - id: ruff-check
27
+ - id: ruff-format
28
+ - repo: https://github.com/tox-dev/pyproject-fmt
29
+ rev: v2.18.1
30
+ hooks:
31
+ - id: pyproject-fmt
32
+ - repo: local
33
+ hooks:
34
+ - id: check-identifiers
35
+ name: block real device identifiers
36
+ entry: python scripts/check_identifiers.py
37
+ language: system
38
+ pass_filenames: true
39
+ exclude: "^uv.lock$"
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - `connect()` after a link lost under the client now closes the dead link
6
+ before opening a new one. Without this the heater rejected the new session
7
+ (hardware-observed), so a reused client could not reconnect after an
8
+ unexpected drop.
9
+ - `AOSmithBLEClient` takes three optional keyword-only hooks for embedding in a
10
+ long-running application: `client_class` (the bleak client class to connect
11
+ with), `device_resolver` (a callback returning the freshest `BLEDevice`, used
12
+ on every connect and every connection retry), and `on_disconnect` (a callback
13
+ for unexpected link loss). All default to the previous behavior.
14
+ - `bleak-retry-connector` is now imported unconditionally; the unreachable
15
+ fallback to a bare `BleakClient` is gone.
16
+ - `HeaterState` is removed. `AOSmithBLEClient.async_get_state()` now returns a
17
+ `FeatureSet` (`Mapping[FieldName, Feature]`) -- a `Feature` per profile
18
+ field graded at least `min_trust` (default `Trust.OK`), each carrying its
19
+ own `value`, `trust`, and `writable`. A field graded below `min_trust` is
20
+ absent from the mapping, never present with a placeholder.
21
+ - `FieldName` (a `StrEnum`) and `FieldScope` (`STATE`/`IDENTITY`) replace bare
22
+ string field names and the implicit assumption that every field lives in
23
+ Block 11.
24
+ - `DeviceInfo.serial` is now populated, once, at connect time.
25
+ - New `AOSmithBLEClient.async_diagnose()`: an independent connect/probe/
26
+ disconnect sequence that works even for a device `connect()` would refuse
27
+ outright, producing a `DiagnosticReport` with the existing `to_markdown()`
28
+ plus a new `to_fixture_entries()` -- redacted, `tests/fixtures/frames.json`-
29
+ shaped output suitable for a new-profile contribution PR.
30
+ - `Profile`/`FieldSpec`/`Match` device-profile system, with `__post_init__` structural
31
+ validation and inheritance (write trust demoted unless re-asserted).
32
+ - One bundled profile (`hpts50-6.3`).
33
+ - The connecting, writing async client (`AOSmithBLEClient`): session establishment,
34
+ profile matching, MTU and UNITS validation, state reads, and a single profile-gated
35
+ write choke point with fault-active read-only gating and write-then-read-back
36
+ verification.
37
+ - Release-on-demand and `BleakOutOfConnectionSlotsError` wrapping.
38
+ - Presence probing and the unknown-device diagnostic report.
39
+ - Distinguishes the two 7-byte short-response forms (`ReadRefusedError` vs.
40
+ `SessionError`) that the 0.0.1 release's `parse_read` treated identically.
41
+
42
+ ## 0.0.1 - Unreleased
43
+
44
+ - Initial protocol layer: frame construction/parsing, CRC-8, the session
45
+ handshake (pairing-slot init, challenge/response), setpoint and mode
46
+ codecs, fault code table.
47
+ - No Bluetooth I/O in this release; frame building and parsing only.
@@ -0,0 +1,64 @@
1
+ # CLAUDE.md
2
+
3
+ Guidance for working in this repository.
4
+
5
+ ## What this is
6
+
7
+ `aosmith-ble` is a Python library plus a documentation site for local Bluetooth LE
8
+ control of A.O. Smith iCOMM heat pump water heaters (no cloud). The library is in
9
+ `src/aosmith_ble/` (client, protocol, profiles, codecs, diagnostics). The protocol
10
+ knowledge and research record are in `docs/`, built with Zensical. Python only.
11
+
12
+ ## Commands
13
+
14
+ ```bash
15
+ uv sync
16
+ uv run pytest -q # the test suite, no hardware needed
17
+ uvx zensical serve # live docs preview
18
+ uvx zensical build --clean --strict # must pass with zero warnings
19
+ ```
20
+
21
+ Use `uv`/`uvx` for all Python work, `rg` for searching, `fd` for finding files.
22
+
23
+ ## Layout
24
+
25
+ - `src/aosmith_ble/protocol.py`, `crc.py`: frame building and parsing, CRC-8.
26
+ - `src/aosmith_ble/profiles/`: per-model field maps and trust grades.
27
+ - `src/aosmith_ble/client.py`: the connecting client. All writes go through one profile-gated path.
28
+ - `tests/`: unit tests with fixtures in `tests/fixtures/frames.json`.
29
+ - `docs/protocol/`, `docs/hardware/`, `docs/research/`, `docs/development/`: the site.
30
+ The nav lives in `zensical.toml`; every page must be listed there.
31
+ - `docs/superpowers/`: working plans and specs, if present. Excluded from the published
32
+ site by the exclude plugin in `zensical.toml`, so never link to it as a site page.
33
+
34
+ ## Safety rules
35
+
36
+ - Research is read-only. Never send a write whose encoding is not proven (byte-for-byte match with a captured frame, or a verified round trip).
37
+ - No speculative writes, no raw-write API, no blind write retries.
38
+ - Never raise the setpoint casually. Writes are capped at 140F absolute and 130F by default; the handbook range is 95-150F.
39
+ - Timed-mode writes are stateful: the device counts days down and reverts at zero. The setpoint is a scalar, not per-mode.
40
+ - GUEST mode has never been observed written; do not add it to any profile's `writable_modes` until a write is observed.
41
+ - Exploratory reads at non-zero start offsets coincided with a change in Block 11 params 9 and 14 (causation unproven). Keep reads to what the app does.
42
+
43
+ ## Hardware facts that cost time
44
+
45
+ - Only one central can connect at a time. Stop other clients first.
46
+ - A stale BlueZ connection blocks discovery (the heater stops advertising while connected): `bluetoothctl disconnect <mac>`.
47
+ - After about 10 minutes with no traffic the heater sleeps and needs a Bluetooth button
48
+ press to wake.
49
+ - The central must answer the heater's ATT MTU request or no notifications arrive.
50
+ - The proprietary service UUID is not advertised. Discovery matches on the `iCOMM-*`
51
+ local name.
52
+
53
+ ## Docs rules
54
+
55
+ - Tag every protocol claim as verified on hardware, from the handbook, or unverified. Never upgrade confidence while moving text.
56
+ - Never put a real MAC, serial, assetID, pairing code or advertised name in docs,
57
+ fixtures or tests. Use `AA:BB:CC:DD:EE:FF`, `iCOMM-AC000W037XXXXXX`,
58
+ `02iQk000000EXAMPLE`, `XXXXXX`, `<plate serial>`. Part number `100350404` and model
59
+ `HPTS-50` are model-level and fine.
60
+ - Test vectors are synthetic. See `docs/development/test-vectors.md` before changing
61
+ `ASSET_ID` or `frames.json`.
62
+ - docs/research and docs/hardware are language-neutral: no code snippets or library
63
+ references. State current facts only; no revision history or dates.
64
+ - Use `!!! warning` admonitions, not GitHub-style alerts, and relative links only.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Trenton Holmes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,89 @@
1
+ Metadata-Version: 2.5
2
+ Name: aosmith-ble
3
+ Version: 0.1.0
4
+ Summary: Local Bluetooth LE client for A.O. Smith iCOMM heat pump water heaters
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Classifier: Programming Language :: Python :: 3 :: Only
8
+ Classifier: Programming Language :: Python :: 3.11
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Python: >=3.11
13
+ Requires-Dist: bleak-retry-connector>=3.4
14
+ Requires-Dist: bleak>=0.21
15
+ Description-Content-Type: text/markdown
16
+
17
+ # aosmith-ble
18
+
19
+ Local Bluetooth LE control of A.O. Smith iCOMM heat pump water heaters -- no cloud
20
+ account, no vendor credentials, no internet connection involved. This project is
21
+ independent and unaffiliated with A.O. Smith.
22
+
23
+ Documentation: see the `docs/` folder or the project site.
24
+
25
+ Looking for the cloud API instead? See [py-aosmith](https://github.com/bdr99/py-aosmith).
26
+
27
+ ## What this is
28
+
29
+ An async client that connects directly to a water heater's BLE interface, matches it against a bundled device profile, and reads or writes only what that profile has verified. An unrecognized device is refused outright. To help add support for a new device, call `AOSmithBLEClient.async_diagnose()`. It connects, probes and disconnects on its own (so it works even where `connect()` refuses) and returns a `DiagnosticReport`: `to_markdown()` gives a human-readable report and `to_fixture_entries()` gives redacted, fixture-shaped captures for a profile-contribution PR.
30
+
31
+ ## Install
32
+
33
+ pip install git+https://github.com/stumpylog/aosmith-ble
34
+
35
+ ## Quickstart
36
+
37
+ ```python
38
+ import asyncio
39
+ from aosmith_ble import AOSmithBLEClient, FieldName, Mode, async_discover, pairing_code_from_name
40
+
41
+ async def main():
42
+ devices = await async_discover()
43
+ device = devices[0]
44
+ code = pairing_code_from_name(device.name)
45
+ async with AOSmithBLEClient(device, pairing_code=code) as client:
46
+ features = await client.async_get_state()
47
+ setpoint = features[FieldName.SETPOINT].value
48
+ mode = features[FieldName.MODE].value
49
+ fault = features[FieldName.FAULT].value
50
+ print(f"{setpoint}F, mode={Mode(mode.mode).name}, fault={fault.name}")
51
+ await client.async_set_setpoint(124.0)
52
+
53
+ asyncio.run(main())
54
+ ```
55
+
56
+ ## Pairing
57
+
58
+ Press the Bluetooth button on the control panel once before first use. The device hands out the "assetID" it authenticates sessions against only while its pairing slot is populated. Cache `AOSmithBLEClient.asset_id` (for example with the device address) and pass it back as `asset_id=` so the button press is a one-time step.
59
+
60
+ ## Embedding in a long-running application
61
+
62
+ A host that owns Bluetooth scanning and wants the client to survive reconnects can pass three optional, keyword-only hooks:
63
+
64
+ ```python
65
+ client = AOSmithBLEClient(
66
+ device,
67
+ pairing_code=code,
68
+ asset_id=cached_asset_id,
69
+ device_resolver=lambda: my_scanner.latest_device(address),
70
+ client_class=MyBleakClientSubclass,
71
+ on_disconnect=lambda: schedule_reconnect(),
72
+ )
73
+ ```
74
+
75
+ - `device_resolver` returns the freshest `BLEDevice` for the heater, or `None` if there is nothing newer. It is called on every `connect()` and on every connection retry, so a handle that changes between connects (a different adapter or relay) is picked up. Passing a `BLEDevice` as `device` and no resolver keeps the current behavior. Passing an address string makes the library run its own scan, so a host that owns scanning should pass a `BLEDevice` or a resolver instead.
76
+ - `client_class` is the bleak client class used to connect, `BleakClient` by default. Pass a subclass, such as `bleak_retry_connector.BleakClientWithServiceCache`, to change how the link behaves.
77
+ - `on_disconnect` is called with no arguments when the link is lost unexpectedly. It is not called for `disconnect()`, `async_release()` or a failed `connect()`. It runs on the event loop, must not block, and an exception it raises is logged and ignored. A drop while `connect()` is still setting up the session calls it as well, and `connect()` then raises its own error.
78
+
79
+ ## Safety model
80
+
81
+ Every write goes through one profile-gated path: setpoint and mode are checked against the profile's verified limits (never above 140F, 130F by default), a fault-active device is read-only, and every write is read back and compared before the call succeeds. The public API cannot write an arbitrary block or parameter.
82
+
83
+ ## What this doesn't do (yet)
84
+
85
+ No current-tank-temperature reading (the manufacturer's app never reads it either). No Home Assistant integration; that is a separate, HACS-distributed package. No support for devices other than the bundled profile: `connect()` refuses an unmatched device. Diagnostics are an explicit, separate call, never part of the automatic connection flow.
86
+
87
+ ## License
88
+
89
+ MIT. See `LICENSE`.
@@ -0,0 +1,73 @@
1
+ # aosmith-ble
2
+
3
+ Local Bluetooth LE control of A.O. Smith iCOMM heat pump water heaters -- no cloud
4
+ account, no vendor credentials, no internet connection involved. This project is
5
+ independent and unaffiliated with A.O. Smith.
6
+
7
+ Documentation: see the `docs/` folder or the project site.
8
+
9
+ Looking for the cloud API instead? See [py-aosmith](https://github.com/bdr99/py-aosmith).
10
+
11
+ ## What this is
12
+
13
+ An async client that connects directly to a water heater's BLE interface, matches it against a bundled device profile, and reads or writes only what that profile has verified. An unrecognized device is refused outright. To help add support for a new device, call `AOSmithBLEClient.async_diagnose()`. It connects, probes and disconnects on its own (so it works even where `connect()` refuses) and returns a `DiagnosticReport`: `to_markdown()` gives a human-readable report and `to_fixture_entries()` gives redacted, fixture-shaped captures for a profile-contribution PR.
14
+
15
+ ## Install
16
+
17
+ pip install git+https://github.com/stumpylog/aosmith-ble
18
+
19
+ ## Quickstart
20
+
21
+ ```python
22
+ import asyncio
23
+ from aosmith_ble import AOSmithBLEClient, FieldName, Mode, async_discover, pairing_code_from_name
24
+
25
+ async def main():
26
+ devices = await async_discover()
27
+ device = devices[0]
28
+ code = pairing_code_from_name(device.name)
29
+ async with AOSmithBLEClient(device, pairing_code=code) as client:
30
+ features = await client.async_get_state()
31
+ setpoint = features[FieldName.SETPOINT].value
32
+ mode = features[FieldName.MODE].value
33
+ fault = features[FieldName.FAULT].value
34
+ print(f"{setpoint}F, mode={Mode(mode.mode).name}, fault={fault.name}")
35
+ await client.async_set_setpoint(124.0)
36
+
37
+ asyncio.run(main())
38
+ ```
39
+
40
+ ## Pairing
41
+
42
+ Press the Bluetooth button on the control panel once before first use. The device hands out the "assetID" it authenticates sessions against only while its pairing slot is populated. Cache `AOSmithBLEClient.asset_id` (for example with the device address) and pass it back as `asset_id=` so the button press is a one-time step.
43
+
44
+ ## Embedding in a long-running application
45
+
46
+ A host that owns Bluetooth scanning and wants the client to survive reconnects can pass three optional, keyword-only hooks:
47
+
48
+ ```python
49
+ client = AOSmithBLEClient(
50
+ device,
51
+ pairing_code=code,
52
+ asset_id=cached_asset_id,
53
+ device_resolver=lambda: my_scanner.latest_device(address),
54
+ client_class=MyBleakClientSubclass,
55
+ on_disconnect=lambda: schedule_reconnect(),
56
+ )
57
+ ```
58
+
59
+ - `device_resolver` returns the freshest `BLEDevice` for the heater, or `None` if there is nothing newer. It is called on every `connect()` and on every connection retry, so a handle that changes between connects (a different adapter or relay) is picked up. Passing a `BLEDevice` as `device` and no resolver keeps the current behavior. Passing an address string makes the library run its own scan, so a host that owns scanning should pass a `BLEDevice` or a resolver instead.
60
+ - `client_class` is the bleak client class used to connect, `BleakClient` by default. Pass a subclass, such as `bleak_retry_connector.BleakClientWithServiceCache`, to change how the link behaves.
61
+ - `on_disconnect` is called with no arguments when the link is lost unexpectedly. It is not called for `disconnect()`, `async_release()` or a failed `connect()`. It runs on the event loop, must not block, and an exception it raises is logged and ignored. A drop while `connect()` is still setting up the session calls it as well, and `connect()` then raises its own error.
62
+
63
+ ## Safety model
64
+
65
+ Every write goes through one profile-gated path: setpoint and mode are checked against the profile's verified limits (never above 140F, 130F by default), a fault-active device is read-only, and every write is read back and compared before the call succeeds. The public API cannot write an arbitrary block or parameter.
66
+
67
+ ## What this doesn't do (yet)
68
+
69
+ No current-tank-temperature reading (the manufacturer's app never reads it either). No Home Assistant integration; that is a separate, HACS-distributed package. No support for devices other than the bundled profile: `connect()` refuses an unmatched device. Diagnostics are an explicit, separate call, never part of the automatic connection flow.
70
+
71
+ ## License
72
+
73
+ MIT. See `LICENSE`.
@@ -0,0 +1,51 @@
1
+ # Does my heater work?
2
+
3
+ `connect()` refuses any heater without a verified profile, because the library writes to a real appliance and the firmware is not trusted to validate input. The one bundled profile is `hpts50-6.3` ([tested devices](../tested-devices.md)).
4
+
5
+ If your heater is refused, you can still help. `async_diagnose()` is a separate, explicit, read-only call that connects, probes and disconnects on its own, and produces a report you can paste into an issue.
6
+
7
+ ## What it does and does not do
8
+
9
+ - It reads only, and never writes.
10
+ - It asks each block id from 0 to 40 for a single word starting at offset 0, with a short
11
+ pause between reads, to record which blocks answer.
12
+ - It reads the identity block to record the model bytes and firmware.
13
+ - It disconnects when finished, whether or not anything matched.
14
+
15
+ ## Run it
16
+
17
+ Press the Bluetooth button on the control panel once first, so the heater hands out the session value the library needs ([Handshake](../protocol/handshake.md#the-assetid)). Close the official app and any other client first, since only one can connect.
18
+
19
+ ```python
20
+ import asyncio
21
+
22
+ from aosmith_ble import AOSmithBLEClient, async_discover, pairing_code_from_name
23
+
24
+
25
+ async def main() -> None:
26
+ device = (await async_discover())[0]
27
+ client = AOSmithBLEClient(device, pairing_code=pairing_code_from_name(device.name))
28
+ report = await client.async_diagnose()
29
+ print(report.to_markdown()) # redacted by default
30
+
31
+
32
+ asyncio.run(main())
33
+ ```
34
+
35
+ `to_markdown()` redacts the session value, serial, Bluetooth address and any Wi-Fi name
36
+ by default. Do not pass `redact=False` when posting publicly.
37
+
38
+ ## File a report
39
+
40
+ Open a "Device support request" issue and paste the report. Before you
41
+ post, check it contains none of these:
42
+
43
+ - your heater's Bluetooth address
44
+ - the 18-character session value (it starts `02iQk`)
45
+ - the full serial number from the rating plate or the advertised name's last digits
46
+
47
+ To contribute fixtures, use `report.to_fixture_entries()` (redacted frames shaped like `tests/fixtures/frames.json`), never raw captures. Why: [Test vectors](../development/test-vectors.md).
48
+
49
+ ## What happens next
50
+
51
+ A maintainer compares your block presence and model bytes with the [block map](../protocol/blocks.md) and the evidence rules in [Research](../research/index.md). A report does not promise support for any model. A new profile starts read-only, and write support is added only after an observed write exists for that exact model and firmware.
@@ -0,0 +1,14 @@
1
+ # Design principles
2
+
3
+ 1. **Audience.** Published for the community, aware of multiple models and firmware versions.
4
+ 2. **No speculative writes, ever.** Confidence comes from read-side validation, limits from the verified profile, and frames from range-checked values. No public raw-write API, no blind write retries.
5
+ 3. **Strict validation.** An unrecognized device is refused outright, and the refusal produces a complete diagnostic report suitable for pasting into an issue.
6
+ 4. **One connection.** Hold a single connection open and poll. Surface the sleep timeout as a repair ("press the Bluetooth button") in the Home Assistant integration.
7
+ 5. **Writes.** Setpoint, mode, and timed modes.
8
+ 6. **Two packages.** `aosmith-ble` is the library. A separate Home Assistant integration (`ha-aosmith-ble`, distributed through HACS) builds on it.
9
+
10
+ ## Compatibility table
11
+
12
+ Compatibility table: contributors run `async_diagnose()` on an unknown device, and `DiagnosticReport.to_fixture_entries()` plus `draft_profile_stanza` become the pull request artifact for a new bundled profile.
13
+
14
+ Frame formats: [Commands](../protocol/commands.md). Low-level frame builders exist for testing and cannot transmit; the [roadmap](handoff.md) tracks whether they stay public. None is a supported write API.
@@ -0,0 +1,30 @@
1
+ # Roadmap
2
+
3
+ What is still open. Read `README.md` and `CHANGELOG.md` for what the library does today and [Design principles](design-principles.md) for why.
4
+
5
+ ## Smaller library items
6
+
7
+ | Item | Detail |
8
+ | --- | --- |
9
+ | `Profile.__post_init__` message ("unless this profile is only ever used as a parent via `inherits`") promises a case that later fails in `resolve_inherits` | Fix the wording, or add an `abstract` flag. |
10
+ | Every bundled profile is directly matchable, including future parents | `match_profile` iterates all profiles with a `match`. Harmless with one profile; needs an `abstract` concept before a second is added via `inherits`. |
11
+ | `CODEC_REGISTRY` is a plain mutable `dict` | Wrap in `types.MappingProxyType`. |
12
+ | `duration_days` type checking | The codec range-checks but does not type-check, so `1.5` raises `TypeError` instead of `ValidationError`, and `bool` is accepted as `0` or `1`. Neither reaches the wire. |
13
+ | `assert self._profile is not None` in the write path | In both write methods and in session and read helpers. Stripped under `python -O`; use an explicit `if ... raise`. |
14
+ | `protocol.set_setpoint_request` and `set_mode_request` are public but unused by the client | Only tests use them (to build expected frames), `set_setpoint_request` has no ceiling check, and neither is re-exported at top level. Keep as documented low-level helpers or move out of the public surface. |
15
+ | `pyproject.toml` pins `bleak>=0.21` with no upper bound | Consider `bleak>=0.21,<4`. |
16
+
17
+ ## Home Assistant integration
18
+
19
+ Needs a specification first: domain `aosmith_ble`, entity table, operation-mode mapping, config flow, coordinator, error and repair handling, diagnostics redaction. Not started. It belongs in its own repository (`ha-aosmith-ble`, via HACS). The diagnostics-only mode for unmatched devices can build on `async_diagnose()`.
20
+
21
+ ## Lower priority
22
+
23
+ - **Stale days-remaining counters.** The Vacation counter keeps its last value after an early switch to another mode (hardware-observed), so `vacation_days_remaining` can read 5 in Hybrid. Decide whether the library should report 0 or hide a counter unless its mode is active.
24
+
25
+ - **Publish to PyPI.** The package builds, but nothing has been uploaded. Do it deliberately, when someone wants it installable from an index.
26
+ - **Fingerprint-based matching.** The third matching outcome: fingerprint matches but firmware differs, so inherit addressing and demote to read-only. Blocked on a block-length detection tool that does not exist yet. Do not start before the Home Assistant integration.
27
+ - **GUEST mode.** No profile may add mode 3 to `writable_modes` until an observed write exists ([Open questions](../research/open-questions.md)).
28
+ - **Current tank temperature and hot-water availability.** Open leads ([Hot water availability](../hardware/hot-water-availability.md)). The official app reads neither over BLE. The [panel experiment](../hardware/maintenance-display.md) could extend to tank temperature, otherwise a new empirical approach is needed.
29
+
30
+ See also [Open questions](../research/open-questions.md).
@@ -0,0 +1,8 @@
1
+ # Development
2
+
3
+ For contributors: where the project stands and how it is built.
4
+
5
+ - [Roadmap](handoff.md): what is still open, item by item.
6
+ - [Design principles](design-principles.md): the settled principles behind the library.
7
+ - [Test vectors](test-vectors.md): why the HMAC vectors in the test suite are synthetic, and how to supply real ones.
8
+ - [Releasing](releasing.md): how a version is tagged and published.
@@ -0,0 +1,16 @@
1
+ # Releasing
2
+
3
+ The version lives in one place, `version` in `pyproject.toml`. `aosmith_ble.__version__` reads it from the installed package metadata.
4
+
5
+ To release:
6
+
7
+ 1. Set the new `version` in `pyproject.toml` and move the `CHANGELOG.md` entries under that version.
8
+ 2. Merge to `main` with the tests, lint and docs jobs green.
9
+ 3. Tag the merge commit `v<version>` (for example `v0.1.0`) and push the tag.
10
+
11
+ The release workflow then checks that the tag matches `pyproject.toml`, runs the tests, builds the sdist and wheel, smoke-tests the wheel in a clean environment, and publishes to PyPI. A tag that does not match the version fails before anything is built.
12
+
13
+ Publishing uses PyPI trusted publishing, so no API token is stored in the repository. The PyPI project trusts the `release.yml` workflow running in the `pypi` GitHub environment.
14
+
15
+ !!! warning
16
+ A version published to PyPI can be yanked but never replaced. Check the built files before tagging, and use a new version number for any fix.
@@ -0,0 +1,19 @@
1
+ # Test vectors
2
+
3
+ The test vectors for the `0xF1` challenge response and the init response are synthetic. The HMAC vectors in `tests/test_protocol_session.py` and the `init_response_slot_populated` frame in `tests/fixtures/frames.json` are computed from a placeholder assetID (`02iQk000000EXAMPLE`, 18 characters like a real one). Tests use a dummy pairing code (`123456`).
4
+
5
+ ## What that means
6
+
7
+ A real capture is an independent check: the official app produced those bytes, so a match proves the code builds the same response. A synthetic vector is computed with `hmac` and `hashlib` using the same construction as the code under test, so it catches regressions but not a wrong construction.
8
+
9
+ The construction is in [Handshake](../protocol/handshake.md).
10
+
11
+ ## Supplying real vectors privately
12
+
13
+ For example from a capture of the official app, all from one device:
14
+
15
+ 1. Take the 18-character ASCII assetID from the `0xF2` init response (`DB 2F 18 01 <assetID> 80 <crc>`).
16
+ 2. For several sessions of that same device, take the 2-byte nonce from each `0xF4` response (`DB 4F 07 <nonce> 80 <crc>`) and the 20-byte payload from the `0xF1` write that follows it (`BD F1 19 01 <payload> <crc>`).
17
+ 3. In `tests/test_protocol_session.py`, replace `ASSET_ID` and `CHALLENGE_RESPONSE_PAIRS` with those values.
18
+ 4. Other tests and `tests/fixtures/frames.json` reuse the placeholder. If you change it everywhere, recompute the CRC of `init_response_slot_populated` in `frames.json`. See [CRC](../protocol/crc.md).
19
+ 5. Redact device-identifying values, or keep real vectors private and run them locally.