carterkit 0.5.2__tar.gz → 0.7.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.7.0}/PKG-INFO +100 -20
- carterkit-0.7.0/README.md +221 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/__init__.py +11 -2
- carterkit-0.7.0/carterkit/bind.py +183 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/buffer.py +36 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/catalog.py +18 -1
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/cli.py +47 -1
- carterkit-0.7.0/carterkit/client.py +596 -0
- carterkit-0.7.0/carterkit/codegen.py +198 -0
- carterkit-0.7.0/carterkit/connection.py +218 -0
- carterkit-0.7.0/carterkit/contract.py +339 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/accordion.md +6 -0
- carterkit-0.7.0/carterkit/controldocs/actions.md +92 -0
- carterkit-0.7.0/carterkit/controldocs/box-plot.md +216 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/button.md +1 -1
- carterkit-0.7.0/carterkit/controldocs/camera.md +189 -0
- carterkit-0.7.0/carterkit/controldocs/canvas.md +180 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/cardList.md +15 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/carousel.md +6 -0
- carterkit-0.7.0/carterkit/controldocs/chart.md +346 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/chat.md +68 -0
- carterkit-0.7.0/carterkit/controldocs/chord.md +169 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/color-picker.md +3 -1
- carterkit-0.7.0/carterkit/controldocs/compass.md +143 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/control-def.md +2 -2
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/flip-card.md +6 -0
- carterkit-0.7.0/carterkit/controldocs/gantt.md +203 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/gauge.md +2 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/graph.md +145 -1
- carterkit-0.7.0/carterkit/controldocs/heatmap.md +262 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/index.md +11 -5
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/layout-config.md +9 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/log-console.md +4 -0
- carterkit-0.7.0/carterkit/controldocs/pie-chart.md +234 -0
- carterkit-0.7.0/carterkit/controldocs/pinboard.md +144 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/privacy.md +1 -0
- carterkit-0.7.0/carterkit/controldocs/publishers.md +68 -0
- carterkit-0.7.0/carterkit/controldocs/radar.md +201 -0
- carterkit-0.7.0/carterkit/controldocs/sankey.md +185 -0
- carterkit-0.7.0/carterkit/controldocs/sensors.md +61 -0
- carterkit-0.7.0/carterkit/controldocs/sortboard.md +233 -0
- carterkit-0.7.0/carterkit/controldocs/sources.md +154 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/sparkline.md +1 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/stepper.md +3 -1
- carterkit-0.7.0/carterkit/controldocs/sync.md +115 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/text-input.md +3 -1
- carterkit-0.7.0/carterkit/controldocs/treemap.md +189 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/declare.py +6 -4
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/e2ee.py +3 -0
- carterkit-0.7.0/carterkit/explore.py +351 -0
- carterkit-0.7.0/carterkit/explore_html.py +506 -0
- carterkit-0.7.0/carterkit/hub.py +382 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/layout.py +242 -14
- carterkit-0.7.0/carterkit/validate.py +433 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/PKG-INFO +100 -20
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/SOURCES.txt +28 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/pyproject.toml +1 -1
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_bind.py +28 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_cli.py +4 -2
- carterkit-0.7.0/tests/test_client.py +609 -0
- carterkit-0.7.0/tests/test_connection.py +97 -0
- carterkit-0.7.0/tests/test_contract.py +148 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_declare.py +3 -2
- carterkit-0.7.0/tests/test_explore.py +172 -0
- carterkit-0.7.0/tests/test_hub.py +303 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_layout.py +33 -0
- carterkit-0.7.0/tests/test_parity_acceptance.py +101 -0
- carterkit-0.7.0/tests/test_parity_authoring.py +118 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_ui.py +19 -3
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_validate.py +14 -1
- carterkit-0.7.0/tests/test_validate_bindings.py +90 -0
- carterkit-0.5.2/README.md +0 -141
- carterkit-0.5.2/carterkit/bind.py +0 -40
- carterkit-0.5.2/carterkit/client.py +0 -298
- carterkit-0.5.2/carterkit/codegen.py +0 -188
- carterkit-0.5.2/carterkit/controldocs/actions.md +0 -51
- carterkit-0.5.2/carterkit/controldocs/sync.md +0 -50
- carterkit-0.5.2/carterkit/validate.py +0 -152
- carterkit-0.5.2/tests/test_client.py +0 -273
- carterkit-0.5.2/tests/test_validate_bindings.py +0 -40
- {carterkit-0.5.2 → carterkit-0.7.0}/LICENSE +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/__main__.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/animations.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/appearance.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/date-picker.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/divider.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/grid-dimensions.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/group-def.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/haptics.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/image.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/joystick.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/label.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/list.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/long-press.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/map.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/picker.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/progress-ring.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/pulse.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/qr-code.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/segmented.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/slider.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/spacer.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/status-light.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/terms.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/theming.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/toggle.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/visibility.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/web-view.md +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controls.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/dynamic.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/grid.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/infer.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/py.typed +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/relay.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/theming.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/tune.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/dependency_links.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/entry_points.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/requires.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/top_level.txt +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/setup.cfg +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_buffer.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_catalog.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_codegen.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_controls.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_dynamic.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_e2ee.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_grid.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_infer.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_theming.py +0 -0
- {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_tune.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: carterkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.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,60 @@ 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=…)`.
|
|
88
|
+
|
|
89
|
+
### Beyond MeshSocket — sources, sensors, and app-side features
|
|
90
|
+
|
|
91
|
+
A layout can drive itself off protocols you already run, or the phone's own hardware, with
|
|
92
|
+
**no server code** — the app speaks them directly:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
ui.source_mqtt("broker", "mqtt://192.168.1.10:1883") # declare a broker
|
|
96
|
+
ui.source_http("api", "http://192.168.1.5:8080", interval=5)
|
|
97
|
+
with ui.tab("Home", icon="house"):
|
|
98
|
+
ui.gauge("temp", label="Temp", min=0, max=40, sync=[bind.mqtt("home/temp")])
|
|
99
|
+
ui.toggle("fan", label="Fan", sync=[bind.mqtt("home/fan/state")],
|
|
100
|
+
action=bind.mqtt_publish("home/fan/set"))
|
|
101
|
+
ui.gauge("cpu", label="CPU", sync=[bind.http("/status", interval=5, valuePath="cpu")])
|
|
102
|
+
ui.compass("hdg", label="Heading", sensor="heading") # device sensor, no backend
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`bind.mqtt` / `bind.mqtt_publish` / `bind.http` / `bind.http_request` / `bind.sensor` build
|
|
106
|
+
the sync/action dicts; the validator checks a `source:` names a declared source and that
|
|
107
|
+
mqtt/http bindings carry a topic/path. These are marked **app-direct** in the contract, so a
|
|
108
|
+
generated `bridge.py` never tries to serve them.
|
|
109
|
+
|
|
110
|
+
Author the rest of the app's surface from Python too:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
ui.publisher("heading", interval=0.25) # stream a sensor to a hub/server
|
|
114
|
+
ui.alert(event="broadcast", value_path="temp", operator="gt", value=30,
|
|
115
|
+
title="Too hot", body="Greenhouse over 30°C") # relay-watcher push rule
|
|
116
|
+
ui.glance(hero="temp", slots=["fan"], live_activity=True) # widgets / Live Activity
|
|
117
|
+
ui.poll_group("tick", event="broadcast_request", interval=10, payload={"msg_type": "poll"})
|
|
118
|
+
ui.appearance(color_scheme="dark", show_header=True)
|
|
119
|
+
ui.dynamic_tab("inject_tab") # runtime-injected tab
|
|
120
|
+
ui.state(sync=True, authority="hub", acks=True) # device-held shared state + acks
|
|
121
|
+
```
|
|
79
122
|
|
|
80
123
|
**Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
|
|
81
124
|
from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
|
|
@@ -89,7 +132,7 @@ class Dashboard(Screen, cols=4, rows=4):
|
|
|
89
132
|
class Main(Tab, icon="gauge"):
|
|
90
133
|
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu")
|
|
91
134
|
warn = StatusLight(visible=cpu > 90)
|
|
92
|
-
refresh = Button(label="Refresh", send="refresh"
|
|
135
|
+
refresh = Button(label="Refresh", send="refresh")
|
|
93
136
|
|
|
94
137
|
Dashboard.save("dashboard.json")
|
|
95
138
|
```
|
|
@@ -121,7 +164,7 @@ Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `
|
|
|
121
164
|
over a held draft; `lay.buffer` exposes it.
|
|
122
165
|
|
|
123
166
|
`infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
|
|
124
|
-
`codegen.generate_service_stub(layout)` emits a runnable
|
|
167
|
+
`codegen.generate_service_stub(layout)` emits a runnable `Hub`-based server skeleton;
|
|
125
168
|
`theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
|
|
126
169
|
|
|
127
170
|
## CLI
|
|
@@ -131,29 +174,66 @@ carterkit catalog # list every control type
|
|
|
131
174
|
carterkit doc gauge # print a control's documentation
|
|
132
175
|
carterkit examples button # list a control's examples (--name to print one)
|
|
133
176
|
carterkit validate layout.json # lint a layout (exit 1 on errors)
|
|
134
|
-
carterkit gen layout.json # generate a
|
|
177
|
+
carterkit gen layout.json # generate a runnable Hub server stub
|
|
135
178
|
carterkit relay --port 8765 # run the bundled MeshSocket relay
|
|
136
179
|
```
|
|
137
180
|
|
|
138
|
-
## Drive
|
|
181
|
+
## Drive the layout you just built
|
|
182
|
+
|
|
183
|
+
The layout already declares every control's wire contract, so the same object that
|
|
184
|
+
authored the UI also drives it — `ctrl.push(value)` derives the broadcast from the
|
|
185
|
+
control's `sync` binding, and `@ctrl.on` derives the demux from its `action`:
|
|
139
186
|
|
|
140
187
|
```python
|
|
141
188
|
import asyncio
|
|
142
|
-
from carterkit import
|
|
189
|
+
from carterkit import Layout
|
|
190
|
+
|
|
191
|
+
with Layout("Thermostat") as ui:
|
|
192
|
+
with ui.tab("Main"):
|
|
193
|
+
temp = ui.gauge("temp", label="Temp", min=0, max=40,
|
|
194
|
+
listen="temp", when={"msg_type": "climate"})
|
|
195
|
+
target = ui.slider("target", min=10, max=30, send="set_target")
|
|
143
196
|
|
|
144
197
|
async def main():
|
|
145
|
-
async with
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await
|
|
149
|
-
|
|
150
|
-
|
|
198
|
+
async with ui.serve() as hub: # zero config: embedded LocalRelay
|
|
199
|
+
print("pair the app with:", hub.qr_json())
|
|
200
|
+
await hub.wait_for_device()
|
|
201
|
+
await hub.push_layout() # routed apply-layout; echoes what rendered
|
|
202
|
+
|
|
203
|
+
@target.on
|
|
204
|
+
async def _(data):
|
|
205
|
+
heater.set(data["value"])
|
|
206
|
+
|
|
207
|
+
while True:
|
|
208
|
+
await temp.push(read_temp())
|
|
209
|
+
await asyncio.sleep(2)
|
|
151
210
|
|
|
152
211
|
asyncio.run(main())
|
|
153
212
|
```
|
|
154
213
|
|
|
155
|
-
|
|
156
|
-
|
|
214
|
+
The same surface works cross-process off the saved JSON — the layout file is the
|
|
215
|
+
contract: `Hub("dashboard.json").push("temp", 21.5)`. Dynamic groups fill with
|
|
216
|
+
`hub.fill(group, fragment)`; the hub answers late joiners with the last pushed values
|
|
217
|
+
(control-state authority) by default.
|
|
218
|
+
|
|
219
|
+
## One connection story
|
|
220
|
+
|
|
221
|
+
`Connection.parse(...)` accepts every connection artifact in the ecosystem, and
|
|
222
|
+
`ui.connect(...)` / `ui.serve(...)` / `Hub(...)` all take it:
|
|
223
|
+
|
|
224
|
+
| You have | Pass | Works |
|
|
225
|
+
|---|---|---|
|
|
226
|
+
| nothing (LAN dev) | `ui.serve()` | embedded `LocalRelay`, QR pairing |
|
|
227
|
+
| a self-hosted relay | `"ws://192.168.1.50:8765"` (+ `token=`) | symmetric: same config for app & hub |
|
|
228
|
+
| Connect+ | the **Add Device** JSON from the app (Members → Add Device) | token self-refresh + room E2EE automatic |
|
|
229
|
+
|
|
230
|
+
The asymmetry to know: self-hosted is symmetric (one URL + shared key both sides);
|
|
231
|
+
on Connect+ the app joins with its own account while the hub holds the per-device
|
|
232
|
+
credential — which is the hub's identity, so it is never embedded into a layout.
|
|
233
|
+
|
|
234
|
+
`CarterClient` remains the lower-level client (`on`/`broadcast`/`request`).
|
|
235
|
+
End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when an
|
|
236
|
+
`e2ee_key` is present. Send a push to every device on a Connect+ account with
|
|
157
237
|
`CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
|
|
158
238
|
|
|
159
239
|
## Built on
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# carterkit
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/carterkit/)
|
|
4
|
+
[](https://pepy.tech/project/carterkit)
|
|
5
|
+
[](https://pypi.org/project/carterkit/)
|
|
6
|
+
|
|
7
|
+
Build and drive [CAR-TER](https://carterbeaudoin.net/CAR-TER) layouts from Python.
|
|
8
|
+
|
|
9
|
+
**The control docs are the library.** Every control's schema, fields, and examples
|
|
10
|
+
are parsed at runtime from the ControlDocs markdown bundled inside the package — the
|
|
11
|
+
exact same docs the CAR-TER app renders — so the catalog never drifts from the
|
|
12
|
+
definitions.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install carterkit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Explore the controls (zero config)
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import carterkit
|
|
22
|
+
|
|
23
|
+
carterkit.controls() # {type: schema} for every placeable control
|
|
24
|
+
carterkit.doc("gauge") # full parsed doc: fields, themeFields, examples
|
|
25
|
+
print(carterkit.doc_markdown("gauge")) # the rendered documentation prose
|
|
26
|
+
carterkit.examples("button") # documented example snippets
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Build a layout
|
|
30
|
+
|
|
31
|
+
Controls are **methods on the layout**, ids are positional, tabs and groups are context
|
|
32
|
+
managers, and bindings fold into kwargs. Each control method returns a **handle** you can
|
|
33
|
+
use as a binding target or patch later. Unknown control types and bad enum values raise
|
|
34
|
+
instead of silently shipping a broken layout:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from carterkit import Layout
|
|
38
|
+
|
|
39
|
+
with Layout("Dashboard", cols=4, rows=4) as ui:
|
|
40
|
+
ui.connect("ws://192.168.1.50:8765", channel="home")
|
|
41
|
+
with ui.tab("Main", icon="gauge"):
|
|
42
|
+
cpu = ui.gauge("cpu", label="CPU", min=0, max=100, span=(2, 2),
|
|
43
|
+
listen="cpu", when={"msg_type": "metrics"})
|
|
44
|
+
ui.status_light("warn", visible=cpu > 90) # handle → visibility condition
|
|
45
|
+
ui.button("refresh", label="Refresh", send="refresh")
|
|
46
|
+
|
|
47
|
+
print(ui.findings()) # schema + grid + binding lint against the bundled catalog
|
|
48
|
+
ui.save("dashboard.json") # the composed layout, ready to push/load
|
|
49
|
+
```
|
|
50
|
+
|
|
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=…)`.
|
|
65
|
+
|
|
66
|
+
### Beyond MeshSocket — sources, sensors, and app-side features
|
|
67
|
+
|
|
68
|
+
A layout can drive itself off protocols you already run, or the phone's own hardware, with
|
|
69
|
+
**no server code** — the app speaks them directly:
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
ui.source_mqtt("broker", "mqtt://192.168.1.10:1883") # declare a broker
|
|
73
|
+
ui.source_http("api", "http://192.168.1.5:8080", interval=5)
|
|
74
|
+
with ui.tab("Home", icon="house"):
|
|
75
|
+
ui.gauge("temp", label="Temp", min=0, max=40, sync=[bind.mqtt("home/temp")])
|
|
76
|
+
ui.toggle("fan", label="Fan", sync=[bind.mqtt("home/fan/state")],
|
|
77
|
+
action=bind.mqtt_publish("home/fan/set"))
|
|
78
|
+
ui.gauge("cpu", label="CPU", sync=[bind.http("/status", interval=5, valuePath="cpu")])
|
|
79
|
+
ui.compass("hdg", label="Heading", sensor="heading") # device sensor, no backend
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`bind.mqtt` / `bind.mqtt_publish` / `bind.http` / `bind.http_request` / `bind.sensor` build
|
|
83
|
+
the sync/action dicts; the validator checks a `source:` names a declared source and that
|
|
84
|
+
mqtt/http bindings carry a topic/path. These are marked **app-direct** in the contract, so a
|
|
85
|
+
generated `bridge.py` never tries to serve them.
|
|
86
|
+
|
|
87
|
+
Author the rest of the app's surface from Python too:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
ui.publisher("heading", interval=0.25) # stream a sensor to a hub/server
|
|
91
|
+
ui.alert(event="broadcast", value_path="temp", operator="gt", value=30,
|
|
92
|
+
title="Too hot", body="Greenhouse over 30°C") # relay-watcher push rule
|
|
93
|
+
ui.glance(hero="temp", slots=["fan"], live_activity=True) # widgets / Live Activity
|
|
94
|
+
ui.poll_group("tick", event="broadcast_request", interval=10, payload={"msg_type": "poll"})
|
|
95
|
+
ui.appearance(color_scheme="dark", show_header=True)
|
|
96
|
+
ui.dynamic_tab("inject_tab") # runtime-injected tab
|
|
97
|
+
ui.state(sync=True, authority="hub", acks=True) # device-held shared state + acks
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
|
|
101
|
+
from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
|
|
102
|
+
builder reads better for generated ones):
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
from carterkit.declare import Screen, Tab, Connect, Gauge, Button, StatusLight
|
|
106
|
+
|
|
107
|
+
class Dashboard(Screen, cols=4, rows=4):
|
|
108
|
+
relay = Connect("ws://192.168.1.50:8765", channel="home")
|
|
109
|
+
class Main(Tab, icon="gauge"):
|
|
110
|
+
cpu = Gauge(label="CPU", min=0, max=100, span=(2, 2), listen="cpu")
|
|
111
|
+
warn = StatusLight(visible=cpu > 90)
|
|
112
|
+
refresh = Button(label="Refresh", send="refresh")
|
|
113
|
+
|
|
114
|
+
Dashboard.save("dashboard.json")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Dynamic groups
|
|
118
|
+
|
|
119
|
+
Generate controls in `for`/`if` loops (auto-placed in the group's own grid), or mark a
|
|
120
|
+
group `dynamic="event"` and replace its children live at runtime. Build that replacement
|
|
121
|
+
payload with `Fragment`, then lint it against the broadcasts your server actually emits —
|
|
122
|
+
catching events that never arrive, missing `children` arrays, and off-grid/invalid
|
|
123
|
+
injected controls before they ship:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import carterkit
|
|
127
|
+
from carterkit import Fragment
|
|
128
|
+
|
|
129
|
+
ui.group("Now Playing", span=(3, 4), cols=4, rows=3, dynamic="player_state")
|
|
130
|
+
|
|
131
|
+
frag = Fragment(cols=4, rows=3)
|
|
132
|
+
frag.label("title", text="Song", span=(1, 4))
|
|
133
|
+
frag.button("play", label="Play", send="play")
|
|
134
|
+
# your server broadcasts frag.payload("player_state") == {"msg_type": ..., "children": [...]}
|
|
135
|
+
|
|
136
|
+
print(carterkit.format_findings(
|
|
137
|
+
carterkit.lint_dynamic_traffic(ui.layout, [frag.payload("player_state")])))
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Prefer surgical edits? `LayoutBuffer` gives `add_control` / `update_control` / `move_control`
|
|
141
|
+
over a held draft; `lay.buffer` exposes it.
|
|
142
|
+
|
|
143
|
+
`infer.build_layout(payload)` generates a wired layout from a sample telemetry dict;
|
|
144
|
+
`codegen.generate_service_stub(layout)` emits a runnable `Hub`-based server skeleton;
|
|
145
|
+
`theming.theme_for(...)` and `tune.tune_gauge(...)` round out the authoring tools.
|
|
146
|
+
|
|
147
|
+
## CLI
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
carterkit catalog # list every control type
|
|
151
|
+
carterkit doc gauge # print a control's documentation
|
|
152
|
+
carterkit examples button # list a control's examples (--name to print one)
|
|
153
|
+
carterkit validate layout.json # lint a layout (exit 1 on errors)
|
|
154
|
+
carterkit gen layout.json # generate a runnable Hub server stub
|
|
155
|
+
carterkit relay --port 8765 # run the bundled MeshSocket relay
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## Drive the layout you just built
|
|
159
|
+
|
|
160
|
+
The layout already declares every control's wire contract, so the same object that
|
|
161
|
+
authored the UI also drives it — `ctrl.push(value)` derives the broadcast from the
|
|
162
|
+
control's `sync` binding, and `@ctrl.on` derives the demux from its `action`:
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
import asyncio
|
|
166
|
+
from carterkit import Layout
|
|
167
|
+
|
|
168
|
+
with Layout("Thermostat") as ui:
|
|
169
|
+
with ui.tab("Main"):
|
|
170
|
+
temp = ui.gauge("temp", label="Temp", min=0, max=40,
|
|
171
|
+
listen="temp", when={"msg_type": "climate"})
|
|
172
|
+
target = ui.slider("target", min=10, max=30, send="set_target")
|
|
173
|
+
|
|
174
|
+
async def main():
|
|
175
|
+
async with ui.serve() as hub: # zero config: embedded LocalRelay
|
|
176
|
+
print("pair the app with:", hub.qr_json())
|
|
177
|
+
await hub.wait_for_device()
|
|
178
|
+
await hub.push_layout() # routed apply-layout; echoes what rendered
|
|
179
|
+
|
|
180
|
+
@target.on
|
|
181
|
+
async def _(data):
|
|
182
|
+
heater.set(data["value"])
|
|
183
|
+
|
|
184
|
+
while True:
|
|
185
|
+
await temp.push(read_temp())
|
|
186
|
+
await asyncio.sleep(2)
|
|
187
|
+
|
|
188
|
+
asyncio.run(main())
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The same surface works cross-process off the saved JSON — the layout file is the
|
|
192
|
+
contract: `Hub("dashboard.json").push("temp", 21.5)`. Dynamic groups fill with
|
|
193
|
+
`hub.fill(group, fragment)`; the hub answers late joiners with the last pushed values
|
|
194
|
+
(control-state authority) by default.
|
|
195
|
+
|
|
196
|
+
## One connection story
|
|
197
|
+
|
|
198
|
+
`Connection.parse(...)` accepts every connection artifact in the ecosystem, and
|
|
199
|
+
`ui.connect(...)` / `ui.serve(...)` / `Hub(...)` all take it:
|
|
200
|
+
|
|
201
|
+
| You have | Pass | Works |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| nothing (LAN dev) | `ui.serve()` | embedded `LocalRelay`, QR pairing |
|
|
204
|
+
| a self-hosted relay | `"ws://192.168.1.50:8765"` (+ `token=`) | symmetric: same config for app & hub |
|
|
205
|
+
| Connect+ | the **Add Device** JSON from the app (Members → Add Device) | token self-refresh + room E2EE automatic |
|
|
206
|
+
|
|
207
|
+
The asymmetry to know: self-hosted is symmetric (one URL + shared key both sides);
|
|
208
|
+
on Connect+ the app joins with its own account while the hub holds the per-device
|
|
209
|
+
credential — which is the hub's identity, so it is never embedded into a layout.
|
|
210
|
+
|
|
211
|
+
`CarterClient` remains the lower-level client (`on`/`broadcast`/`request`).
|
|
212
|
+
End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when an
|
|
213
|
+
`e2ee_key` is present. Send a push to every device on a Connect+ account with
|
|
214
|
+
`CarterClient.notify(...)` or the stdlib-only `carterkit.notify_http(...)`.
|
|
215
|
+
|
|
216
|
+
## Built on
|
|
217
|
+
|
|
218
|
+
[`meshsocket`](https://pypi.org/project/meshsocket/) — the WebSocket mesh transport.
|
|
219
|
+
|
|
220
|
+
The ControlDocs are vendored from the CAR-TER app repo; refresh them with
|
|
221
|
+
`scripts/sync-controldocs.sh`.
|
|
@@ -13,12 +13,17 @@ 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
|
|
20
24
|
|
|
21
|
-
from . import catalog, grid, codegen, infer, theming, tune, dynamic
|
|
25
|
+
from . import catalog, grid, codegen, infer, theming, tune, dynamic, contract
|
|
26
|
+
from .contract import extract_contract
|
|
22
27
|
from .buffer import LayoutBuffer, BufferError
|
|
23
28
|
from .validate import validate_layout as _validate_layout, format_findings
|
|
24
29
|
from .client import (CarterClient, notify_http, CarterNotifyError,
|
|
@@ -27,6 +32,8 @@ from .relay import LocalRelay, port_in_use, lan_ip
|
|
|
27
32
|
from . import bind
|
|
28
33
|
from .controls import build, control
|
|
29
34
|
from .layout import Layout, Fragment, Control, Condition
|
|
35
|
+
from .connection import Connection
|
|
36
|
+
from .hub import Hub, HubError
|
|
30
37
|
|
|
31
38
|
try:
|
|
32
39
|
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
@@ -85,5 +92,7 @@ __all__ = [
|
|
|
85
92
|
"controls", "doc", "doc_markdown", "examples", "validate_layout",
|
|
86
93
|
"lint_dynamic_traffic", "format_findings", "controldocs_dir",
|
|
87
94
|
"build", "control", "bind", "Layout", "Fragment", "Control", "Condition",
|
|
95
|
+
"Connection", "Hub", "HubError",
|
|
88
96
|
"catalog", "grid", "codegen", "infer", "theming", "tune", "dynamic",
|
|
97
|
+
"contract", "extract_contract",
|
|
89
98
|
]
|
|
@@ -0,0 +1,183 @@
|
|
|
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
|
+
#: Sensor pipelines the app can bind (see sensors.md). Dotted keys pick a field
|
|
73
|
+
#: (`motion.roll`); a bare name is shorthand for its `.value`.
|
|
74
|
+
SENSOR_PIPELINES = ("heading", "motion", "barometer", "device", "audio", "location")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def sensor(name: str, *, value_path: str | None = None) -> dict:
|
|
78
|
+
"""A `sync` entry bound to a device sensor — no backend needed (the app streams
|
|
79
|
+
the reading locally). `name` is a pipeline or dotted key (`"heading"`,
|
|
80
|
+
`"motion.roll"`, `"device.battery"`). See sensors.md."""
|
|
81
|
+
s: dict = {"method": "sensor", "sensor": name}
|
|
82
|
+
if value_path is not None:
|
|
83
|
+
s["valuePath"] = value_path
|
|
84
|
+
return s
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def mqtt(topic: str, *, value_path: str = "", filter: dict | None = None,
|
|
88
|
+
source: str | None = None) -> dict:
|
|
89
|
+
"""A `sync` entry that subscribes an MQTT topic (see sources.md). `value_path`
|
|
90
|
+
extracts from a JSON payload (leave empty for bare number/bool/string payloads);
|
|
91
|
+
`source` names the declared broker (omit when the layout has exactly one)."""
|
|
92
|
+
s: dict = {"method": "mqtt", "topic": topic}
|
|
93
|
+
if value_path:
|
|
94
|
+
s["valuePath"] = value_path
|
|
95
|
+
if filter is not None:
|
|
96
|
+
s["filter"] = filter
|
|
97
|
+
if source is not None:
|
|
98
|
+
s["source"] = source
|
|
99
|
+
return s
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def mqtt_publish(topic: str, *, payload="{{value}}", retain: bool | None = None,
|
|
103
|
+
source: str | None = None) -> dict:
|
|
104
|
+
"""An `action` that publishes to an MQTT topic. String payloads publish raw bytes
|
|
105
|
+
(`ON`, not `"ON"`); objects publish as JSON. `{{value}}` rides the control value."""
|
|
106
|
+
a: dict = {"method": "mqtt", "topic": topic, "payload": payload}
|
|
107
|
+
if retain is not None:
|
|
108
|
+
a["retain"] = retain
|
|
109
|
+
if source is not None:
|
|
110
|
+
a["source"] = source
|
|
111
|
+
return a
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def http(path: str | None = None, *, url: str | None = None, interval: float | None = None,
|
|
115
|
+
value_path: str = "", filter: dict | None = None, source: str | None = None) -> dict:
|
|
116
|
+
"""A `sync` entry that polls an HTTP endpoint (see sources.md). Give `path`
|
|
117
|
+
(against a source's baseURL) or an absolute `url`; `interval` is seconds."""
|
|
118
|
+
if not path and not url:
|
|
119
|
+
raise ValueError("http() needs a 'path' (against a source baseURL) or an absolute 'url'")
|
|
120
|
+
s: dict = {"method": "http"}
|
|
121
|
+
if path:
|
|
122
|
+
s["path"] = path
|
|
123
|
+
if url:
|
|
124
|
+
s["url"] = url
|
|
125
|
+
if interval is not None:
|
|
126
|
+
s["interval"] = interval
|
|
127
|
+
if value_path:
|
|
128
|
+
s["valuePath"] = value_path
|
|
129
|
+
if filter is not None:
|
|
130
|
+
s["filter"] = filter
|
|
131
|
+
if source is not None:
|
|
132
|
+
s["source"] = source
|
|
133
|
+
return s
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def http_request(path: str | None = None, *, url: str | None = None,
|
|
137
|
+
http_method: str | None = None, payload=None, source: str | None = None) -> dict:
|
|
138
|
+
"""An `action` that fires an HTTP request. `http_method` defaults to POST when a
|
|
139
|
+
payload is present, else GET. Object payloads send as JSON, strings as text."""
|
|
140
|
+
if not path and not url:
|
|
141
|
+
raise ValueError("http_request() needs a 'path' or an absolute 'url'")
|
|
142
|
+
a: dict = {"method": "http"}
|
|
143
|
+
if path:
|
|
144
|
+
a["path"] = path
|
|
145
|
+
if url:
|
|
146
|
+
a["url"] = url
|
|
147
|
+
if http_method is not None:
|
|
148
|
+
a["httpMethod"] = http_method
|
|
149
|
+
if payload is not None:
|
|
150
|
+
a["payload"] = payload
|
|
151
|
+
if source is not None:
|
|
152
|
+
a["source"] = source
|
|
153
|
+
return a
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def connection(url: str | None, *, channel: str = "home", name: str = "CAR-TER",
|
|
157
|
+
role: str = "controller", token: str | None = None,
|
|
158
|
+
hub: str | None = None, mode: str | None = None,
|
|
159
|
+
e2ee_key: str | None = None, can_broadcast: bool | None = None) -> dict:
|
|
160
|
+
"""A layout `connection` block (relay URL + identity). `hub` names the mesh
|
|
161
|
+
identity of the server that will drive this layout (`Layout.serve` adopts it),
|
|
162
|
+
so both sides of the contract live in one artifact.
|
|
163
|
+
|
|
164
|
+
`mode="room"` + `e2ee_key` (base64 group key, emitted as `e2eeKey`) author the
|
|
165
|
+
Connect+ room shape — see `carterkit.connection.Connection.layout_block`, whose
|
|
166
|
+
output this mirrors. In a room, `url=None` omits the URL entirely: the app dials
|
|
167
|
+
its own Connect+ relay with its own account session, and a raw url/token here
|
|
168
|
+
would make it hand the wrong credential to the wrong host (reconnect loop)."""
|
|
169
|
+
conn: dict = {"identity": {"name": name, "channel": channel, "role": role}}
|
|
170
|
+
if url is not None:
|
|
171
|
+
conn["url"] = url
|
|
172
|
+
if token is not None:
|
|
173
|
+
conn["token"] = token
|
|
174
|
+
if hub is not None:
|
|
175
|
+
conn["hub"] = hub
|
|
176
|
+
if e2ee_key is not None:
|
|
177
|
+
conn["mode"] = mode or "room"
|
|
178
|
+
conn["e2eeKey"] = e2ee_key
|
|
179
|
+
elif mode is not None:
|
|
180
|
+
conn["mode"] = mode
|
|
181
|
+
if can_broadcast is not None:
|
|
182
|
+
conn["identity"]["can_broadcast"] = can_broadcast
|
|
183
|
+
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]:
|