python-hilo 2026.10.1__tar.gz → 2026.10.2__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 (73) hide show
  1. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.devcontainer/devcontainer.json +1 -1
  2. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/workflows/tests.yaml +14 -10
  3. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.pre-commit-config.yaml +2 -2
  4. python_hilo-2026.10.2/AGENTS.md +56 -0
  5. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/PKG-INFO +6 -5
  6. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/README.md +2 -2
  7. python_hilo-2026.10.2/doc/README.md +92 -0
  8. python_hilo-2026.10.2/doc/graphql/onAnyDeviceUpdated-cee.json +33 -0
  9. python_hilo-2026.10.2/doc/graphql/onAnyDeviceUpdated-meter.json +22 -0
  10. python_hilo-2026.10.2/doc/rest/location_weather.json +7 -0
  11. python_hilo-2026.10.2/doc/rest/season_events_ch_2025.json +42 -0
  12. python_hilo-2026.10.2/doc/rest/season_events_ch_2026_empty.json +5 -0
  13. python_hilo-2026.10.2/doc/rest/season_events_hilo_2024.json +36 -0
  14. python_hilo-2026.10.2/doc/rest/seasons_summary.json +27 -0
  15. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/__init__.py +2 -0
  16. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/api.py +27 -15
  17. python_hilo-2026.10.2/pyhilo/auth.py +42 -0
  18. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/const.py +1 -1
  19. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/device/graphql_value_mapper.py +1 -1
  20. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyproject.toml +6 -5
  21. python_hilo-2026.10.2/tests/test_api_auth.py +135 -0
  22. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/tests/test_device.py +20 -9
  23. python_hilo-2026.10.2/tests/test_graphql_value_mapper.py +23 -0
  24. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/tests/test_util_state.py +41 -9
  25. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/uv.lock +29 -415
  26. python_hilo-2026.10.1/doc/Readme.md +0 -111
  27. python_hilo-2026.10.1/tests/conftest.py +0 -18
  28. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.bandit.yaml +0 -0
  29. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.devcontainer/devcontainer-lock.json +0 -0
  30. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.editorconfig +0 -0
  31. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.gitattributes +0 -0
  32. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  33. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/ISSUE_TEMPLATE/issue.md +0 -0
  34. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/dependabot.yml +0 -0
  35. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/labels.yml +0 -0
  36. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/release-drafter.yml +0 -0
  37. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/workflows/labeler.yml +0 -0
  38. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.github/workflows/release-drafter.yml +0 -0
  39. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/.gitignore +0 -0
  40. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/LICENSE +0 -0
  41. /python_hilo-2026.10.1/doc/Postman/Hilo Prod.postman_environment.json → /python_hilo-2026.10.2/doc/postman/hilo-prod.postman_environment.json +0 -0
  42. /python_hilo-2026.10.1/doc/Postman/Hilo.postman_collection.json → /python_hilo-2026.10.2/doc/postman/hilo.postman_collection.json +0 -0
  43. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/ChallengeAdded.json +0 -0
  44. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventAdded.json +0 -0
  45. /python_hilo-2026.10.1/doc/Challenge(Event) JSON/EventCHConsumptionUpdatedValuesReceived(en cours de d/303/251fi).json" → /python_hilo-2026.10.2/doc/signalr/challengehub/EventCHConsumptionUpdatedValuesReceived-during-event.json +0 -0
  46. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventCHConsumptionUpdatedValuesReceived.json +0 -0
  47. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventCHDetailsInitialValuesReceived.json +0 -0
  48. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventCHDetailsUpdatedValuesReceived.json +0 -0
  49. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventFlexConsumptionUpdatedValuesReceived.json +0 -0
  50. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventFlexDetailsInitialValuesReceived.json +0 -0
  51. {python_hilo-2026.10.1/doc/Challenge(Event) JSON → python_hilo-2026.10.2/doc/signalr/challengehub}/EventFlexDetailsUpdatedValuesReceived.json +0 -0
  52. {python_hilo-2026.10.1/doc/Old JSONs (pre 2025-26 season) → python_hilo-2026.10.2/doc/signalr/challengehub/legacy-pre-2025}/ChallengeAdded.json +0 -0
  53. {python_hilo-2026.10.1/doc/Old JSONs (pre 2025-26 season) → python_hilo-2026.10.2/doc/signalr/challengehub/legacy-pre-2025}/ChallengeConsumptionUpdatedValuesReceived.json +0 -0
  54. {python_hilo-2026.10.1/doc/Old JSONs (pre 2025-26 season) → python_hilo-2026.10.2/doc/signalr/challengehub/legacy-pre-2025}/ChallengeDetailsInitialValuesReceived.json +0 -0
  55. {python_hilo-2026.10.1/doc/Old JSONs (pre 2025-26 season) → python_hilo-2026.10.2/doc/signalr/challengehub/legacy-pre-2025}/ChallengeListInitialValuesReceived.json +0 -0
  56. {python_hilo-2026.10.1/doc/Old JSONs (pre 2025-26 season) → python_hilo-2026.10.2/doc/signalr/challengehub/legacy-pre-2025}/ChallengeListUpdatedValuesReceived.json +0 -0
  57. {python_hilo-2026.10.1/doc/Device Replies → python_hilo-2026.10.2/doc/signalr/devicehub}/DeviceListInitialValuesReceived.json +0 -0
  58. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/device/__init__.py +0 -0
  59. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/device/climate.py +0 -0
  60. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/device/light.py +0 -0
  61. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/device/sensor.py +0 -0
  62. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/device/switch.py +0 -0
  63. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/devices.py +0 -0
  64. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/event.py +0 -0
  65. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/exceptions.py +0 -0
  66. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/graphql.py +0 -0
  67. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/oauth2helper.py +0 -0
  68. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/signalr.py +0 -0
  69. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/util/__init__.py +0 -0
  70. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/pyhilo/util/state.py +0 -0
  71. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/tests/__init__.py +0 -0
  72. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/tests/test_signalr.py +0 -0
  73. {python_hilo-2026.10.1 → python_hilo-2026.10.2}/tests/test_util.py +0 -0
@@ -47,7 +47,7 @@
47
47
  "ghcr.io/devcontainers/features/github-cli:1": {},
48
48
  "ghcr.io/jsburckhardt/devcontainer-features/uv:1": {}
49
49
  },
50
- "image": "mcr.microsoft.com/devcontainers/python:3.11-bookworm",
50
+ "image": "mcr.microsoft.com/devcontainers/python:3-3.14-bookworm",
51
51
  "name": "A Python3, async interface to the Hilo API",
52
52
  "postCreateCommand": "uv sync --group dev --group test && uv run pre-commit install"
53
53
  }
@@ -8,7 +8,7 @@ on:
8
8
  pull_request:
9
9
 
10
10
  env:
11
- DEFAULT_PYTHON: 3.13
11
+ DEFAULT_PYTHON: "3.14"
12
12
 
13
13
  jobs:
14
14
  pre-commit:
@@ -19,12 +19,12 @@ jobs:
19
19
  uses: actions/checkout@v7
20
20
 
21
21
  - name: Set up Python ${{ env.DEFAULT_PYTHON }}
22
- uses: actions/setup-python@v7.0.0
22
+ uses: actions/setup-python@v7.1.0
23
23
  with:
24
24
  python-version: ${{ env.DEFAULT_PYTHON }}
25
25
 
26
26
  - name: Install uv
27
- uses: astral-sh/setup-uv@v10.2.0
27
+ uses: astral-sh/setup-uv@v10.3.1
28
28
 
29
29
  - name: Install dependencies
30
30
  run: uv sync --group dev
@@ -36,21 +36,25 @@ jobs:
36
36
 
37
37
  tests:
38
38
  runs-on: "ubuntu-latest"
39
- name: Tests
39
+ name: Tests (${{ matrix.python-version }})
40
+ strategy:
41
+ matrix:
42
+ # Mirrors the Hilo integration, which still supports Home Assistant releases on 3.13.
43
+ python-version: ["3.13", "3.14"]
40
44
  steps:
41
45
  - name: Check out the repository
42
46
  uses: actions/checkout@v7
43
47
 
44
- - name: Set up Python ${{ env.DEFAULT_PYTHON }}
45
- uses: actions/setup-python@v7.0.0
48
+ - name: Set up Python ${{ matrix.python-version }}
49
+ uses: actions/setup-python@v7.1.0
46
50
  with:
47
- python-version: ${{ env.DEFAULT_PYTHON }}
51
+ python-version: ${{ matrix.python-version }}
48
52
 
49
53
  - name: Install uv
50
- uses: astral-sh/setup-uv@v10.2.0
54
+ uses: astral-sh/setup-uv@v10.3.1
51
55
 
52
56
  - name: Install dependencies
53
- run: uv sync --group test
57
+ run: uv sync --python ${{ matrix.python-version }} --group test
54
58
 
55
59
  - name: Run tests
56
- run: uv run pytest -v
60
+ run: uv run --python ${{ matrix.python-version }} pytest -v
@@ -14,10 +14,10 @@ repos:
14
14
  hooks:
15
15
  - id: ruff-format
16
16
  args: [--quiet]
17
- files: ^pyhilo/.+\.py$
17
+ files: ^(pyhilo|tests)/.+\.py$
18
18
  - id: ruff
19
19
  args: [--fix, --exit-non-zero-on-fix]
20
- files: ^pyhilo/.+\.py$
20
+ files: ^(pyhilo|tests)/.+\.py$
21
21
  - repo: https://github.com/codespell-project/codespell
22
22
  rev: v2.4.1
23
23
  hooks:
@@ -0,0 +1,56 @@
1
+ # Coding Assistant Instructions: python-hilo Library
2
+
3
+ This repository contains `pyhilo`, a standalone asynchronous Python client library for Hilo energy services (SignalR WebSocket, GraphQL, and REST APIs).
4
+
5
+ ## Git Commit Guidelines
6
+
7
+ - **First line**: Short, specific imperative sentence (e.g. "Add abstract auth interface for token provider"). No emojis in titles.
8
+ - **Commit description**: Focus on the **WHY** (problem, context, and architectural rationale). Keep it concise, factual, and proportional.
9
+ - **Do NOT amend, squash, or rebase commits that have already been pushed to the PR branch after the PR is opened**: Reviewers need to follow the commit history and see what changed since their last review.
10
+
11
+ ## Development Commands
12
+
13
+ - Install dependencies: `uv sync`
14
+ - Run test suite: `uv run pytest`
15
+ - Run a specific test: `uv run pytest tests/test_device.py`
16
+ - Check linting and formatting: `uv run ruff check .` and `uv run ruff format --check .`
17
+
18
+ ## Architecture & Boundaries
19
+
20
+ - **Strict Decoupling from Home Assistant**:
21
+ - This library MUST NEVER import `homeassistant` or depend on Home Assistant Core modules, constants, or types.
22
+ - The library must be 100% installable, testable, and runnable independently in any standard Python 3.13+ environment.
23
+ - **Authentication Contract**:
24
+ - Expose an abstract base class `AbstractAuth` (`pyhilo.auth`):
25
+ ```python
26
+ class AbstractAuth(ABC):
27
+ @abstractmethod
28
+ async def async_get_access_token(self) -> str:
29
+ """Return a valid access token, refreshing if necessary."""
30
+ ```
31
+ - The library calls this method whenever it needs a valid token, leaving token storage and refresh mechanisms to the consumer (such as the Hilo Home Assistant integration or CLI tools).
32
+ - **Backwards Compatibility**: When modifying public API signatures, provide temporary shims with deprecation warnings for at least 1–2 minor versions to prevent breaking downstream consumers.
33
+ - **Credential Protection**: Never log raw access tokens, refresh tokens, passwords, or decoded JWT claims in `DEBUG` or `INFO` logs.
34
+
35
+ ## Testing Standards
36
+
37
+ - Verify library protocol behavior, event decoding, device mapping, and error handling.
38
+ - When writing tests or implementation code, ensure all parameters have precise type annotations. **The use of `Any` is strictly forbidden in new code.**
39
+ - **Single purpose tests**: Each test function must verify only one specific behavior or outcome. Avoid testing multiple unrelated scenarios in a single test; split them into focused tests or use parametrization.
40
+ - Prefer standard library `unittest.mock` (`AsyncMock`, `MagicMock`) over large bespoke stub classes.
41
+ - Prefer `@pytest.mark.usefixtures` over unused fixture arguments.
42
+ - Avoid branching (`if`/`else`) in tests; use `@pytest.mark.parametrize` with `pytest.param(..., id="...")` for table-driven testing.
43
+ - Tests must execute cleanly with `uv run pytest` without requiring network access.
44
+
45
+ ## Good practices
46
+
47
+ - Built with `asyncio`, `aiohttp`, `pysignalr`, and `gql`.
48
+ - Non-blocking I/O: avoid any synchronous disk reads/writes on active event loops. Accept explicit file paths when managing state.
49
+ - Keep `try` clauses as small as possible.
50
+ - When validation guarantees a dict key exists, prefer direct key access (`data["key"]`) instead of `.get("key")`.
51
+ - Keep comments concise and explain only the WHY. Do not restate code or use divider comments (`# --- XYZ ---`).
52
+ - CalVer format: Versions follow `vYYYY.MM.MICRO` (e.g. `v2026.10.1`).
53
+
54
+ ## AI policy
55
+
56
+ Autonomous contributions are not accepted. Every change must be reviewed and understood by a human contributor before submission. Never open pull requests, create release tags, or post comments autonomously without explicit contributor instruction.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-hilo
3
- Version: 2026.10.1
3
+ Version: 2026.10.2
4
4
  Summary: A Python3, async interface to the Hilo API
5
5
  Project-URL: Repository, https://github.com/dvd-dev/python-hilo
6
6
  Author-email: David Vallee Delisle <me@dvd.dev>
@@ -11,11 +11,12 @@ Classifier: Framework :: aiohttp
11
11
  Classifier: License :: OSI Approved :: MIT License
12
12
  Classifier: Programming Language :: Python
13
13
  Classifier: Programming Language :: Python :: 3
14
- Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Programming Language :: Python :: 3.14
15
16
  Classifier: Programming Language :: Python :: Implementation :: CPython
16
17
  Classifier: Programming Language :: Python :: Implementation :: PyPy
17
18
  Classifier: Topic :: Home Automation
18
- Requires-Python: >=3.11
19
+ Requires-Python: >=3.13
19
20
  Requires-Dist: aiofiles>=23.2.1
20
21
  Requires-Dist: aiohttp>=3.8.0
21
22
  Requires-Dist: backoff>=1.11.1
@@ -34,7 +35,7 @@ Description-Content-Type: text/markdown
34
35
  [![Build Status][build-shield]][build]
35
36
  [![Open in Dev Containers][devcontainer-shield]][devcontainer]
36
37
 
37
- `python-hilo` (aka `pyhilo`) is a Python 3.11, `asyncio`-driven interface to the unofficial
38
+ `python-hilo` (aka `pyhilo`) is a Python 3.13+, `asyncio`-driven interface to the unofficial
38
39
  Hilo API from Hydro Quebec. This is meant to be integrated into Home Assistant but can also
39
40
  be used as a standalone library.
40
41
 
@@ -63,7 +64,7 @@ for formatting and linting.
63
64
 
64
65
  You need at least:
65
66
 
66
- - Python 3.11+
67
+ - Python 3.13+
67
68
  - [uv][uv]
68
69
 
69
70
  To install all packages, including all development requirements:
@@ -6,7 +6,7 @@
6
6
  [![Build Status][build-shield]][build]
7
7
  [![Open in Dev Containers][devcontainer-shield]][devcontainer]
8
8
 
9
- `python-hilo` (aka `pyhilo`) is a Python 3.11, `asyncio`-driven interface to the unofficial
9
+ `python-hilo` (aka `pyhilo`) is a Python 3.13+, `asyncio`-driven interface to the unofficial
10
10
  Hilo API from Hydro Quebec. This is meant to be integrated into Home Assistant but can also
11
11
  be used as a standalone library.
12
12
 
@@ -35,7 +35,7 @@ for formatting and linting.
35
35
 
36
36
  You need at least:
37
37
 
38
- - Python 3.11+
38
+ - Python 3.13+
39
39
  - [uv][uv]
40
40
 
41
41
  To install all packages, including all development requirements:
@@ -0,0 +1,92 @@
1
+ # Hilo API reference payloads
2
+
3
+ Captured responses from Hilo's services, grouped by transport to mirror the library modules.
4
+ IDs, URNs and serial numbers in the payload files are anonymized.
5
+
6
+ | Folder | Transport | Library module | Contents |
7
+ |---|---|---|---|
8
+ | [`rest/`](rest/) | HTTPS REST | `pyhilo/api.py` | Automation (`/Locations`, `/Weather`) and challenge season endpoints |
9
+ | [`graphql/`](graphql/) | GraphQL subscription | `pyhilo/graphql.py` | `onAnyDeviceUpdated` payloads per device type |
10
+ | [`signalr/devicehub/`](signalr/devicehub/) | SignalR WebSocket | `pyhilo/signalr.py` | DeviceHub messages |
11
+ | [`signalr/challengehub/`](signalr/challengehub/) | SignalR WebSocket | `pyhilo/signalr.py` | ChallengeHub messages, 2025-26 season onward (CH = Winter Credits, Flex = Flex D) |
12
+ | [`signalr/challengehub/legacy-pre-2025/`](signalr/challengehub/legacy-pre-2025/) | SignalR WebSocket | — | Challenge messages before the CH/Flex split; kept for reference |
13
+ | [`postman/`](postman/) | — | — | Postman collection and environment for manual testing |
14
+
15
+ ## Adding a payload
16
+
17
+ 1. Strip tokens, location URNs, serial numbers and personal data before committing.
18
+ 2. Name REST files after the endpoint (`season_events_ch_2025.json`) and SignalR/GraphQL files after the message target (`EventCHDetailsInitialValuesReceived.json`).
19
+ 3. Add a short suffix for variants (`-during-event`, `-empty`, `-meter`).
20
+
21
+ # Using Postman to query Hilo
22
+
23
+ Import [`postman/hilo.postman_collection.json`](postman/hilo.postman_collection.json) and [`postman/hilo-prod.postman_environment.json`](postman/hilo-prod.postman_environment.json), then select the **Hilo Prod** environment.
24
+
25
+ ## Authentication
26
+
27
+ The collection uses OAuth 2.0 at the collection level. Open the collection's **Authorization** tab, click **Get New Access Token**, log in with your Hilo credentials in the browser window, then click **Use Token**. Every request in the collection inherits that token.
28
+
29
+ Tokens are short-lived and contain personally identifiable information. Do not share them or commit an exported environment that contains them.
30
+
31
+ ## REST requests
32
+
33
+ With the access token set, the REST requests (`1-Get Locations`, `Get SeasonsSummary`, `Get CHEvents`, `Get FlexEvents`, ...) can be sent directly.
34
+
35
+ ## Connecting to a hub
36
+
37
+ Each hub folder (`DeviceHub`, `ChallengeHub`) contains two negotiate requests. Send them in order:
38
+
39
+ 1. `1-API Hilo ... Hub Negotiate` stores the hub URL and hub access token in the environment (`_deviceHubUrl` / `_deviceHubAccessToken`, or `_challengeHubUrl` / `_challengeHubAccessToken`).
40
+ 2. `2-... Hub Negotiate` stores the connection token (`_deviceHubConnectionToken` or `_challengeHubConnectionToken`).
41
+
42
+ Then open a new WebSocket request in Postman:
43
+
44
+ 1. Use the hub URL from the environment (`{{_deviceHubUrl}}` or `{{_challengeHubUrl}}`).
45
+ 2. In **Headers**, add `Authorization` with the value `Bearer {{_deviceHubAccessToken}}` (or `Bearer {{_challengeHubAccessToken}}`).
46
+ 3. Connect, then send the handshake below. The trailing record separator character `chr(30)` is required after every message.
47
+
48
+ ```
49
+ {
50
+ "protocol": "json",
51
+ "version": 1
52
+ }
53
+ ```
54
+
55
+ ### DeviceHub
56
+
57
+ Invoke `SubscribeToLocation` with your location ID to get your device information:
58
+
59
+ ```
60
+ {
61
+ "arguments": [
62
+ 69420
63
+ ],
64
+ "invocationId": "0",
65
+ "target": "SubscribeToLocation",
66
+ "type": 1
67
+ }
68
+ ```
69
+
70
+ You should receive a [`DeviceListInitialValuesReceived`](signalr/devicehub/DeviceListInitialValuesReceived.json) message back.
71
+
72
+ ### ChallengeHub
73
+
74
+ Since the 2025-2026 season and the addition of Flex D, messages are split between Winter Credits (Crédit Hivernal) and Flex D. Winter Credits targets contain `CH` and Flex D targets contain `Flex`. Each rate has its own event ID; for example, event 337 was for Winter Credits and event 338 for Flex D, both at the same time. You only have access to the one matching your rate.
75
+
76
+ #### SubscribeToEventCH or SubscribeToEventFlex
77
+
78
+ ```
79
+ {
80
+ "arguments": [
81
+ {
82
+ "locationHiloId": "urn:YOUR-URN",
83
+ "eventId": ID(numeric)
84
+ }
85
+ ],
86
+ "invocationId": "1",
87
+ "target": "SubscribeToEventCH",
88
+ "type": 1
89
+ }
90
+ ```
91
+
92
+ Returns [`EventCHDetailsInitialValuesReceived`](signalr/challengehub/EventCHDetailsInitialValuesReceived.json) or [`EventFlexDetailsInitialValuesReceived`](signalr/challengehub/EventFlexDetailsInitialValuesReceived.json).
@@ -0,0 +1,33 @@
1
+ {
2
+ "data": {
3
+ "onAnyDeviceUpdated": {
4
+ "deviceType": "cee",
5
+ "locationHiloId": "urn:hilo:crm:00000-abcdef:0",
6
+ "transmissionTime": "2026-10-24T19:46:35.207Z",
7
+ "operationId": "00000000-0000-0000-0000-000000000002",
8
+ "status": "REPORT",
9
+ "device": {
10
+ "deviceType": "cee",
11
+ "hiloId": "urn:hilo:philo:4a5b6c7d8e9f0001:0",
12
+ "physicalAddress": "4A5B6C7D8E9F0001",
13
+ "connectionStatus": "CONNECTED",
14
+ "gDState": "EXCLUDED",
15
+ "version": "45",
16
+ "probeTemp": {
17
+ "value": 54,
18
+ "kind": "DEGREE_CELSIUS"
19
+ },
20
+ "zigbeeVersion": "45",
21
+ "state": "ON",
22
+ "ccrType": "WATER_HEATER",
23
+ "alerts": [
24
+ "0"
25
+ ],
26
+ "power": {
27
+ "value": 0,
28
+ "kind": "WATT"
29
+ }
30
+ }
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,22 @@
1
+ {
2
+ "data": {
3
+ "onAnyDeviceUpdated": {
4
+ "deviceType": "meter",
5
+ "locationHiloId": "urn:hilo:crm:00000-abcdef:0",
6
+ "transmissionTime": "2026-10-24T19:48:54.966Z",
7
+ "operationId": "00000000-0000-0000-0000-000000000001",
8
+ "status": "REPORT",
9
+ "device": {
10
+ "deviceType": "meter",
11
+ "hiloId": "urn:hilo:philo:0x006baa0000000001-smart_meter:0",
12
+ "physicalAddress": "0x006BAA0000000001-smart_meter",
13
+ "connectionStatus": "CONNECTED",
14
+ "zigBeeChannel": 24,
15
+ "power": {
16
+ "value": 0.572,
17
+ "kind": "KILOWATT"
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "temperature": 10.0,
3
+ "time": "0001-01-01T00:00:00Z",
4
+ "condition": "Clear",
5
+ "icon": 0,
6
+ "humidity": 72.0
7
+ }
@@ -0,0 +1,42 @@
1
+ {
2
+ "season": 2025,
3
+ "events": [
4
+ {
5
+ "id": 378,
6
+ "startDateUtc": "2026-03-18T08:00:00+00:00",
7
+ "period": "AM",
8
+ "reward": 11.68,
9
+ "energySavingsWh": 20570,
10
+ "status": "Success",
11
+ "isPreseasonEvent": false,
12
+ "isCredited": true,
13
+ "isFlatRate": false,
14
+ "isInsufficientReduction": false
15
+ },
16
+ {
17
+ "id": 377,
18
+ "startDateUtc": "2026-03-03T09:00:00+00:00",
19
+ "period": "AM",
20
+ "reward": 17.95,
21
+ "energySavingsWh": 31620,
22
+ "status": "Success",
23
+ "isPreseasonEvent": false,
24
+ "isCredited": true,
25
+ "isFlatRate": false,
26
+ "isInsufficientReduction": false
27
+ },
28
+ {
29
+ "id": 373,
30
+ "startDateUtc": "2026-02-08T09:00:00+00:00",
31
+ "period": "AM",
32
+ "reward": 20.56,
33
+ "energySavingsWh": 36220,
34
+ "status": "Success",
35
+ "isPreseasonEvent": false,
36
+ "isCredited": true,
37
+ "isFlatRate": false,
38
+ "isInsufficientReduction": false
39
+ }
40
+ ],
41
+ "periodId": "Q0h8dXJuOmhpbG86Y3JtOjAwMDAwLWFiY2RlZjowfDIwMjUtMTEtMDFUMTM6MDE6MTcuODA4Nzc4NVo="
42
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "season": 2026,
3
+ "events": [],
4
+ "periodId": "Q0h8dXJuOmhpbG86Y3JtOjAwMDAwLWFiY2RlZjowfDIwMjUtMTEtMDFUMTM6MDE6MTcuODA4Nzc4NVo="
5
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "season": 2024,
3
+ "events": [
4
+ {
5
+ "id": 303,
6
+ "startDateUtc": "2025-03-03T09:00:00+00:00",
7
+ "period": "AM",
8
+ "reportId": 1000000,
9
+ "reward": 20.39,
10
+ "energySavingsWh": 37081.6845703125,
11
+ "status": "Success",
12
+ "isPreseasonEvent": false
13
+ },
14
+ {
15
+ "id": 302,
16
+ "startDateUtc": "2025-02-19T09:00:00+00:00",
17
+ "period": "AM",
18
+ "reportId": 1000001,
19
+ "reward": 21.85,
20
+ "energySavingsWh": 39730.3349609375,
21
+ "status": "Success",
22
+ "isPreseasonEvent": false
23
+ },
24
+ {
25
+ "id": 301,
26
+ "startDateUtc": "2025-02-18T09:00:00+00:00",
27
+ "period": "AM",
28
+ "reportId": 1000002,
29
+ "reward": 23.91,
30
+ "energySavingsWh": 43477.09521484375,
31
+ "status": "Success",
32
+ "isPreseasonEvent": false
33
+ }
34
+ ],
35
+ "periodId": "SGlsb3x1cm46aGlsbzpjcm06MDAwMDAtYWJjZGVmOjB8MjAyMi0wNi0yMlQxOTo1MDozOC4wMDAwMDAwWg=="
36
+ }
@@ -0,0 +1,27 @@
1
+ [
2
+ {
3
+ "season": 2026,
4
+ "ratePlan": "CH",
5
+ "periodId": "Q0h8dXJuOmhpbG86Y3JtOjAwMDAwLWFiY2RlZjowfDIwMjUtMTEtMDFUMTM6MDE6MTcuODA4Nzc4NVo="
6
+ },
7
+ {
8
+ "season": 2025,
9
+ "ratePlan": "CH",
10
+ "periodId": "Q0h8dXJuOmhpbG86Y3JtOjAwMDAwLWFiY2RlZjowfDIwMjUtMTEtMDFUMTM6MDE6MTcuODA4Nzc4NVo="
11
+ },
12
+ {
13
+ "season": 2024,
14
+ "ratePlan": "Hilo",
15
+ "periodId": "SGlsb3x1cm46aGlsbzpjcm06MDAwMDAtYWJjZGVmOjB8MjAyMi0wNi0yMlQxOTo1MDozOC4wMDAwMDAwWg=="
16
+ },
17
+ {
18
+ "season": 2023,
19
+ "ratePlan": "Hilo",
20
+ "periodId": "SGlsb3x1cm46aGlsbzpjcm06MDAwMDAtYWJjZGVmOjB8MjAyMi0wNi0yMlQxOTo1MDozOC4wMDAwMDAwWg=="
21
+ },
22
+ {
23
+ "season": 2022,
24
+ "ratePlan": "Hilo",
25
+ "periodId": "SGlsb3x1cm46aGlsbzpjcm06MDAwMDAtYWJjZGVmOjB8MjAyMi0wNi0yMlQxOTo1MDozOC4wMDAwMDAwWg=="
26
+ }
27
+ ]
@@ -1,6 +1,7 @@
1
1
  """Define the hilo package."""
2
2
 
3
3
  from pyhilo.api import API
4
+ from pyhilo.auth import AbstractAuth
4
5
  from pyhilo.const import UNMONITORED_DEVICES
5
6
  from pyhilo.device import HiloDevice
6
7
  from pyhilo.device.switch import Switch
@@ -12,6 +13,7 @@ from pyhilo.util import from_utc_timestamp, time_diff
12
13
 
13
14
  __all__ = [
14
15
  "API",
16
+ "AbstractAuth",
15
17
  "Devices",
16
18
  "HiloDevice",
17
19
  "Event",
@@ -13,8 +13,8 @@ from urllib import parse
13
13
  from aiohttp import ClientSession
14
14
  from aiohttp.client_exceptions import ClientResponseError
15
15
  import backoff
16
- from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
17
16
 
17
+ from pyhilo.auth import AbstractAuth, _LegacyOAuthSessionAuth
18
18
  from pyhilo.const import (
19
19
  ANDROID_CLIENT_ENDPOINT,
20
20
  ANDROID_CLIENT_HEADERS,
@@ -62,7 +62,8 @@ class API:
62
62
  self,
63
63
  *,
64
64
  session: ClientSession,
65
- oauth_session: OAuth2Session,
65
+ auth: AbstractAuth | None = None,
66
+ oauth_session: Any = None,
66
67
  request_retries: int = REQUEST_RETRY,
67
68
  log_traces: bool = False,
68
69
  state_yaml: str = DEFAULT_STATE_FILE,
@@ -75,7 +76,8 @@ class API:
75
76
  self.async_request = self._wrap_request_method(self._request_retries)
76
77
  self.device_attributes = get_device_attributes()
77
78
  self.session: ClientSession = session
78
- self._oauth_session = oauth_session
79
+ self._auth = self._resolve_auth(auth, oauth_session)
80
+ self._access_token: str | None = None
79
81
  self.signalr_devices: SignalRHub
80
82
  self.signalr_challenges: SignalRHub
81
83
  self.log_traces = log_traces
@@ -85,12 +87,24 @@ class API:
85
87
  self._device_cache: list[dict[str, Any]] = []
86
88
  self._device_cache_event: asyncio.Event = asyncio.Event()
87
89
 
90
+ @staticmethod
91
+ def _resolve_auth(auth: AbstractAuth | None, oauth_session: Any) -> AbstractAuth:
92
+ """Pick the auth provider, adapting the deprecated ``oauth_session``."""
93
+ if auth is not None and oauth_session is not None:
94
+ raise ValueError("Pass either auth or oauth_session, not both")
95
+ if auth is not None:
96
+ return auth
97
+ if oauth_session is not None:
98
+ return _LegacyOAuthSessionAuth(oauth_session)
99
+ raise ValueError("An auth provider is required")
100
+
88
101
  @classmethod
89
102
  async def async_create(
90
103
  cls,
91
104
  *,
92
105
  session: ClientSession,
93
- oauth_session: OAuth2Session,
106
+ auth: AbstractAuth | None = None,
107
+ oauth_session: Any = None,
94
108
  request_retries: int = REQUEST_RETRY,
95
109
  log_traces: bool = False,
96
110
  state_yaml: str = DEFAULT_STATE_FILE,
@@ -98,8 +112,10 @@ class API:
98
112
  """Get an authenticated API object.
99
113
  :param session: The ``aiohttp`` ``ClientSession`` session used for all HTTP requests
100
114
  :type session: ``aiohttp.client.ClientSession``
101
- :param oauth_session: The session to make requests authenticated with OAuth2.
102
- :type oauth_session: ``config_entry_oauth2_flow.OAuth2Session``
115
+ :param auth: Provides valid access tokens for API requests.
116
+ :type auth: :class:`pyhilo.auth.AbstractAuth`
117
+ :param oauth_session: Deprecated, use ``auth`` instead. A Home Assistant
118
+ ``OAuth2Session``, adapted internally.
103
119
  :param request_retries: The default number of request retries to use
104
120
  :type request_retries: ``int``
105
121
  :param state_yaml: Path of the file where the API state (tokens, etc.) is saved.
@@ -107,6 +123,7 @@ class API:
107
123
  """
108
124
  api = cls(
109
125
  session=session,
126
+ auth=auth,
110
127
  oauth_session=oauth_session,
111
128
  request_retries=request_retries,
112
129
  log_traces=log_traces,
@@ -132,16 +149,12 @@ class API:
132
149
 
133
150
  async def async_get_access_token(self) -> str:
134
151
  """Return a valid access token."""
135
- if not self._oauth_session.valid_token:
136
- await self._oauth_session.async_ensure_token_valid()
137
-
138
- access_token = str(self._oauth_session.token["access_token"])
139
- LOG.debug("SignalR access token is %s", access_token)
152
+ self._access_token = await self._auth.async_get_access_token()
140
153
 
141
154
  urn = self.urn
142
155
  LOG.debug("Extracted URN: %s", urn)
143
156
 
144
- return str(self._oauth_session.token["access_token"])
157
+ return self._access_token
145
158
 
146
159
  @property
147
160
  def urn(self) -> str | None:
@@ -150,10 +163,9 @@ class API:
150
163
  return self._urn
151
164
 
152
165
  try:
153
- if not self._oauth_session.valid_token:
166
+ if not self._access_token:
154
167
  return None
155
- token = self._oauth_session.token["access_token"]
156
- payload_part = token.split(".")[1]
168
+ payload_part = self._access_token.split(".")[1]
157
169
  # Add padding if necessary
158
170
  padding = 4 - len(payload_part) % 4
159
171
  if padding != 4:
@@ -0,0 +1,42 @@
1
+ """Authentication interface for the Hilo API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from pyhilo.const import LOG
9
+
10
+
11
+ class AbstractAuth(ABC):
12
+ """Supplies valid Hilo access tokens to :class:`pyhilo.api.API`.
13
+
14
+ Applications subclass this and implement :meth:`async_get_access_token`.
15
+ In Home Assistant, the integration wraps its ``OAuth2Session``; standalone
16
+ scripts can return a static token or run their own refresh logic.
17
+ """
18
+
19
+ @abstractmethod
20
+ async def async_get_access_token(self) -> str:
21
+ """Return a valid access token, refreshing it if needed."""
22
+
23
+
24
+ class _LegacyOAuthSessionAuth(AbstractAuth):
25
+ """Adapts a Home Assistant ``OAuth2Session`` passed as ``oauth_session``.
26
+
27
+ Deprecated: kept so integration releases that still pass ``oauth_session``
28
+ keep working. The session is duck-typed so pyhilo never imports
29
+ Home Assistant.
30
+ """
31
+
32
+ def __init__(self, oauth_session: Any) -> None:
33
+ LOG.warning(
34
+ "Passing oauth_session to pyhilo.API is deprecated and will be "
35
+ "removed in a future release; pass an AbstractAuth via auth instead"
36
+ )
37
+ self._oauth_session = oauth_session
38
+
39
+ async def async_get_access_token(self) -> str:
40
+ if not self._oauth_session.valid_token:
41
+ await self._oauth_session.async_ensure_token_valid()
42
+ return str(self._oauth_session.token["access_token"])
@@ -11,7 +11,7 @@ INSTANCE_ID: Final = str(uuid.uuid4())[24:]
11
11
  LOG: Final = logging.getLogger(__package__)
12
12
  DEFAULT_STATE_FILE: Final = "hilo_state.yaml"
13
13
  REQUEST_RETRY: Final = 9
14
- PYHILO_VERSION: Final = "2026.10.01"
14
+ PYHILO_VERSION: Final = "2026.10.02"
15
15
  # TODO: Find a way to keep previous line in sync with pyproject.toml automatically
16
16
 
17
17
  CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded"
@@ -76,7 +76,7 @@ class GraphqlValueMapper:
76
76
 
77
77
  def _build_smart_meter(self, device: Dict[str, Any]) -> list[Dict[str, Any]]:
78
78
  attributes = []
79
- if device.get("zigbeeChannel") is not None:
79
+ if device.get("zigBeeChannel") is not None:
80
80
  attributes.append(
81
81
  self.build_attribute(
82
82
  device["hiloId"], "ZigbeeChannel", device["zigBeeChannel"]