carterkit 0.5.2__tar.gz → 0.6.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.
- {carterkit-0.5.2 → carterkit-0.6.0}/PKG-INFO +66 -20
- {carterkit-0.5.2 → carterkit-0.6.0}/README.md +65 -19
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/__init__.py +8 -1
- carterkit-0.6.0/carterkit/bind.py +99 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/buffer.py +36 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/catalog.py +8 -1
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/cli.py +1 -1
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/client.py +85 -10
- carterkit-0.6.0/carterkit/codegen.py +196 -0
- carterkit-0.6.0/carterkit/connection.py +218 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/accordion.md +6 -0
- carterkit-0.6.0/carterkit/controldocs/box-plot.md +216 -0
- carterkit-0.6.0/carterkit/controldocs/camera.md +189 -0
- carterkit-0.6.0/carterkit/controldocs/canvas.md +180 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/cardList.md +15 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/carousel.md +6 -0
- carterkit-0.6.0/carterkit/controldocs/chart.md +346 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/chat.md +36 -0
- carterkit-0.6.0/carterkit/controldocs/chord.md +169 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/color-picker.md +3 -1
- carterkit-0.6.0/carterkit/controldocs/compass.md +143 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/control-def.md +2 -2
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/flip-card.md +6 -0
- carterkit-0.6.0/carterkit/controldocs/gantt.md +203 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/gauge.md +2 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/graph.md +145 -1
- carterkit-0.6.0/carterkit/controldocs/heatmap.md +262 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/index.md +6 -2
- carterkit-0.6.0/carterkit/controldocs/pie-chart.md +234 -0
- carterkit-0.6.0/carterkit/controldocs/pinboard.md +144 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/privacy.md +1 -0
- carterkit-0.6.0/carterkit/controldocs/publishers.md +68 -0
- carterkit-0.6.0/carterkit/controldocs/radar.md +201 -0
- carterkit-0.6.0/carterkit/controldocs/sankey.md +185 -0
- carterkit-0.6.0/carterkit/controldocs/sensors.md +61 -0
- carterkit-0.6.0/carterkit/controldocs/sortboard.md +233 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/sparkline.md +1 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/stepper.md +3 -1
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/sync.md +8 -1
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/text-input.md +3 -1
- carterkit-0.6.0/carterkit/controldocs/treemap.md +189 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/declare.py +6 -4
- carterkit-0.6.0/carterkit/hub.py +358 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/layout.py +107 -13
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/validate.py +48 -1
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/PKG-INFO +66 -20
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/SOURCES.txt +20 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/pyproject.toml +1 -1
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_bind.py +28 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_cli.py +4 -2
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_client.py +148 -0
- carterkit-0.6.0/tests/test_connection.py +97 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_declare.py +3 -2
- carterkit-0.6.0/tests/test_hub.py +303 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_ui.py +19 -3
- carterkit-0.6.0/tests/test_validate_bindings.py +90 -0
- carterkit-0.5.2/carterkit/bind.py +0 -40
- carterkit-0.5.2/carterkit/codegen.py +0 -188
- carterkit-0.5.2/tests/test_validate_bindings.py +0 -40
- {carterkit-0.5.2 → carterkit-0.6.0}/LICENSE +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/__main__.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/actions.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/animations.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/appearance.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/button.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/date-picker.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/divider.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/grid-dimensions.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/group-def.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/haptics.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/image.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/joystick.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/label.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/layout-config.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/list.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/log-console.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/long-press.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/map.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/picker.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/progress-ring.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/pulse.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/qr-code.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/segmented.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/slider.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/spacer.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/status-light.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/terms.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/theming.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/toggle.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/visibility.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/web-view.md +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controls.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/dynamic.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/e2ee.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/grid.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/infer.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/py.typed +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/relay.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/theming.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/tune.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/dependency_links.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/entry_points.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/requires.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/top_level.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/setup.cfg +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_buffer.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_catalog.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_codegen.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_controls.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_dynamic.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_e2ee.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_grid.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_infer.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_layout.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_theming.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_tune.py +0 -0
- {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_validate.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: carterkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: Build and drive CAR-TER layouts from Python — the control docs are the library.
|
|
5
5
|
Author: Carter Beaudoin
|
|
6
6
|
License-Expression: MIT
|
|
@@ -65,17 +65,26 @@ with Layout("Dashboard", cols=4, rows=4) as ui:
|
|
|
65
65
|
cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
|
|
66
66
|
listen="cpu", when={"msg_type": "metrics"})
|
|
67
67
|
ui.status_light("warn", visible=cpu > 90) # handle → visibility condition
|
|
68
|
-
ui.button("refresh", label="Refresh", send="refresh"
|
|
68
|
+
ui.button("refresh", label="Refresh", send="refresh")
|
|
69
69
|
|
|
70
70
|
print(ui.findings()) # schema + grid + binding lint against the bundled catalog
|
|
71
71
|
ui.save("dashboard.json") # the composed layout, ready to push/load
|
|
72
72
|
```
|
|
73
73
|
|
|
74
|
-
Binding sugar: `listen=`/`when=`/`event=` build a `sync`, and `send=`/`
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
Binding sugar: `listen=`/`when=`/`event=` build a `sync`, and `send=`/`payload=` build
|
|
75
|
+
an `action` — `send="refresh"` compiles to the one shape the relay actually forwards
|
|
76
|
+
(`broadcast_request` tagged `msg_type: "refresh"`; `Hub.on` demuxes it back for you).
|
|
77
|
+
Pass `sync=[...]`/`action={...}` (via `carterkit.bind`) for anything fancier. A handle
|
|
78
|
+
comparison (`cpu > 90`) becomes a real visibility condition; `==`/`!=` stay normal
|
|
79
|
+
Python, so use `.eq()`/`.neq()`. `help(carterkit.build.gauge)` prints any control's
|
|
80
|
+
documentation, straight from the bundled docs.
|
|
81
|
+
|
|
82
|
+
> **Naming:** multi-word controls are **`snake_case` as `Layout` methods**
|
|
83
|
+
> (`ui.status_light(...)`, `ui.log_console(...)`, `ui.progress_ring(...)`) but
|
|
84
|
+
> **`camelCase` as the JSON `type`** and as `carterkit.build.*` functions
|
|
85
|
+
> (`"statusLight"`, `build.logConsole`). Single-word controls (`gauge`, `button`) look
|
|
86
|
+
> the same either way. Grid size (`cols`/`rows`) set on `Layout(...)` is the default for
|
|
87
|
+
> every tab; override it per tab with `ui.tab("Name", rows=…)`.
|
|
79
88
|
|
|
80
89
|
**Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
|
|
81
90
|
from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
|
|
@@ -89,7 +98,7 @@ class Dashboard(Screen, cols=4, rows=4):
|
|
|
89
98
|
class Main(Tab, icon="gauge"):
|
|
90
99
|
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu")
|
|
91
100
|
warn = StatusLight(visible=cpu > 90)
|
|
92
|
-
refresh = Button(label="Refresh", send="refresh"
|
|
101
|
+
refresh = Button(label="Refresh", send="refresh")
|
|
93
102
|
|
|
94
103
|
Dashboard.save("dashboard.json")
|
|
95
104
|
```
|
|
@@ -121,7 +130,7 @@ Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `
|
|
|
121
130
|
over a held draft; `lay.buffer` exposes it.
|
|
122
131
|
|
|
123
132
|
`infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
|
|
124
|
-
`codegen.generate_service_stub(layout)` emits a runnable
|
|
133
|
+
`codegen.generate_service_stub(layout)` emits a runnable `Hub`-based server skeleton;
|
|
125
134
|
`theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
|
|
126
135
|
|
|
127
136
|
## CLI
|
|
@@ -131,29 +140,66 @@ carterkit catalog # list every control type
|
|
|
131
140
|
carterkit doc gauge # print a control's documentation
|
|
132
141
|
carterkit examples button # list a control's examples (--name to print one)
|
|
133
142
|
carterkit validate layout.json # lint a layout (exit 1 on errors)
|
|
134
|
-
carterkit gen layout.json # generate a
|
|
143
|
+
carterkit gen layout.json # generate a runnable Hub server stub
|
|
135
144
|
carterkit relay --port 8765 # run the bundled MeshSocket relay
|
|
136
145
|
```
|
|
137
146
|
|
|
138
|
-
## Drive
|
|
147
|
+
## Drive the layout you just built
|
|
148
|
+
|
|
149
|
+
The layout already declares every control's wire contract, so the same object that
|
|
150
|
+
authored the UI also drives it — `ctrl.push(value)` derives the broadcast from the
|
|
151
|
+
control's `sync` binding, and `@ctrl.on` derives the demux from its `action`:
|
|
139
152
|
|
|
140
153
|
```python
|
|
141
154
|
import asyncio
|
|
142
|
-
from carterkit import
|
|
155
|
+
from carterkit import Layout
|
|
156
|
+
|
|
157
|
+
with Layout("Thermostat") as ui:
|
|
158
|
+
with ui.tab("Main"):
|
|
159
|
+
temp = ui.gauge("temp", label="Temp", min=0, max=40,
|
|
160
|
+
listen="temp", when={"msg_type": "climate"})
|
|
161
|
+
target = ui.slider("target", min=10, max=30, send="set_target")
|
|
143
162
|
|
|
144
163
|
async def main():
|
|
145
|
-
async with
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await
|
|
149
|
-
|
|
150
|
-
|
|
164
|
+
async with ui.serve() as hub: # zero config: embedded LocalRelay
|
|
165
|
+
print("pair the app with:", hub.qr_json())
|
|
166
|
+
await hub.wait_for_device()
|
|
167
|
+
await hub.push_layout() # routed apply-layout; echoes what rendered
|
|
168
|
+
|
|
169
|
+
@target.on
|
|
170
|
+
async def _(data):
|
|
171
|
+
heater.set(data["value"])
|
|
172
|
+
|
|
173
|
+
while True:
|
|
174
|
+
await temp.push(read_temp())
|
|
175
|
+
await asyncio.sleep(2)
|
|
151
176
|
|
|
152
177
|
asyncio.run(main())
|
|
153
178
|
```
|
|
154
179
|
|
|
155
|
-
|
|
156
|
-
|
|
180
|
+
The same surface works cross-process off the saved JSON — the layout file is the
|
|
181
|
+
contract: `Hub("dashboard.json").push("temp", 21.5)`. Dynamic groups fill with
|
|
182
|
+
`hub.fill(group, fragment)`; the hub answers late joiners with the last pushed values
|
|
183
|
+
(control-state authority) by default.
|
|
184
|
+
|
|
185
|
+
## One connection story
|
|
186
|
+
|
|
187
|
+
`Connection.parse(...)` accepts every connection artifact in the ecosystem, and
|
|
188
|
+
`ui.connect(...)` / `ui.serve(...)` / `Hub(...)` all take it:
|
|
189
|
+
|
|
190
|
+
| You have | Pass | Works |
|
|
191
|
+
|---|---|---|
|
|
192
|
+
| nothing (LAN dev) | `ui.serve()` | embedded `LocalRelay`, QR pairing |
|
|
193
|
+
| a self-hosted relay | `"ws://192.168.1.50:8765"` (+ `token=`) | symmetric: same config for app & hub |
|
|
194
|
+
| Connect+ | the **Add Device** JSON from the app (Members → Add Device) | token self-refresh + room E2EE automatic |
|
|
195
|
+
|
|
196
|
+
The asymmetry to know: self-hosted is symmetric (one URL + shared key both sides);
|
|
197
|
+
on Connect+ the app joins with its own account while the hub holds the per-device
|
|
198
|
+
credential — which is the hub's identity, so it is never embedded into a layout.
|
|
199
|
+
|
|
200
|
+
`CarterClient` remains the lower-level client (`on`/`broadcast`/`request`).
|
|
201
|
+
End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when an
|
|
202
|
+
`e2ee_key` is present. Send a push to every device on a Connect+ account with
|
|
157
203
|
`CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
|
|
158
204
|
|
|
159
205
|
## Built on
|
|
@@ -42,17 +42,26 @@ with Layout("Dashboard", cols=4, rows=4) as ui:
|
|
|
42
42
|
cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
|
|
43
43
|
listen="cpu", when={"msg_type": "metrics"})
|
|
44
44
|
ui.status_light("warn", visible=cpu > 90) # handle → visibility condition
|
|
45
|
-
ui.button("refresh", label="Refresh", send="refresh"
|
|
45
|
+
ui.button("refresh", label="Refresh", send="refresh")
|
|
46
46
|
|
|
47
47
|
print(ui.findings()) # schema + grid + binding lint against the bundled catalog
|
|
48
48
|
ui.save("dashboard.json") # the composed layout, ready to push/load
|
|
49
49
|
```
|
|
50
50
|
|
|
51
|
-
Binding sugar: `listen=`/`when=`/`event=` build a `sync`, and `send=`/`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
51
|
+
Binding sugar: `listen=`/`when=`/`event=` build a `sync`, and `send=`/`payload=` build
|
|
52
|
+
an `action` — `send="refresh"` compiles to the one shape the relay actually forwards
|
|
53
|
+
(`broadcast_request` tagged `msg_type: "refresh"`; `Hub.on` demuxes it back for you).
|
|
54
|
+
Pass `sync=[...]`/`action={...}` (via `carterkit.bind`) for anything fancier. A handle
|
|
55
|
+
comparison (`cpu > 90`) becomes a real visibility condition; `==`/`!=` stay normal
|
|
56
|
+
Python, so use `.eq()`/`.neq()`. `help(carterkit.build.gauge)` prints any control's
|
|
57
|
+
documentation, straight from the bundled docs.
|
|
58
|
+
|
|
59
|
+
> **Naming:** multi-word controls are **`snake_case` as `Layout` methods**
|
|
60
|
+
> (`ui.status_light(...)`, `ui.log_console(...)`, `ui.progress_ring(...)`) but
|
|
61
|
+
> **`camelCase` as the JSON `type`** and as `carterkit.build.*` functions
|
|
62
|
+
> (`"statusLight"`, `build.logConsole`). Single-word controls (`gauge`, `button`) look
|
|
63
|
+
> the same either way. Grid size (`cols`/`rows`) set on `Layout(...)` is the default for
|
|
64
|
+
> every tab; override it per tab with `ui.tab("Name", rows=…)`.
|
|
56
65
|
|
|
57
66
|
**Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
|
|
58
67
|
from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
|
|
@@ -66,7 +75,7 @@ class Dashboard(Screen, cols=4, rows=4):
|
|
|
66
75
|
class Main(Tab, icon="gauge"):
|
|
67
76
|
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu")
|
|
68
77
|
warn = StatusLight(visible=cpu > 90)
|
|
69
|
-
refresh = Button(label="Refresh", send="refresh"
|
|
78
|
+
refresh = Button(label="Refresh", send="refresh")
|
|
70
79
|
|
|
71
80
|
Dashboard.save("dashboard.json")
|
|
72
81
|
```
|
|
@@ -98,7 +107,7 @@ Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `
|
|
|
98
107
|
over a held draft; `lay.buffer` exposes it.
|
|
99
108
|
|
|
100
109
|
`infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
|
|
101
|
-
`codegen.generate_service_stub(layout)` emits a runnable
|
|
110
|
+
`codegen.generate_service_stub(layout)` emits a runnable `Hub`-based server skeleton;
|
|
102
111
|
`theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
|
|
103
112
|
|
|
104
113
|
## CLI
|
|
@@ -108,29 +117,66 @@ carterkit catalog # list every control type
|
|
|
108
117
|
carterkit doc gauge # print a control's documentation
|
|
109
118
|
carterkit examples button # list a control's examples (--name to print one)
|
|
110
119
|
carterkit validate layout.json # lint a layout (exit 1 on errors)
|
|
111
|
-
carterkit gen layout.json # generate a
|
|
120
|
+
carterkit gen layout.json # generate a runnable Hub server stub
|
|
112
121
|
carterkit relay --port 8765 # run the bundled MeshSocket relay
|
|
113
122
|
```
|
|
114
123
|
|
|
115
|
-
## Drive
|
|
124
|
+
## Drive the layout you just built
|
|
125
|
+
|
|
126
|
+
The layout already declares every control's wire contract, so the same object that
|
|
127
|
+
authored the UI also drives it — `ctrl.push(value)` derives the broadcast from the
|
|
128
|
+
control's `sync` binding, and `@ctrl.on` derives the demux from its `action`:
|
|
116
129
|
|
|
117
130
|
```python
|
|
118
131
|
import asyncio
|
|
119
|
-
from carterkit import
|
|
132
|
+
from carterkit import Layout
|
|
133
|
+
|
|
134
|
+
with Layout("Thermostat") as ui:
|
|
135
|
+
with ui.tab("Main"):
|
|
136
|
+
temp = ui.gauge("temp", label="Temp", min=0, max=40,
|
|
137
|
+
listen="temp", when={"msg_type": "climate"})
|
|
138
|
+
target = ui.slider("target", min=10, max=30, send="set_target")
|
|
120
139
|
|
|
121
140
|
async def main():
|
|
122
|
-
async with
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
await
|
|
126
|
-
|
|
127
|
-
|
|
141
|
+
async with ui.serve() as hub: # zero config: embedded LocalRelay
|
|
142
|
+
print("pair the app with:", hub.qr_json())
|
|
143
|
+
await hub.wait_for_device()
|
|
144
|
+
await hub.push_layout() # routed apply-layout; echoes what rendered
|
|
145
|
+
|
|
146
|
+
@target.on
|
|
147
|
+
async def _(data):
|
|
148
|
+
heater.set(data["value"])
|
|
149
|
+
|
|
150
|
+
while True:
|
|
151
|
+
await temp.push(read_temp())
|
|
152
|
+
await asyncio.sleep(2)
|
|
128
153
|
|
|
129
154
|
asyncio.run(main())
|
|
130
155
|
```
|
|
131
156
|
|
|
132
|
-
|
|
133
|
-
|
|
157
|
+
The same surface works cross-process off the saved JSON — the layout file is the
|
|
158
|
+
contract: `Hub("dashboard.json").push("temp", 21.5)`. Dynamic groups fill with
|
|
159
|
+
`hub.fill(group, fragment)`; the hub answers late joiners with the last pushed values
|
|
160
|
+
(control-state authority) by default.
|
|
161
|
+
|
|
162
|
+
## One connection story
|
|
163
|
+
|
|
164
|
+
`Connection.parse(...)` accepts every connection artifact in the ecosystem, and
|
|
165
|
+
`ui.connect(...)` / `ui.serve(...)` / `Hub(...)` all take it:
|
|
166
|
+
|
|
167
|
+
| You have | Pass | Works |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| nothing (LAN dev) | `ui.serve()` | embedded `LocalRelay`, QR pairing |
|
|
170
|
+
| a self-hosted relay | `"ws://192.168.1.50:8765"` (+ `token=`) | symmetric: same config for app & hub |
|
|
171
|
+
| Connect+ | the **Add Device** JSON from the app (Members → Add Device) | token self-refresh + room E2EE automatic |
|
|
172
|
+
|
|
173
|
+
The asymmetry to know: self-hosted is symmetric (one URL + shared key both sides);
|
|
174
|
+
on Connect+ the app joins with its own account while the hub holds the per-device
|
|
175
|
+
credential — which is the hub's identity, so it is never embedded into a layout.
|
|
176
|
+
|
|
177
|
+
`CarterClient` remains the lower-level client (`on`/`broadcast`/`request`).
|
|
178
|
+
End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when an
|
|
179
|
+
`e2ee_key` is present. Send a push to every device on a Connect+ account with
|
|
134
180
|
`CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
|
|
135
181
|
|
|
136
182
|
## Built on
|
|
@@ -13,7 +13,11 @@ Quick map:
|
|
|
13
13
|
- ``validate_layout()`` — schema + grid lint against the bundled catalog
|
|
14
14
|
- ``lint_dynamic_traffic()`` — check ``dynamic=`` groups against observed broadcasts
|
|
15
15
|
- ``infer`` / ``codegen`` / ``theming`` / ``tune`` — generate layouts, servers, themes
|
|
16
|
-
- ``
|
|
16
|
+
- ``Connection`` — ONE parser for every connection artifact (relay URL, pairing
|
|
17
|
+
QR JSON, Connect+ Add-Device credential, layout connection block)
|
|
18
|
+
- ``Hub`` / ``Layout.serve()`` — drive the layout you built: ``ctrl.push(value)``
|
|
19
|
+
and ``@ctrl.on`` derived from the very sync/action bindings you authored
|
|
20
|
+
- ``CarterClient`` / ``notify_http`` — the lower-level client: connect, push, alerts
|
|
17
21
|
"""
|
|
18
22
|
from importlib.resources import files
|
|
19
23
|
from pathlib import Path
|
|
@@ -27,6 +31,8 @@ from .relay import LocalRelay, port_in_use, lan_ip
|
|
|
27
31
|
from . import bind
|
|
28
32
|
from .controls import build, control
|
|
29
33
|
from .layout import Layout, Fragment, Control, Condition
|
|
34
|
+
from .connection import Connection
|
|
35
|
+
from .hub import Hub, HubError
|
|
30
36
|
|
|
31
37
|
try:
|
|
32
38
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
@@ -85,5 +91,6 @@ __all__ = [
|
|
|
85
91
|
"controls", "doc", "doc_markdown", "examples", "validate_layout",
|
|
86
92
|
"lint_dynamic_traffic", "format_findings", "controldocs_dir",
|
|
87
93
|
"build", "control", "bind", "Layout", "Fragment", "Control", "Condition",
|
|
94
|
+
"Connection", "Hub", "HubError",
|
|
88
95
|
"catalog", "grid", "codegen", "infer", "theming", "tune", "dynamic",
|
|
89
96
|
]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Helpers that build the (verbose, easy-to-get-wrong) sync / action / connection
|
|
2
|
+
dicts controls use. Shapes mirror the bundled ControlDocs (sync.md, actions.md).
|
|
3
|
+
|
|
4
|
+
from carterkit import bind
|
|
5
|
+
bind.listen("cpu", filter={"msg_type": "telemetry"})
|
|
6
|
+
bind.command("set_power", payload={"state": "{{value}}"})
|
|
7
|
+
bind.connection("ws://192.168.1.50:8765", channel="home")
|
|
8
|
+
|
|
9
|
+
The one wire fact everything here encodes: the relay dispatches ONLY its own verbs
|
|
10
|
+
(`WIRE_VERBS` plus housekeeping like `ping`). An action whose `event` is any other
|
|
11
|
+
name is silently dropped at the relay — the control does nothing. Commands therefore
|
|
12
|
+
ride `broadcast_request` with the command name as the payload's `msg_type`, and the
|
|
13
|
+
server demuxes on that (`Hub.on` does it automatically).
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
#: The only frame types the relay forwards between peers. An action's `event`
|
|
18
|
+
#: must be one of these (or be built by :func:`command`) to go anywhere.
|
|
19
|
+
WIRE_VERBS = ("broadcast_request", "route_msg", "route_msg_noreply")
|
|
20
|
+
|
|
21
|
+
#: Frame types the relay itself answers (housekeeping) — legal, but not data-plane.
|
|
22
|
+
#: `identify` re-identifies the sender (name/channel switching from a control).
|
|
23
|
+
RELAY_SERVICE_VERBS = ("ping", "handshake", "status_request", "get_nodes", "identify")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def listen(value_path: str, *, event: str = "broadcast", filter: dict | None = None,
|
|
27
|
+
method: str = "meshsocket") -> dict:
|
|
28
|
+
"""A `sync` entry: subscribe to `event`, match `filter`, extract `value_path`
|
|
29
|
+
(dot-notation). Returns one sync dict — controls take a list of them."""
|
|
30
|
+
s: dict = {"method": method, "type": "listen", "event": event, "valuePath": value_path}
|
|
31
|
+
if filter is not None:
|
|
32
|
+
s["filter"] = filter
|
|
33
|
+
return s
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def action(event: str, *, payload: dict | None = None, mode: str = "broadcast",
|
|
37
|
+
method: str = "meshsocket") -> dict:
|
|
38
|
+
"""A raw `action` dict: fire the frame `event` on tap/change. `mode` is
|
|
39
|
+
"broadcast" (fire and forget) or "request" (await reply). `payload` strings
|
|
40
|
+
support `{{value}}`.
|
|
41
|
+
|
|
42
|
+
This is the power-user escape hatch — `event` goes on the wire as the frame
|
|
43
|
+
type verbatim, so it must be a relay verb (`WIRE_VERBS`) to be forwarded.
|
|
44
|
+
For a named command, use :func:`command` instead."""
|
|
45
|
+
if mode not in ("broadcast", "request"):
|
|
46
|
+
raise ValueError(f"mode must be 'broadcast' or 'request', got {mode!r}")
|
|
47
|
+
a: dict = {"method": method, "mode": mode, "event": event}
|
|
48
|
+
if payload is not None:
|
|
49
|
+
a["payload"] = payload
|
|
50
|
+
return a
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def command(name: str, *, payload: dict | None = None, method: str = "meshsocket") -> dict:
|
|
54
|
+
"""An `action` for a named command that actually crosses the relay.
|
|
55
|
+
|
|
56
|
+
Compiles to `broadcast_request` with `name` as the payload's `msg_type` — the
|
|
57
|
+
fan-out shape every server can demux on (`Hub.on(handle)` derives it back).
|
|
58
|
+
Default payload adds `{"value": "{{value}}"}` so the control's value rides
|
|
59
|
+
along; pass `payload=` to replace that (the `msg_type` key always stays the
|
|
60
|
+
command name).
|
|
61
|
+
|
|
62
|
+
There is deliberately no request/reply variant: the relay only routes replies
|
|
63
|
+
for `route_msg`, which needs a live `target_id` no layout can know at author
|
|
64
|
+
time. Use the round-trip idiom instead — fire the command, and `listen=` for
|
|
65
|
+
the state broadcast the server sends back."""
|
|
66
|
+
body = dict(payload) if payload is not None else {"value": "{{value}}"}
|
|
67
|
+
body["msg_type"] = name
|
|
68
|
+
return {"method": method, "mode": "broadcast", "event": "broadcast_request",
|
|
69
|
+
"payload": body}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def connection(url: str | None, *, channel: str = "home", name: str = "CAR-TER",
|
|
73
|
+
role: str = "controller", token: str | None = None,
|
|
74
|
+
hub: str | None = None, mode: str | None = None,
|
|
75
|
+
e2ee_key: str | None = None, can_broadcast: bool | None = None) -> dict:
|
|
76
|
+
"""A layout `connection` block (relay URL + identity). `hub` names the mesh
|
|
77
|
+
identity of the server that will drive this layout (`Layout.serve` adopts it),
|
|
78
|
+
so both sides of the contract live in one artifact.
|
|
79
|
+
|
|
80
|
+
`mode="room"` + `e2ee_key` (base64 group key, emitted as `e2eeKey`) author the
|
|
81
|
+
Connect+ room shape — see `carterkit.connection.Connection.layout_block`, whose
|
|
82
|
+
output this mirrors. In a room, `url=None` omits the URL entirely: the app dials
|
|
83
|
+
its own Connect+ relay with its own account session, and a raw url/token here
|
|
84
|
+
would make it hand the wrong credential to the wrong host (reconnect loop)."""
|
|
85
|
+
conn: dict = {"identity": {"name": name, "channel": channel, "role": role}}
|
|
86
|
+
if url is not None:
|
|
87
|
+
conn["url"] = url
|
|
88
|
+
if token is not None:
|
|
89
|
+
conn["token"] = token
|
|
90
|
+
if hub is not None:
|
|
91
|
+
conn["hub"] = hub
|
|
92
|
+
if e2ee_key is not None:
|
|
93
|
+
conn["mode"] = mode or "room"
|
|
94
|
+
conn["e2eeKey"] = e2ee_key
|
|
95
|
+
elif mode is not None:
|
|
96
|
+
conn["mode"] = mode
|
|
97
|
+
if can_broadcast is not None:
|
|
98
|
+
conn["identity"]["can_broadcast"] = can_broadcast
|
|
99
|
+
return conn
|
|
@@ -197,8 +197,44 @@ class LayoutBuffer:
|
|
|
197
197
|
position = slot
|
|
198
198
|
group["position"] = position
|
|
199
199
|
children.append(group)
|
|
200
|
+
# Normalize nested children the same way add_control does for top-level ones:
|
|
201
|
+
# give each an id and an auto-placed position in the group's own grid, so the
|
|
202
|
+
# group validates instead of erroring on missing id/position. (Append first so
|
|
203
|
+
# unique_id, which scans the committed tree, sees the group's own children.)
|
|
204
|
+
self._normalize_group_children(group)
|
|
200
205
|
return group
|
|
201
206
|
|
|
207
|
+
def _normalize_group_children(self, group: dict) -> None:
|
|
208
|
+
"""Ensure every child of a group has an id and a position within the group's
|
|
209
|
+
grid. Auto-places (like add_control) any child missing a position, recursing
|
|
210
|
+
into nested groups. Raises if the group's grid has no room."""
|
|
211
|
+
kids = group.get("children")
|
|
212
|
+
if not isinstance(kids, list):
|
|
213
|
+
return
|
|
214
|
+
g = group.get("grid") or {}
|
|
215
|
+
cols = int(g.get("columns", g.get("cols", DEFAULT_COLUMNS)))
|
|
216
|
+
rows = int(g.get("rows", DEFAULT_ROWS))
|
|
217
|
+
# Record the grid we placed against so the device renders with the same dims.
|
|
218
|
+
group.setdefault("grid", {"columns": cols, "rows": rows})
|
|
219
|
+
placed = [c for c in kids if isinstance(c, dict) and c.get("position") is not None]
|
|
220
|
+
for child in kids:
|
|
221
|
+
if not isinstance(child, dict) or "type" not in child:
|
|
222
|
+
continue
|
|
223
|
+
if not child.get("id"):
|
|
224
|
+
child["id"] = self.unique_id(child["type"])
|
|
225
|
+
if child.get("position") is None:
|
|
226
|
+
span = child.get("span") or [1, 1]
|
|
227
|
+
slot = gridmod.find_slot(placed, cols, rows, span)
|
|
228
|
+
if slot is None:
|
|
229
|
+
raise BufferError(
|
|
230
|
+
f"group '{group['id']}' has no free {span} slot in its "
|
|
231
|
+
f"{rows}x{cols} grid for child '{child['id']}' — grow the group "
|
|
232
|
+
f"grid (cols/rows) or set the child's position.")
|
|
233
|
+
child["position"] = slot
|
|
234
|
+
placed.append(child)
|
|
235
|
+
if child.get("type") == "group":
|
|
236
|
+
self._normalize_group_children(child)
|
|
237
|
+
|
|
202
238
|
# ─── views ───────────────────────────────────────────────────────────────
|
|
203
239
|
|
|
204
240
|
def issues(self) -> list[dict]:
|
|
@@ -57,6 +57,10 @@ def _make_field(raw: dict[str, str]) -> dict:
|
|
|
57
57
|
field["default"] = raw["default"]
|
|
58
58
|
if raw.get("description"):
|
|
59
59
|
field["description"] = raw["description"]
|
|
60
|
+
if raw.get("group"):
|
|
61
|
+
# Per-field `group:` nests the field under a config object (e.g.
|
|
62
|
+
# `sortboardConfig`) — mirrored from the Swift loader's makeField.
|
|
63
|
+
field["group"] = raw["group"]
|
|
60
64
|
return field
|
|
61
65
|
|
|
62
66
|
|
|
@@ -197,7 +201,10 @@ def extract_examples(body: str) -> list[dict]:
|
|
|
197
201
|
# ─── Catalog assembly ────────────────────────────────────────────────────────
|
|
198
202
|
|
|
199
203
|
# Categories whose docs describe controls that can be placed in a layout grid.
|
|
200
|
-
|
|
204
|
+
# "layout" covers the structural placeables (divider, spacer) — real grid
|
|
205
|
+
# citizens the app renders, previously missing from the catalog so typed
|
|
206
|
+
# builders rejected them and servers had to inject raw dicts.
|
|
207
|
+
PLACEABLE_CATEGORIES = {"controls", "display", "layout"}
|
|
201
208
|
|
|
202
209
|
|
|
203
210
|
def parse_all(docs_dir) -> dict[str, dict]:
|
|
@@ -106,12 +106,17 @@ def device_refresh_http(validator_url, device_id, refresh_token, *, _send=None):
|
|
|
106
106
|
class CarterClient:
|
|
107
107
|
def __init__(self, gateway_url, token, channel, role="device", name="hub", e2ee_key=None,
|
|
108
108
|
validator_url=None, session_jwt=None, room=False,
|
|
109
|
-
device_id=None, refresh_token=None, refresh_interval=2400
|
|
109
|
+
device_id=None, refresh_token=None, refresh_interval=2400,
|
|
110
|
+
can_route=False, can_monitor=False):
|
|
110
111
|
if MeshSocket is None:
|
|
111
112
|
raise ImportError("MeshSocket is unavailable; run `pip install meshsocket`. "
|
|
112
113
|
"(notify_http does not need it.)")
|
|
114
|
+
# can_route lets this client SEND routed requests (route_msg); can_monitor
|
|
115
|
+
# unlocks get_nodes roster reads. Both off by default — a plain data hub
|
|
116
|
+
# needs neither; Hub turns them on to resolve and push to devices.
|
|
113
117
|
self._sock = MeshSocket(url=gateway_url, name=name, auth_token=token,
|
|
114
|
-
channel=channel, role=role, can_broadcast=True,
|
|
118
|
+
channel=channel, role=role, can_broadcast=True,
|
|
119
|
+
can_route=can_route, can_monitor=can_monitor)
|
|
115
120
|
# `room=True` matches the app's `mode: room`: a symmetric group cipher so the hub
|
|
116
121
|
# shares an encrypted room with several members. Otherwise the directional 1:1 cipher.
|
|
117
122
|
if e2ee_key:
|
|
@@ -139,6 +144,8 @@ class CarterClient:
|
|
|
139
144
|
self._is_state_authority = False
|
|
140
145
|
self._broadcast_handler = None
|
|
141
146
|
self._broadcast_registered = False
|
|
147
|
+
self._join_handler = None
|
|
148
|
+
self._ack_commands = False
|
|
142
149
|
|
|
143
150
|
def _open(self, payload):
|
|
144
151
|
if self._session and isinstance(payload, dict) and E2EESession.is_envelope(payload):
|
|
@@ -176,17 +183,68 @@ class CarterClient:
|
|
|
176
183
|
return None
|
|
177
184
|
self._sock.on("broadcast", wrapper)
|
|
178
185
|
|
|
186
|
+
#: Broadcast msg_types that are protocol plane, not app data — consumed by
|
|
187
|
+
#: _dispatch_broadcast and never handed to on_broadcast handlers. command_ack
|
|
188
|
+
#: is app-directed (a hub's reply to a phone), so hubs must not see each
|
|
189
|
+
#: other's acks as data.
|
|
190
|
+
_PROTOCOL_BROADCASTS = ("control_sync_request", "control_snapshot", "command_ack")
|
|
191
|
+
|
|
179
192
|
async def _dispatch_broadcast(self, data):
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
193
|
+
# Protocol frames are consumed here so they never reach the user's
|
|
194
|
+
# on_broadcast handler. A sync request is answered by an authority AND
|
|
195
|
+
# surfaced via on_sync_request (the join signal); snapshots/acks are for
|
|
196
|
+
# replicas (the app), not for us.
|
|
197
|
+
if isinstance(data, dict) and data.get("msg_type") in self._PROTOCOL_BROADCASTS:
|
|
198
|
+
if data.get("msg_type") == "control_sync_request":
|
|
199
|
+
if self._is_state_authority:
|
|
200
|
+
await self._answer_control_sync(data)
|
|
201
|
+
if self._join_handler is not None:
|
|
202
|
+
result = self._join_handler(data)
|
|
203
|
+
if asyncio.iscoroutine(result):
|
|
204
|
+
await result
|
|
205
|
+
return
|
|
206
|
+
if self._broadcast_handler is None:
|
|
185
207
|
return
|
|
186
|
-
|
|
208
|
+
# Ack'd-command layer (the app's layout `state.acks`): a `_cmd`-stamped frame
|
|
209
|
+
# is acknowledged only when the handler REPORTS it handled it (returns True) —
|
|
210
|
+
# a hub whose demux matched nothing must stay silent so the app times out and
|
|
211
|
+
# reverts (and so another hub on the channel can be the one that answers).
|
|
212
|
+
# ok:false on a raised exception, which still propagates unchanged.
|
|
213
|
+
cmd_id = data.get("_cmd") if (self._ack_commands and isinstance(data, dict)) else None
|
|
214
|
+
try:
|
|
187
215
|
result = self._broadcast_handler(data)
|
|
188
216
|
if asyncio.iscoroutine(result):
|
|
189
|
-
await result
|
|
217
|
+
result = await result
|
|
218
|
+
except Exception:
|
|
219
|
+
if cmd_id is not None:
|
|
220
|
+
await self.broadcast("command_ack", {
|
|
221
|
+
"cmd_id": cmd_id, "to": data.get("_from"), "ok": False})
|
|
222
|
+
raise
|
|
223
|
+
if cmd_id is not None and result is True:
|
|
224
|
+
await self.broadcast("command_ack", {
|
|
225
|
+
"cmd_id": cmd_id, "to": data.get("_from"), "ok": True})
|
|
226
|
+
|
|
227
|
+
def on_sync_request(self, handler):
|
|
228
|
+
"""Register the deterministic "a replica just joined / came back" signal: the
|
|
229
|
+
app broadcasts `control_sync_request` when a layout with synced or dynamic
|
|
230
|
+
content loads AND on every reconnect. handler(data: dict) gets the decrypted
|
|
231
|
+
frame (`{from, dynamic?: [...]}` — `dynamic` lists the layout's dynamic slot
|
|
232
|
+
events); sync or async. Use it to re-push dynamic decks and any full-state
|
|
233
|
+
snapshot a late joiner needs. (Distinct from LocalRelay.on_join, which is the
|
|
234
|
+
relay-auth join of a socket, not a layout replica asking for state.)"""
|
|
235
|
+
self._join_handler = handler
|
|
236
|
+
self._ensure_broadcast_listener()
|
|
237
|
+
|
|
238
|
+
def enable_command_acks(self):
|
|
239
|
+
"""Acknowledge `_cmd`-stamped command broadcasts (the app's opt-in ack'd
|
|
240
|
+
commands, layout `state.acks: true`) with `command_ack {cmd_id, to, ok}`.
|
|
241
|
+
The on_broadcast handler must return True for frames it actually handled —
|
|
242
|
+
only those are acked ok:true; a raised exception acks ok:false; anything
|
|
243
|
+
else gets NO ack, so the app's pending control times out and reverts (and a
|
|
244
|
+
different hub on the channel may be the one that answers). Frames without
|
|
245
|
+
`_cmd` are untouched, so servers stay compatible with plain layouts."""
|
|
246
|
+
self._ack_commands = True
|
|
247
|
+
self._ensure_broadcast_listener()
|
|
190
248
|
|
|
191
249
|
def set_control_state(self, control_id, value):
|
|
192
250
|
"""Record the authoritative current value of a control so the hub can answer a
|
|
@@ -210,7 +268,12 @@ class CarterClient:
|
|
|
210
268
|
{"to": to, "v": self._state_version, "controls": dict(self._control_state)})
|
|
211
269
|
|
|
212
270
|
async def broadcast(self, msg_type, data):
|
|
213
|
-
await self.
|
|
271
|
+
await self.broadcast_frame({**data, "msg_type": msg_type})
|
|
272
|
+
|
|
273
|
+
async def broadcast_frame(self, frame):
|
|
274
|
+
"""Broadcast a pre-assembled payload verbatim (no msg_type is forced on it) —
|
|
275
|
+
the escape hatch for frames whose shape a control's sync filter dictates."""
|
|
276
|
+
await self._sock.send("broadcast_request", self._seal(frame))
|
|
214
277
|
|
|
215
278
|
async def chat(self, text, *, name="Server", role="device", channel=None, msg_id=None, **extra):
|
|
216
279
|
"""Send a channel chat message a chat control will display, as a bubble from `name`.
|
|
@@ -277,6 +340,18 @@ class CarterClient:
|
|
|
277
340
|
pass # transient (network/5xx) — retry next interval
|
|
278
341
|
|
|
279
342
|
async def connect(self):
|
|
343
|
+
# A hub that sat stopped past its short-lived token's expiry can never
|
|
344
|
+
# identify (the relay drops it with "not admitted" forever) — when we hold
|
|
345
|
+
# a refresh credential, pre-mint a fresh token so (re)starts self-heal.
|
|
346
|
+
# A transient validator error falls through to the stored token.
|
|
347
|
+
if self._device_id and self._refresh_token and self._validator_url:
|
|
348
|
+
try:
|
|
349
|
+
await self.refresh_device_token()
|
|
350
|
+
except CarterDeviceRevoked:
|
|
351
|
+
self.revoked = True
|
|
352
|
+
raise
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
280
355
|
await self._sock.start()
|
|
281
356
|
await self._sock.wait_until_ready()
|
|
282
357
|
# Auto-start the refresh loop only for a self-refreshing external device.
|