um-smart-hub 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matt Trentini
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,273 @@
1
+ Metadata-Version: 2.4
2
+ Name: um-smart-hub
3
+ Version: 0.1.0
4
+ Summary: Python library and CLI for the UM Smart USB Hub
5
+ Author: Matt Trentini
6
+ Author-email: Matt Trentini <matthew.trentini@planetinnovation.com.au>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: httpx2>=2.16.0
10
+ Requires-Dist: rich>=15.0.0
11
+ Requires-Dist: typer>=0.29.0
12
+ Requires-Python: >=3.14
13
+ Description-Content-Type: text/markdown
14
+
15
+ # um-smart-hub
16
+
17
+ Python library and `hub` CLI for the
18
+ [UM Smart USB Hub](https://github.com/UnexpectedMaker/um_smart_usb_hub_6).
19
+ Requires Python 3.14+; uses httpx2 and Typer.
20
+
21
+ ## CLI
22
+
23
+ From this directory, run `uv run hub ...`. To install the CLI from PyPI:
24
+
25
+ ```sh
26
+ uv tool install um-smart-hub
27
+ ```
28
+
29
+ To install from a local checkout instead, use `uv tool install .`.
30
+
31
+ ```sh
32
+ hub on 1 # turn port 1 on
33
+ hub off 1 # turn port 1 off
34
+ hub toggle 1 # read the current power state and flip it once
35
+ hub toggle 1 5 # flip immediately, then every 5 seconds until Ctrl+C
36
+ hub --host http://192.168.2.124 on 1
37
+ hub info # hub identity, supplies, sensors and hardware health
38
+ hub status # all six ports
39
+ hub status 6 # one port's details and next scheduled action
40
+ hub identify 6 # flash the port's LED without changing power
41
+ hub watch # refresh all port statuses every second
42
+ hub watch 6 --interval 0.5 # monitor one port until Ctrl+C
43
+ hub info --json
44
+ hub status 6 --json
45
+ hub watch 6 --json # one JSON snapshot per line
46
+ ```
47
+
48
+ The default address is `http://usb-hub.local`. Override it with `--host` (before
49
+ any command) or `HUB_HOST`. If mDNS doesn't resolve from WSL, use the IP address.
50
+ Looping mode prints a start message rather than each transition. Ctrl+C stops
51
+ without switching the port off or restoring its original state.
52
+
53
+ `info` and `status` emit human-readable tables by default. `watch` refreshes a
54
+ table in an interactive terminal, or prints successive tables when redirected.
55
+ `--json` uses the library's normalized, unit-labelled fields, not raw firmware
56
+ field names: `info` and single-port `status` emit an object; all-port `status`
57
+ emits an array. Each `watch --json` line is one object (selected port) or array
58
+ (all ports). Diagnostics and the Ctrl+C message go to stderr, keeping stdout
59
+ machine-readable.
60
+
61
+ ## Python library
62
+
63
+ Install into another uv project:
64
+
65
+ ```sh
66
+ uv add um-smart-hub
67
+ ```
68
+
69
+ For local development, use `uv add /absolute/path/to/hub` instead.
70
+
71
+ ```python
72
+ from smart_hub import Hub
73
+
74
+ hub = Hub() # defaults to http://usb-hub.local
75
+ # hub = Hub("http://192.168.2.124")
76
+ hub.on(6)
77
+ hub.off(6)
78
+ enabled = hub.toggle(6) # a synchronous, single flip; returns the new bool state
79
+ ```
80
+
81
+ `on`, `off`, and single-flip `toggle` are blocking calls returning the confirmed
82
+ power state. Synchronous operations close their HTTP clients; `Hub` itself
83
+ needs no explicit cleanup or context manager.
84
+
85
+ ### Information, status and identification
86
+
87
+ ```python
88
+ from smart_hub import Hub, HubInfo, PortStatus
89
+
90
+ hub = Hub()
91
+ info: HubInfo = hub.info()
92
+ ports: list[PortStatus] = hub.ports()
93
+ port: PortStatus = hub.status(6)
94
+ print(info.firmware, info.temperature_c, info.main_5v)
95
+ print(port.enabled, port.occupied, port.current_ma, port.next_action)
96
+ hub.identify(6) # flashes the LED; does not change power
97
+ ```
98
+
99
+ `HubInfo`, `PortStatus` and `ScheduledAction` are frozen dataclasses. They are
100
+ snapshots, not live views; fetch again to refresh them. Use
101
+ `dataclasses.asdict(snapshot)` for a JSON-compatible dictionary.
102
+
103
+ - `enabled` means the firmware's switch state, not proof that a 5 V supply or
104
+ current is present. Check `HubInfo.main_5v` separately.
105
+ - Absent port monitors yield `None` for `voltage_v`, `current_ma` and `power_mw`.
106
+ Real zero readings remain `0.0`.
107
+ - Invalid environment readings yield `None` for `temperature_c` and
108
+ `humidity_percent`; an unsynced clock yields `None` for `time_iso`.
109
+ - `power_mode` is available from `status(port)`, but is `None` in `ports()` and
110
+ all-port watches because the bulk endpoint doesn't supply it.
111
+ - `next_action` is a `ScheduledAction` or `None`. It exposes `delay_seconds`,
112
+ `enabled` (the action's target state), `local_time` and `entry_index`.
113
+ `schedule_count` counts active entries; full schedule editing isn't included.
114
+
115
+ ### Asynchronous monitoring
116
+
117
+ `watch(port=None, interval=1.0)` is an asynchronous iterator. It fetches
118
+ immediately, waits between polls, and yields a fresh `PortStatus` for one port or
119
+ a `list[PortStatus]` for all ports. It never changes power.
120
+
121
+ ```python
122
+ import asyncio
123
+ from contextlib import aclosing
124
+ from smart_hub import Hub
125
+
126
+
127
+ async def monitor():
128
+ hub = Hub()
129
+ async with aclosing(hub.watch(6, interval=0.5)) as snapshots:
130
+ async for port in snapshots:
131
+ print(port.voltage_v, port.current_ma, port.fault)
132
+ # Break when your application is finished monitoring.
133
+
134
+
135
+ asyncio.run(monitor())
136
+ ```
137
+
138
+ The iterator holds one async HTTP client. Cancellation closes it and propagates
139
+ normally. Use `aclosing` as above when you may break out early, so the client is
140
+ closed immediately. Polling errors propagate; there are no automatic retries.
141
+ Watch intervals must be finite and strictly positive.
142
+
143
+ ### Asynchronous toggling
144
+
145
+ Supplying an interval to `toggle` returns a coroutine instead. It uses an asynchronous HTTP
146
+ client and `asyncio.sleep`, so it doesn't block other asyncio tasks:
147
+
148
+ ```python
149
+ import asyncio
150
+ from smart_hub import Hub
151
+
152
+
153
+ async def main():
154
+ hub = Hub()
155
+ await hub.toggle(6, 2) # flip immediately, then every 2 seconds indefinitely
156
+
157
+
158
+ asyncio.run(main())
159
+ ```
160
+
161
+ Inside an existing async application, cancel the task when finished:
162
+
163
+ ```python
164
+ from contextlib import suppress
165
+
166
+ task = asyncio.create_task(hub.toggle(6, 2))
167
+ # ... other application work ...
168
+ task.cancel()
169
+ with suppress(asyncio.CancelledError):
170
+ await task
171
+ ```
172
+
173
+ Cancellation propagates normally and doesn't send a final power command. If a
174
+ request is interrupted in flight, it may already have reached the hub.
175
+
176
+ Ports are numbered 1–6. Toggle intervals must be finite and non-negative; zero
177
+ means no intentional delay. Invalid arguments raise `ValueError`; HTTP failures
178
+ propagate as `httpx2.HTTPError`. Power writes check the hub's reported state,
179
+ raising `RuntimeError` if it doesn't match; `identify` checks the acknowledgement.
180
+ CLI errors exit with code 1. Existing hub schedules remain active and can
181
+ independently change port power.
182
+
183
+ ### API choice
184
+
185
+ `hub.on(6)` keeps a small, direct API. `hub[6].on()` would be useful if a retained
186
+ port object exposed several operations or measurements, but isn't implemented:
187
+ maintaining two equivalent interfaces isn't worthwhile for the current scope.
188
+
189
+ ## Development
190
+
191
+ ```sh
192
+ uv sync --locked
193
+ uv run --locked ruff check .
194
+ uv run --locked ruff format --check .
195
+ uv run --locked ty check
196
+ uv run --locked python -m unittest discover -s tests -v
197
+ uv build
198
+ ```
199
+
200
+ Ruff and ty are uv development dependencies. Ruff checks lint, import ordering,
201
+ Python modernization, and formatting; ty checks the library, CLI, and tests
202
+ using Python 3.14. To apply Ruff's safe fixes and formatting locally:
203
+
204
+ ```sh
205
+ uv run ruff check --fix .
206
+ uv run ruff format .
207
+ ```
208
+
209
+ `.github/workflows/ci.yml` runs lint, formatting, types, and regression checks on
210
+ pull requests and pushes to `main`. The release workflow calls the same CI
211
+ workflow before building distributions, so publishing cannot bypass those
212
+ checks. No pre-commit hook is required.
213
+
214
+ Distribution name: `um-smart-hub`; import package: `smart_hub`; CLI command: `hub`.
215
+ The CLI delegates all hub controls to the library.
216
+
217
+ ## Releases and PyPI publishing
218
+
219
+ `.github/workflows/release.yml` runs when a `v*` tag is **pushed**. It first calls
220
+ the shared CI workflow to run Ruff, ty, and the regression suite on Python 3.14
221
+ with locked dependencies. It then checks that the tag exactly matches `v` plus
222
+ `[project].version`, builds a wheel and source distribution, and checks their
223
+ metadata with Twine. A separate job publishes those artifacts to PyPI using
224
+ Trusted Publishing. Only after publishing succeeds does it create a GitHub
225
+ release with generated notes and attach the same distributions.
226
+
227
+ Publishing uses short-lived GitHub OIDC credentials, not a stored PyPI API token.
228
+ Only the publishing job has `id-token: write`; only the GitHub release job has
229
+ `contents: write`. Actions are pinned to commit hashes.
230
+
231
+ ### One-time setup before the first release
232
+
233
+ 1. The PyPI distribution name is **`um-smart-hub`** (`[project].name` in
234
+ `pyproject.toml`). The explicit build-backend `module-name = "smart_hub"`
235
+ keeps the import package independent of the distribution name. The CLI
236
+ remains `hub`.
237
+ 2. In this repository's GitHub settings, create an environment named **`pypi`**.
238
+ Restrict deployments to release tags (`v*`); requiring reviewer approval is
239
+ recommended.
240
+ 3. In your [PyPI publishing settings](https://pypi.org/manage/account/publishing/),
241
+ add a **pending Trusted Publisher** for `um-smart-hub`:
242
+
243
+ | Setting | Value |
244
+ |---|---|
245
+ | PyPI project | `um-smart-hub` |
246
+ | Owner | `mattytrentini` |
247
+ | Repository | `um-smart-hub` |
248
+ | Workflow filename | `release.yml` |
249
+ | Environment | `pypi` |
250
+
251
+ For an existing PyPI project you own, add the same publisher in that project's
252
+ publishing settings instead. A pending publisher creates a new project on the
253
+ first successful upload; it **does not reserve its name** in advance. See the
254
+ [PyPI setup guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
255
+ 4. Commit and push the workflow, package metadata, and lockfile before pushing
256
+ a release tag.
257
+
258
+ ### Creating a release
259
+
260
+ For an initial `0.1.0` release, after completing the setup above:
261
+
262
+ ```sh
263
+ uv version 0.1.0
264
+ uv lock
265
+ # Commit any name/version/lockfile changes, then push the release commit.
266
+ git push origin main
267
+ git tag -a v0.1.0 -m "Release 0.1.0"
268
+ git push origin v0.1.0
269
+ ```
270
+
271
+ Use a new package version and matching tag for each subsequent release. Creating
272
+ a local tag alone does not publish anything. Never reuse a version already
273
+ uploaded to PyPI. The workflow deliberately has no manual-dispatch trigger.
@@ -0,0 +1,259 @@
1
+ # um-smart-hub
2
+
3
+ Python library and `hub` CLI for the
4
+ [UM Smart USB Hub](https://github.com/UnexpectedMaker/um_smart_usb_hub_6).
5
+ Requires Python 3.14+; uses httpx2 and Typer.
6
+
7
+ ## CLI
8
+
9
+ From this directory, run `uv run hub ...`. To install the CLI from PyPI:
10
+
11
+ ```sh
12
+ uv tool install um-smart-hub
13
+ ```
14
+
15
+ To install from a local checkout instead, use `uv tool install .`.
16
+
17
+ ```sh
18
+ hub on 1 # turn port 1 on
19
+ hub off 1 # turn port 1 off
20
+ hub toggle 1 # read the current power state and flip it once
21
+ hub toggle 1 5 # flip immediately, then every 5 seconds until Ctrl+C
22
+ hub --host http://192.168.2.124 on 1
23
+ hub info # hub identity, supplies, sensors and hardware health
24
+ hub status # all six ports
25
+ hub status 6 # one port's details and next scheduled action
26
+ hub identify 6 # flash the port's LED without changing power
27
+ hub watch # refresh all port statuses every second
28
+ hub watch 6 --interval 0.5 # monitor one port until Ctrl+C
29
+ hub info --json
30
+ hub status 6 --json
31
+ hub watch 6 --json # one JSON snapshot per line
32
+ ```
33
+
34
+ The default address is `http://usb-hub.local`. Override it with `--host` (before
35
+ any command) or `HUB_HOST`. If mDNS doesn't resolve from WSL, use the IP address.
36
+ Looping mode prints a start message rather than each transition. Ctrl+C stops
37
+ without switching the port off or restoring its original state.
38
+
39
+ `info` and `status` emit human-readable tables by default. `watch` refreshes a
40
+ table in an interactive terminal, or prints successive tables when redirected.
41
+ `--json` uses the library's normalized, unit-labelled fields, not raw firmware
42
+ field names: `info` and single-port `status` emit an object; all-port `status`
43
+ emits an array. Each `watch --json` line is one object (selected port) or array
44
+ (all ports). Diagnostics and the Ctrl+C message go to stderr, keeping stdout
45
+ machine-readable.
46
+
47
+ ## Python library
48
+
49
+ Install into another uv project:
50
+
51
+ ```sh
52
+ uv add um-smart-hub
53
+ ```
54
+
55
+ For local development, use `uv add /absolute/path/to/hub` instead.
56
+
57
+ ```python
58
+ from smart_hub import Hub
59
+
60
+ hub = Hub() # defaults to http://usb-hub.local
61
+ # hub = Hub("http://192.168.2.124")
62
+ hub.on(6)
63
+ hub.off(6)
64
+ enabled = hub.toggle(6) # a synchronous, single flip; returns the new bool state
65
+ ```
66
+
67
+ `on`, `off`, and single-flip `toggle` are blocking calls returning the confirmed
68
+ power state. Synchronous operations close their HTTP clients; `Hub` itself
69
+ needs no explicit cleanup or context manager.
70
+
71
+ ### Information, status and identification
72
+
73
+ ```python
74
+ from smart_hub import Hub, HubInfo, PortStatus
75
+
76
+ hub = Hub()
77
+ info: HubInfo = hub.info()
78
+ ports: list[PortStatus] = hub.ports()
79
+ port: PortStatus = hub.status(6)
80
+ print(info.firmware, info.temperature_c, info.main_5v)
81
+ print(port.enabled, port.occupied, port.current_ma, port.next_action)
82
+ hub.identify(6) # flashes the LED; does not change power
83
+ ```
84
+
85
+ `HubInfo`, `PortStatus` and `ScheduledAction` are frozen dataclasses. They are
86
+ snapshots, not live views; fetch again to refresh them. Use
87
+ `dataclasses.asdict(snapshot)` for a JSON-compatible dictionary.
88
+
89
+ - `enabled` means the firmware's switch state, not proof that a 5 V supply or
90
+ current is present. Check `HubInfo.main_5v` separately.
91
+ - Absent port monitors yield `None` for `voltage_v`, `current_ma` and `power_mw`.
92
+ Real zero readings remain `0.0`.
93
+ - Invalid environment readings yield `None` for `temperature_c` and
94
+ `humidity_percent`; an unsynced clock yields `None` for `time_iso`.
95
+ - `power_mode` is available from `status(port)`, but is `None` in `ports()` and
96
+ all-port watches because the bulk endpoint doesn't supply it.
97
+ - `next_action` is a `ScheduledAction` or `None`. It exposes `delay_seconds`,
98
+ `enabled` (the action's target state), `local_time` and `entry_index`.
99
+ `schedule_count` counts active entries; full schedule editing isn't included.
100
+
101
+ ### Asynchronous monitoring
102
+
103
+ `watch(port=None, interval=1.0)` is an asynchronous iterator. It fetches
104
+ immediately, waits between polls, and yields a fresh `PortStatus` for one port or
105
+ a `list[PortStatus]` for all ports. It never changes power.
106
+
107
+ ```python
108
+ import asyncio
109
+ from contextlib import aclosing
110
+ from smart_hub import Hub
111
+
112
+
113
+ async def monitor():
114
+ hub = Hub()
115
+ async with aclosing(hub.watch(6, interval=0.5)) as snapshots:
116
+ async for port in snapshots:
117
+ print(port.voltage_v, port.current_ma, port.fault)
118
+ # Break when your application is finished monitoring.
119
+
120
+
121
+ asyncio.run(monitor())
122
+ ```
123
+
124
+ The iterator holds one async HTTP client. Cancellation closes it and propagates
125
+ normally. Use `aclosing` as above when you may break out early, so the client is
126
+ closed immediately. Polling errors propagate; there are no automatic retries.
127
+ Watch intervals must be finite and strictly positive.
128
+
129
+ ### Asynchronous toggling
130
+
131
+ Supplying an interval to `toggle` returns a coroutine instead. It uses an asynchronous HTTP
132
+ client and `asyncio.sleep`, so it doesn't block other asyncio tasks:
133
+
134
+ ```python
135
+ import asyncio
136
+ from smart_hub import Hub
137
+
138
+
139
+ async def main():
140
+ hub = Hub()
141
+ await hub.toggle(6, 2) # flip immediately, then every 2 seconds indefinitely
142
+
143
+
144
+ asyncio.run(main())
145
+ ```
146
+
147
+ Inside an existing async application, cancel the task when finished:
148
+
149
+ ```python
150
+ from contextlib import suppress
151
+
152
+ task = asyncio.create_task(hub.toggle(6, 2))
153
+ # ... other application work ...
154
+ task.cancel()
155
+ with suppress(asyncio.CancelledError):
156
+ await task
157
+ ```
158
+
159
+ Cancellation propagates normally and doesn't send a final power command. If a
160
+ request is interrupted in flight, it may already have reached the hub.
161
+
162
+ Ports are numbered 1–6. Toggle intervals must be finite and non-negative; zero
163
+ means no intentional delay. Invalid arguments raise `ValueError`; HTTP failures
164
+ propagate as `httpx2.HTTPError`. Power writes check the hub's reported state,
165
+ raising `RuntimeError` if it doesn't match; `identify` checks the acknowledgement.
166
+ CLI errors exit with code 1. Existing hub schedules remain active and can
167
+ independently change port power.
168
+
169
+ ### API choice
170
+
171
+ `hub.on(6)` keeps a small, direct API. `hub[6].on()` would be useful if a retained
172
+ port object exposed several operations or measurements, but isn't implemented:
173
+ maintaining two equivalent interfaces isn't worthwhile for the current scope.
174
+
175
+ ## Development
176
+
177
+ ```sh
178
+ uv sync --locked
179
+ uv run --locked ruff check .
180
+ uv run --locked ruff format --check .
181
+ uv run --locked ty check
182
+ uv run --locked python -m unittest discover -s tests -v
183
+ uv build
184
+ ```
185
+
186
+ Ruff and ty are uv development dependencies. Ruff checks lint, import ordering,
187
+ Python modernization, and formatting; ty checks the library, CLI, and tests
188
+ using Python 3.14. To apply Ruff's safe fixes and formatting locally:
189
+
190
+ ```sh
191
+ uv run ruff check --fix .
192
+ uv run ruff format .
193
+ ```
194
+
195
+ `.github/workflows/ci.yml` runs lint, formatting, types, and regression checks on
196
+ pull requests and pushes to `main`. The release workflow calls the same CI
197
+ workflow before building distributions, so publishing cannot bypass those
198
+ checks. No pre-commit hook is required.
199
+
200
+ Distribution name: `um-smart-hub`; import package: `smart_hub`; CLI command: `hub`.
201
+ The CLI delegates all hub controls to the library.
202
+
203
+ ## Releases and PyPI publishing
204
+
205
+ `.github/workflows/release.yml` runs when a `v*` tag is **pushed**. It first calls
206
+ the shared CI workflow to run Ruff, ty, and the regression suite on Python 3.14
207
+ with locked dependencies. It then checks that the tag exactly matches `v` plus
208
+ `[project].version`, builds a wheel and source distribution, and checks their
209
+ metadata with Twine. A separate job publishes those artifacts to PyPI using
210
+ Trusted Publishing. Only after publishing succeeds does it create a GitHub
211
+ release with generated notes and attach the same distributions.
212
+
213
+ Publishing uses short-lived GitHub OIDC credentials, not a stored PyPI API token.
214
+ Only the publishing job has `id-token: write`; only the GitHub release job has
215
+ `contents: write`. Actions are pinned to commit hashes.
216
+
217
+ ### One-time setup before the first release
218
+
219
+ 1. The PyPI distribution name is **`um-smart-hub`** (`[project].name` in
220
+ `pyproject.toml`). The explicit build-backend `module-name = "smart_hub"`
221
+ keeps the import package independent of the distribution name. The CLI
222
+ remains `hub`.
223
+ 2. In this repository's GitHub settings, create an environment named **`pypi`**.
224
+ Restrict deployments to release tags (`v*`); requiring reviewer approval is
225
+ recommended.
226
+ 3. In your [PyPI publishing settings](https://pypi.org/manage/account/publishing/),
227
+ add a **pending Trusted Publisher** for `um-smart-hub`:
228
+
229
+ | Setting | Value |
230
+ |---|---|
231
+ | PyPI project | `um-smart-hub` |
232
+ | Owner | `mattytrentini` |
233
+ | Repository | `um-smart-hub` |
234
+ | Workflow filename | `release.yml` |
235
+ | Environment | `pypi` |
236
+
237
+ For an existing PyPI project you own, add the same publisher in that project's
238
+ publishing settings instead. A pending publisher creates a new project on the
239
+ first successful upload; it **does not reserve its name** in advance. See the
240
+ [PyPI setup guide](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/).
241
+ 4. Commit and push the workflow, package metadata, and lockfile before pushing
242
+ a release tag.
243
+
244
+ ### Creating a release
245
+
246
+ For an initial `0.1.0` release, after completing the setup above:
247
+
248
+ ```sh
249
+ uv version 0.1.0
250
+ uv lock
251
+ # Commit any name/version/lockfile changes, then push the release commit.
252
+ git push origin main
253
+ git tag -a v0.1.0 -m "Release 0.1.0"
254
+ git push origin v0.1.0
255
+ ```
256
+
257
+ Use a new package version and matching tag for each subsequent release. Creating
258
+ a local tag alone does not publish anything. Never reuse a version already
259
+ uploaded to PyPI. The workflow deliberately has no manual-dispatch trigger.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "um-smart-hub"
3
+ version = "0.1.0"
4
+ description = "Python library and CLI for the UM Smart USB Hub"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.14"
9
+ dependencies = [
10
+ "httpx2>=2.16.0",
11
+ "rich>=15.0.0",
12
+ "typer>=0.29.0",
13
+ ]
14
+
15
+ [[project.authors]]
16
+ name = "Matt Trentini"
17
+ email = "matthew.trentini@planetinnovation.com.au"
18
+
19
+ [project.scripts]
20
+ hub = "smart_hub.cli:app"
21
+
22
+ [build-system]
23
+ requires = ["uv_build>=0.12.8,<0.13.0"]
24
+ build-backend = "uv_build"
25
+
26
+ [tool.uv.build-backend]
27
+ module-name = "smart_hub"
28
+
29
+ [tool.ruff]
30
+ target-version = "py314"
31
+
32
+ [tool.ruff.lint]
33
+ extend-select = [
34
+ "I",
35
+ "UP",
36
+ ]
37
+
38
+ [tool.ty.environment]
39
+ python-version = "3.14"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "ruff>=0.17.4",
44
+ "ty>=0.0.100",
45
+ ]
@@ -0,0 +1,41 @@
1
+ [project]
2
+ name = "um-smart-hub"
3
+ version = "0.1.0"
4
+ description = "Python library and CLI for the UM Smart USB Hub"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Matt Trentini", email = "matthew.trentini@planetinnovation.com.au" }
10
+ ]
11
+ requires-python = ">=3.14"
12
+ dependencies = [
13
+ "httpx2>=2.16.0",
14
+ "rich>=15.0.0",
15
+ "typer>=0.29.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ hub = "smart_hub.cli:app"
20
+
21
+ [build-system]
22
+ requires = ["uv_build>=0.12.8,<0.13.0"]
23
+ build-backend = "uv_build"
24
+
25
+ [tool.uv.build-backend]
26
+ module-name = "smart_hub"
27
+
28
+ [dependency-groups]
29
+ dev = [
30
+ "ruff>=0.17.4",
31
+ "ty>=0.0.100",
32
+ ]
33
+
34
+ [tool.ruff]
35
+ target-version = "py314"
36
+
37
+ [tool.ruff.lint]
38
+ extend-select = ["I", "UP"]
39
+
40
+ [tool.ty.environment]
41
+ python-version = "3.14"
@@ -0,0 +1,172 @@
1
+ """Typed hub snapshots, port identification, and synchronous/async controls."""
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import AsyncGenerator, Coroutine
6
+ from typing import Any, overload
7
+
8
+ import httpx2
9
+
10
+ from .models import HubInfo, PortStatus, ScheduledAction
11
+
12
+ __all__ = ["Hub", "HubInfo", "PortStatus", "ScheduledAction"]
13
+
14
+
15
+ def _validate_port(port: int) -> None:
16
+ if isinstance(port, bool) or not isinstance(port, int) or not 1 <= port <= 6:
17
+ raise ValueError("port must be an integer from 1 to 6")
18
+
19
+
20
+ def _confirmed(response: httpx2.Response, port: int, enabled: bool) -> bool:
21
+ response.raise_for_status()
22
+ # Firmware can silently ignore a write and still return HTTP 200.
23
+ actual = response.json()["ports"][port - 1]["enabled"]
24
+ if actual is not enabled:
25
+ raise RuntimeError(f"port {port} reports enabled={actual}, expected {enabled}")
26
+ return actual
27
+
28
+
29
+ class Hub:
30
+ """Control a hub at base_url (default http://usb-hub.local).
31
+
32
+ HTTP failures raise httpx2.HTTPError. Rejected state changes raise RuntimeError.
33
+ Clients are scoped to each operation; watch holds one until its iterator closes.
34
+ """
35
+
36
+ def __init__(self, base_url: str = "http://usb-hub.local") -> None:
37
+ self.base_url = base_url
38
+
39
+ def info(self) -> HubInfo:
40
+ """Fetch an immutable hub-wide snapshot, not a live view."""
41
+ with httpx2.Client(base_url=self.base_url, timeout=5.0) as client:
42
+ response = client.get("/api/hub")
43
+ response.raise_for_status()
44
+ return HubInfo.from_api(response.json())
45
+
46
+ def ports(self) -> list[PortStatus]:
47
+ """Fetch all port snapshots; bulk data does not include power modes."""
48
+ with httpx2.Client(base_url=self.base_url, timeout=5.0) as client:
49
+ response = client.get("/api/ports")
50
+ response.raise_for_status()
51
+ return [PortStatus.from_api(data) for data in response.json()["ports"]]
52
+
53
+ def status(self, port: int) -> PortStatus:
54
+ """Fetch one immutable port snapshot, including its power mode."""
55
+ _validate_port(port)
56
+ with httpx2.Client(base_url=self.base_url, timeout=5.0) as client:
57
+ response = client.get(f"/api/ports/{port}")
58
+ response.raise_for_status()
59
+ return PortStatus.from_api(response.json())
60
+
61
+ def identify(self, port: int) -> None:
62
+ """Flash a port's identification LED without changing its power state."""
63
+ _validate_port(port)
64
+ with httpx2.Client(base_url=self.base_url, timeout=5.0) as client:
65
+ response = client.post("/api/identify", params={"n": port})
66
+ response.raise_for_status()
67
+ if response.json().get("ok") is not True:
68
+ raise RuntimeError(f"port {port} identification was not acknowledged")
69
+
70
+ @overload
71
+ def watch(self, port: int, interval: float = 1.0) -> AsyncGenerator[PortStatus]: ...
72
+
73
+ @overload
74
+ def watch(
75
+ self, port: None = None, interval: float = 1.0
76
+ ) -> AsyncGenerator[list[PortStatus]]: ...
77
+
78
+ def watch(
79
+ self, port: int | None = None, interval: float = 1.0
80
+ ) -> AsyncGenerator[PortStatus | list[PortStatus]]:
81
+ """Poll fresh snapshots immediately, then sleep interval between polls.
82
+
83
+ One async client lives for the iterator's lifetime. Cancellation and HTTP
84
+ errors propagate and close it without changing power. Close the iterator
85
+ with aclose() (or contextlib.aclosing) when stopping consumption early.
86
+ Each yielded value is a separate snapshot, not an updating live view;
87
+ all-port snapshots omit power modes just like ports().
88
+ """
89
+ if port is not None:
90
+ _validate_port(port)
91
+ if not math.isfinite(interval) or interval <= 0:
92
+ raise ValueError("interval must be finite and strictly positive")
93
+ return self._watch_loop(port, interval)
94
+
95
+ async def _watch_loop(
96
+ self, port: int | None, interval: float
97
+ ) -> AsyncGenerator[PortStatus | list[PortStatus]]:
98
+ """Keep a single read-only client until cancellation or iterator closure."""
99
+ path = "/api/ports" if port is None else f"/api/ports/{port}"
100
+ async with httpx2.AsyncClient(base_url=self.base_url, timeout=5.0) as client:
101
+ while True:
102
+ response = await client.get(path)
103
+ response.raise_for_status()
104
+ data = response.json()
105
+ if port is None:
106
+ yield [PortStatus.from_api(item) for item in data["ports"]]
107
+ else:
108
+ yield PortStatus.from_api(data)
109
+ await asyncio.sleep(interval)
110
+
111
+ def _set(self, port: int, enabled: bool) -> bool:
112
+ _validate_port(port)
113
+ with httpx2.Client(base_url=self.base_url, timeout=5.0) as client:
114
+ return _confirmed(
115
+ client.post("/api/port", params={"n": port, "on": int(enabled)}),
116
+ port,
117
+ enabled,
118
+ )
119
+
120
+ def on(self, port: int) -> bool:
121
+ """Turn port on and return its confirmed power state (True)."""
122
+ return self._set(port, True)
123
+
124
+ def off(self, port: int) -> bool:
125
+ """Turn port off and return its confirmed power state (False)."""
126
+ return self._set(port, False)
127
+
128
+ @overload
129
+ def toggle(self, port: int, seconds: None = None) -> bool: ...
130
+
131
+ @overload
132
+ def toggle(self, port: int, seconds: float) -> Coroutine[Any, Any, None]: ...
133
+
134
+ def toggle(
135
+ self, port: int, seconds: float | None = None
136
+ ) -> bool | Coroutine[Any, Any, None]:
137
+ """Flip once synchronously, or return an async loop when seconds is given.
138
+
139
+ The loop flips immediately, then waits seconds between changes. Cancel
140
+ its task to stop; cancellation never deliberately changes the final state.
141
+ A request interrupted in flight may already have reached the hub.
142
+ """
143
+ _validate_port(port)
144
+ if seconds is not None:
145
+ if not math.isfinite(seconds) or seconds < 0:
146
+ raise ValueError("seconds must be finite and non-negative")
147
+ return self._toggle_loop(port, seconds)
148
+ with httpx2.Client(base_url=self.base_url, timeout=5.0) as client:
149
+ response = client.get(f"/api/ports/{port}")
150
+ response.raise_for_status()
151
+ enabled = not response.json()["enabled"]
152
+ return _confirmed(
153
+ client.post("/api/port", params={"n": port, "on": int(enabled)}),
154
+ port,
155
+ enabled,
156
+ )
157
+
158
+ async def _toggle_loop(self, port: int, seconds: float) -> None:
159
+ async with httpx2.AsyncClient(base_url=self.base_url, timeout=5.0) as client:
160
+ response = await client.get(f"/api/ports/{port}")
161
+ response.raise_for_status()
162
+ enabled = not response.json()["enabled"]
163
+ while True:
164
+ _confirmed(
165
+ await client.post(
166
+ "/api/port", params={"n": port, "on": int(enabled)}
167
+ ),
168
+ port,
169
+ enabled,
170
+ )
171
+ await asyncio.sleep(seconds)
172
+ enabled = not enabled
@@ -0,0 +1,274 @@
1
+ """Typer entry point; all hub interaction is implemented by Hub."""
2
+
3
+ import asyncio
4
+ import json
5
+ from collections.abc import Callable, Coroutine, Generator
6
+ from contextlib import aclosing, contextmanager
7
+ from dataclasses import asdict
8
+ from typing import Annotated, Any
9
+
10
+ import httpx2
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.live import Live
14
+ from rich.table import Table
15
+ from rich.text import Text
16
+
17
+ from smart_hub import Hub, HubInfo, PortStatus
18
+
19
+ app = typer.Typer(add_completion=False, no_args_is_help=True)
20
+ Port = Annotated[int, typer.Argument(min=1, max=6, help="Port number (1-6).")]
21
+ OptionalPort = Annotated[
22
+ int | None, typer.Argument(min=1, max=6, help="Port number; omit for all ports.")
23
+ ]
24
+ JsonOutput = Annotated[
25
+ bool, typer.Option("--json", help="Output JSON instead of a table.")
26
+ ]
27
+ console = Console(markup=False, highlight=False, emoji=False)
28
+
29
+
30
+ @app.callback()
31
+ def main(
32
+ ctx: typer.Context,
33
+ host: Annotated[
34
+ str, typer.Option(envvar="HUB_HOST", help="Hub base URL.")
35
+ ] = "http://usb-hub.local",
36
+ ) -> None:
37
+ """Control the UM Smart USB Hub's ports."""
38
+ ctx.obj = Hub(host)
39
+
40
+
41
+ @contextmanager
42
+ def _errors() -> Generator[None]:
43
+ try:
44
+ yield
45
+ except KeyboardInterrupt:
46
+ typer.echo("stopped", err=True)
47
+ except (httpx2.HTTPError, RuntimeError, ValueError) as error:
48
+ typer.echo(f"error: {error}", err=True)
49
+ raise typer.Exit(1) from error
50
+
51
+
52
+ def _run(port: int, action: Callable[[], bool | Coroutine[Any, Any, None]]) -> None:
53
+ with _errors():
54
+ result = action()
55
+ if isinstance(result, bool):
56
+ typer.echo(f"port {port} {'on' if result else 'off'}")
57
+ else:
58
+ asyncio.run(result)
59
+
60
+
61
+ def _json(snapshot: HubInfo | PortStatus | list[PortStatus]) -> None:
62
+ data = (
63
+ [asdict(port) for port in snapshot]
64
+ if isinstance(snapshot, list)
65
+ else asdict(snapshot)
66
+ )
67
+ typer.echo(json.dumps(data, allow_nan=False))
68
+
69
+
70
+ def _value(value: object, unit: str = "") -> str:
71
+ if value is None:
72
+ return "unavailable"
73
+ if isinstance(value, bool):
74
+ return "yes" if value else "no"
75
+ return f"{value}{unit}"
76
+
77
+
78
+ def _info_table(snapshot: HubInfo) -> Table:
79
+ table = Table(title=Text(snapshot.name), show_header=False)
80
+ table.add_column("Field", style="bold")
81
+ table.add_column("Value")
82
+ rows = [
83
+ ("Firmware", snapshot.firmware),
84
+ ("Uptime", _value(snapshot.uptime_seconds, " s")),
85
+ ("Reset reason", snapshot.reset_reason),
86
+ (
87
+ "Main / upstream 5 V",
88
+ f"{_value(snapshot.main_5v)} / {_value(snapshot.upstream_5v)}",
89
+ ),
90
+ ("Total current", _value(snapshot.total_current_ma, " mA")),
91
+ ("Total power", _value(snapshot.total_power_mw, " mW")),
92
+ ("Temperature", _value(snapshot.temperature_c, " °C")),
93
+ ("Humidity", _value(snapshot.humidity_percent, " %")),
94
+ ("Clock synced", _value(snapshot.clock_synced)),
95
+ ("Time", _value(snapshot.time_iso)),
96
+ ("UTC offset", _value(snapshot.utc_offset_hours, " h")),
97
+ ("Network connected", _value(snapshot.network_connected)),
98
+ ("IP address", snapshot.ip_address),
99
+ ("Hostname", snapshot.hostname),
100
+ ("WiFi SSID", snapshot.ssid),
101
+ ("WiFi RSSI", _value(snapshot.rssi_dbm, " dBm")),
102
+ ("Expander present", _value(snapshot.expander_present)),
103
+ (
104
+ "Current monitors present",
105
+ f"{_value(snapshot.current_monitor_1_present)} / {_value(snapshot.current_monitor_2_present)}",
106
+ ),
107
+ ("Environment sensor present", _value(snapshot.environment_sensor_present)),
108
+ (
109
+ "Free heap / largest block",
110
+ f"{snapshot.heap_free_bytes} / {snapshot.heap_largest_block_bytes} bytes",
111
+ ),
112
+ (
113
+ "Ports enabled / occupied / faulted",
114
+ f"{snapshot.ports_enabled} / {snapshot.ports_occupied} / {snapshot.ports_faulted} of {snapshot.port_count}",
115
+ ),
116
+ ]
117
+ for label, value in rows:
118
+ table.add_row(label, value)
119
+ return table
120
+
121
+
122
+ def _next_action(port: PortStatus) -> str:
123
+ action = port.next_action
124
+ if action is None:
125
+ return "—"
126
+ return f"{'on' if action.enabled else 'off'} {action.local_time} ({action.delay_seconds}s)"
127
+
128
+
129
+ def _status_table(snapshot: PortStatus | list[PortStatus]) -> Table:
130
+ if isinstance(snapshot, PortStatus):
131
+ table = Table(title=f"Port {snapshot.number}", show_header=False)
132
+ table.add_column("Field", style="bold")
133
+ table.add_column("Value")
134
+ rows = [
135
+ ("Name", snapshot.name or "—"),
136
+ ("Enabled", _value(snapshot.enabled)),
137
+ ("Device present", _value(snapshot.occupied)),
138
+ ("Fault", _value(snapshot.fault)),
139
+ ("LED state", snapshot.led_state),
140
+ ("Monitor present", _value(snapshot.monitor_present)),
141
+ ("Voltage", _value(snapshot.voltage_v, " V")),
142
+ ("Current", _value(snapshot.current_ma, " mA")),
143
+ ("Power", _value(snapshot.power_mw, " mW")),
144
+ ("Power mode", _value(snapshot.power_mode)),
145
+ ("Active schedule entries", str(snapshot.schedule_count)),
146
+ ("Next action", _next_action(snapshot)),
147
+ ]
148
+ for label, value in rows:
149
+ table.add_row(label, value)
150
+ return table
151
+ table = Table(title="Ports — enabled is a switch state, not a supply check")
152
+ for column in (
153
+ "Port",
154
+ "Name",
155
+ "Enabled",
156
+ "Device",
157
+ "Fault",
158
+ "V",
159
+ "mA",
160
+ "mW",
161
+ "Next",
162
+ ):
163
+ table.add_column(column)
164
+ for port in snapshot:
165
+ table.add_row(
166
+ str(port.number),
167
+ port.name or "—",
168
+ _value(port.enabled),
169
+ _value(port.occupied),
170
+ _value(port.fault),
171
+ *(
172
+ _value(value) if value is not None else "—"
173
+ for value in (port.voltage_v, port.current_ma, port.power_mw)
174
+ ),
175
+ _next_action(port),
176
+ )
177
+ table.caption = "Device = plugged in; — measurement = unavailable"
178
+ return table
179
+
180
+
181
+ @app.command()
182
+ def info(ctx: typer.Context, json_output: JsonOutput = False) -> None:
183
+ """Show hub identity, supply, sensors, network and hardware health."""
184
+ with _errors():
185
+ snapshot = ctx.obj.info()
186
+ if json_output:
187
+ _json(snapshot)
188
+ else:
189
+ console.print(_info_table(snapshot), markup=False)
190
+
191
+
192
+ @app.command()
193
+ def status(
194
+ ctx: typer.Context, port: OptionalPort = None, json_output: JsonOutput = False
195
+ ) -> None:
196
+ """Show one port's details, or a table of all ports."""
197
+ with _errors():
198
+ snapshot = ctx.obj.ports() if port is None else ctx.obj.status(port)
199
+ if json_output:
200
+ _json(snapshot)
201
+ else:
202
+ console.print(_status_table(snapshot), markup=False)
203
+
204
+
205
+ @app.command()
206
+ def identify(ctx: typer.Context, port: Port) -> None:
207
+ """Flash PORT's LED without switching its power."""
208
+ with _errors():
209
+ ctx.obj.identify(port)
210
+ typer.echo(f"identifying port {port}")
211
+
212
+
213
+ async def _watch(
214
+ hub: Hub, port: int | None, interval: float, json_output: bool
215
+ ) -> None:
216
+ async with aclosing(hub.watch(port, interval=interval)) as snapshots:
217
+ if not json_output and console.is_terminal:
218
+ with Live(console=console, auto_refresh=False) as live:
219
+ async for snapshot in snapshots:
220
+ live.update(_status_table(snapshot), refresh=True)
221
+ else:
222
+ async for snapshot in snapshots:
223
+ if json_output:
224
+ _json(snapshot)
225
+ else:
226
+ console.print(_status_table(snapshot), markup=False)
227
+
228
+
229
+ @app.command()
230
+ def watch(
231
+ ctx: typer.Context,
232
+ port: OptionalPort = None,
233
+ interval: Annotated[
234
+ float,
235
+ typer.Option(min=0, help="Polling interval in seconds; must be positive."),
236
+ ] = 1.0,
237
+ json_output: JsonOutput = False,
238
+ ) -> None:
239
+ """Monitor port status until Ctrl+C. --json emits one snapshot per line.
240
+
241
+ Monitoring is read-only. Omit PORT to watch all ports.
242
+ """
243
+ with _errors():
244
+ asyncio.run(_watch(ctx.obj, port, interval, json_output))
245
+
246
+
247
+ @app.command()
248
+ def on(ctx: typer.Context, port: Port) -> None:
249
+ """Turn PORT on."""
250
+ _run(port, lambda: ctx.obj.on(port))
251
+
252
+
253
+ @app.command()
254
+ def off(ctx: typer.Context, port: Port) -> None:
255
+ """Turn PORT off."""
256
+ _run(port, lambda: ctx.obj.off(port))
257
+
258
+
259
+ @app.command()
260
+ def toggle(
261
+ ctx: typer.Context,
262
+ port: Port,
263
+ seconds: Annotated[
264
+ float | None,
265
+ typer.Argument(min=0, help="Toggle again every SECONDS until Ctrl+C."),
266
+ ] = None,
267
+ ) -> None:
268
+ """Flip PORT's power. With SECONDS, keep flipping until Ctrl+C.
269
+
270
+ Ctrl+C leaves the port in whatever state it was in.
271
+ """
272
+ if seconds is not None:
273
+ typer.echo(f"toggling port {port} every {seconds:g} seconds; Ctrl+C to stop")
274
+ _run(port, lambda: ctx.obj.toggle(port, seconds))
@@ -0,0 +1,144 @@
1
+ """Immutable snapshots decoded from the hub's REST API."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class ScheduledAction:
9
+ """The next scheduled action at the instant a snapshot was fetched."""
10
+
11
+ delay_seconds: int
12
+ enabled: bool
13
+ local_time: str
14
+ entry_index: int
15
+
16
+ @classmethod
17
+ def from_api(cls, data: dict[str, Any]) -> ScheduledAction:
18
+ """Decode the API's next-action object without schedule entry details."""
19
+ return cls(
20
+ delay_seconds=data["in_s"],
21
+ enabled=data["on"],
22
+ local_time=data["at"],
23
+ entry_index=data["entry"],
24
+ )
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class PortStatus:
29
+ """One immutable port snapshot; unavailable readings are None, not zero."""
30
+
31
+ number: int
32
+ name: str
33
+ enabled: bool
34
+ occupied: bool
35
+ fault: bool
36
+ led_state: str
37
+ monitor_present: bool
38
+ voltage_v: float | None
39
+ current_ma: float | None
40
+ power_mw: float | None
41
+ power_mode: str | None
42
+ schedule_count: int
43
+ next_action: ScheduledAction | None
44
+
45
+ @classmethod
46
+ def from_api(cls, data: dict[str, Any]) -> PortStatus:
47
+ """Decode either port endpoint; bulk snapshots omit power_mode."""
48
+ monitor = data["monitor"]
49
+ schedule = data["schedule"]
50
+ next_action = schedule["next"]
51
+ return cls(
52
+ number=data["n"],
53
+ name=data["name"],
54
+ enabled=data["enabled"],
55
+ occupied=data["occupied"],
56
+ fault=data["fault"],
57
+ led_state=data["state"],
58
+ monitor_present=monitor,
59
+ voltage_v=float(data["volts"]) if monitor else None,
60
+ current_ma=float(data["ma"]) if monitor else None,
61
+ power_mw=float(data["mw"]) if monitor else None,
62
+ power_mode=data.get("power_mode"),
63
+ schedule_count=schedule["count"],
64
+ next_action=(
65
+ ScheduledAction.from_api(next_action)
66
+ if next_action is not None
67
+ else None
68
+ ),
69
+ )
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class HubInfo:
74
+ """Immutable hub-wide snapshot, with invalid sensor and clock data absent."""
75
+
76
+ name: str
77
+ firmware: str
78
+ uptime_seconds: int
79
+ reset_reason: str
80
+ main_5v: bool
81
+ upstream_5v: bool
82
+ total_current_ma: float
83
+ total_power_mw: float
84
+ temperature_c: float | None
85
+ humidity_percent: float | None
86
+ clock_synced: bool
87
+ time_iso: str | None
88
+ utc_offset_hours: float
89
+ network_connected: bool
90
+ ip_address: str
91
+ ssid: str
92
+ rssi_dbm: int
93
+ hostname: str
94
+ expander_present: bool
95
+ current_monitor_1_present: bool
96
+ current_monitor_2_present: bool
97
+ environment_sensor_present: bool
98
+ heap_free_bytes: int
99
+ heap_largest_block_bytes: int
100
+ port_count: int
101
+ ports_enabled: int
102
+ ports_occupied: int
103
+ ports_faulted: int
104
+
105
+ @classmethod
106
+ def from_api(cls, data: dict[str, Any]) -> HubInfo:
107
+ """Normalize /api/hub, retaining only currently valid measurements."""
108
+ power = data["power"]
109
+ sensors = data["sensors"]
110
+ clock = data["clock"]
111
+ network = data["network"]
112
+ devices = data["devices"]
113
+ memory = data["memory"]
114
+ ports = data["ports_summary"]
115
+ return cls(
116
+ name=data["name"],
117
+ firmware=data["fw"],
118
+ uptime_seconds=data["uptime_s"],
119
+ reset_reason=data["reset_reason"],
120
+ main_5v=power["main_5v"],
121
+ upstream_5v=power["upstream_5v"],
122
+ total_current_ma=float(power["total_ma"]),
123
+ total_power_mw=float(power["total_mw"]),
124
+ temperature_c=float(sensors["temp_c"]) if sensors["valid"] else None,
125
+ humidity_percent=float(sensors["humidity"]) if sensors["valid"] else None,
126
+ clock_synced=clock["synced"],
127
+ time_iso=clock["iso"] if clock["synced"] else None,
128
+ utc_offset_hours=float(clock["utc_offset"]),
129
+ network_connected=network["connected"],
130
+ ip_address=network["ip"],
131
+ ssid=network["ssid"],
132
+ rssi_dbm=network["rssi"],
133
+ hostname=network["hostname"],
134
+ expander_present=devices["expander"],
135
+ current_monitor_1_present=devices["ina_1"],
136
+ current_monitor_2_present=devices["ina_2"],
137
+ environment_sensor_present=devices["aht20"],
138
+ heap_free_bytes=memory["heap_free"],
139
+ heap_largest_block_bytes=memory["heap_max_block"],
140
+ port_count=ports["count"],
141
+ ports_enabled=ports["powered"],
142
+ ports_occupied=ports["occupied"],
143
+ ports_faulted=ports["faulted"],
144
+ )
File without changes