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.
- um_smart_hub-0.1.0/LICENSE +21 -0
- um_smart_hub-0.1.0/PKG-INFO +273 -0
- um_smart_hub-0.1.0/README.md +259 -0
- um_smart_hub-0.1.0/pyproject.toml +45 -0
- um_smart_hub-0.1.0/pyproject.toml.orig +41 -0
- um_smart_hub-0.1.0/src/smart_hub/__init__.py +172 -0
- um_smart_hub-0.1.0/src/smart_hub/cli.py +274 -0
- um_smart_hub-0.1.0/src/smart_hub/models.py +144 -0
- um_smart_hub-0.1.0/src/smart_hub/py.typed +0 -0
|
@@ -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
|