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.
Files changed (117) hide show
  1. {carterkit-0.5.2 → carterkit-0.6.0}/PKG-INFO +66 -20
  2. {carterkit-0.5.2 → carterkit-0.6.0}/README.md +65 -19
  3. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/__init__.py +8 -1
  4. carterkit-0.6.0/carterkit/bind.py +99 -0
  5. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/buffer.py +36 -0
  6. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/catalog.py +8 -1
  7. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/cli.py +1 -1
  8. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/client.py +85 -10
  9. carterkit-0.6.0/carterkit/codegen.py +196 -0
  10. carterkit-0.6.0/carterkit/connection.py +218 -0
  11. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/accordion.md +6 -0
  12. carterkit-0.6.0/carterkit/controldocs/box-plot.md +216 -0
  13. carterkit-0.6.0/carterkit/controldocs/camera.md +189 -0
  14. carterkit-0.6.0/carterkit/controldocs/canvas.md +180 -0
  15. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/cardList.md +15 -0
  16. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/carousel.md +6 -0
  17. carterkit-0.6.0/carterkit/controldocs/chart.md +346 -0
  18. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/chat.md +36 -0
  19. carterkit-0.6.0/carterkit/controldocs/chord.md +169 -0
  20. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/color-picker.md +3 -1
  21. carterkit-0.6.0/carterkit/controldocs/compass.md +143 -0
  22. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/control-def.md +2 -2
  23. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/flip-card.md +6 -0
  24. carterkit-0.6.0/carterkit/controldocs/gantt.md +203 -0
  25. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/gauge.md +2 -0
  26. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/graph.md +145 -1
  27. carterkit-0.6.0/carterkit/controldocs/heatmap.md +262 -0
  28. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/index.md +6 -2
  29. carterkit-0.6.0/carterkit/controldocs/pie-chart.md +234 -0
  30. carterkit-0.6.0/carterkit/controldocs/pinboard.md +144 -0
  31. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/privacy.md +1 -0
  32. carterkit-0.6.0/carterkit/controldocs/publishers.md +68 -0
  33. carterkit-0.6.0/carterkit/controldocs/radar.md +201 -0
  34. carterkit-0.6.0/carterkit/controldocs/sankey.md +185 -0
  35. carterkit-0.6.0/carterkit/controldocs/sensors.md +61 -0
  36. carterkit-0.6.0/carterkit/controldocs/sortboard.md +233 -0
  37. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/sparkline.md +1 -0
  38. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/stepper.md +3 -1
  39. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/sync.md +8 -1
  40. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/text-input.md +3 -1
  41. carterkit-0.6.0/carterkit/controldocs/treemap.md +189 -0
  42. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/declare.py +6 -4
  43. carterkit-0.6.0/carterkit/hub.py +358 -0
  44. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/layout.py +107 -13
  45. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/validate.py +48 -1
  46. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/PKG-INFO +66 -20
  47. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/SOURCES.txt +20 -0
  48. {carterkit-0.5.2 → carterkit-0.6.0}/pyproject.toml +1 -1
  49. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_bind.py +28 -0
  50. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_cli.py +4 -2
  51. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_client.py +148 -0
  52. carterkit-0.6.0/tests/test_connection.py +97 -0
  53. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_declare.py +3 -2
  54. carterkit-0.6.0/tests/test_hub.py +303 -0
  55. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_ui.py +19 -3
  56. carterkit-0.6.0/tests/test_validate_bindings.py +90 -0
  57. carterkit-0.5.2/carterkit/bind.py +0 -40
  58. carterkit-0.5.2/carterkit/codegen.py +0 -188
  59. carterkit-0.5.2/tests/test_validate_bindings.py +0 -40
  60. {carterkit-0.5.2 → carterkit-0.6.0}/LICENSE +0 -0
  61. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/__main__.py +0 -0
  62. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/actions.md +0 -0
  63. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/animations.md +0 -0
  64. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/appearance.md +0 -0
  65. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/button.md +0 -0
  66. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/date-picker.md +0 -0
  67. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/divider.md +0 -0
  68. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/grid-dimensions.md +0 -0
  69. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/group-def.md +0 -0
  70. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/haptics.md +0 -0
  71. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/image.md +0 -0
  72. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/joystick.md +0 -0
  73. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/label.md +0 -0
  74. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/layout-config.md +0 -0
  75. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/list.md +0 -0
  76. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/log-console.md +0 -0
  77. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/long-press.md +0 -0
  78. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/map.md +0 -0
  79. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/picker.md +0 -0
  80. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/progress-ring.md +0 -0
  81. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/pulse.md +0 -0
  82. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/qr-code.md +0 -0
  83. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/segmented.md +0 -0
  84. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/slider.md +0 -0
  85. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/spacer.md +0 -0
  86. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/status-light.md +0 -0
  87. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/terms.md +0 -0
  88. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/theming.md +0 -0
  89. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/toggle.md +0 -0
  90. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/visibility.md +0 -0
  91. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controldocs/web-view.md +0 -0
  92. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/controls.py +0 -0
  93. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/dynamic.py +0 -0
  94. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/e2ee.py +0 -0
  95. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/grid.py +0 -0
  96. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/infer.py +0 -0
  97. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/py.typed +0 -0
  98. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/relay.py +0 -0
  99. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/theming.py +0 -0
  100. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit/tune.py +0 -0
  101. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/dependency_links.txt +0 -0
  102. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/entry_points.txt +0 -0
  103. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/requires.txt +0 -0
  104. {carterkit-0.5.2 → carterkit-0.6.0}/carterkit.egg-info/top_level.txt +0 -0
  105. {carterkit-0.5.2 → carterkit-0.6.0}/setup.cfg +0 -0
  106. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_buffer.py +0 -0
  107. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_catalog.py +0 -0
  108. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_codegen.py +0 -0
  109. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_controls.py +0 -0
  110. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_dynamic.py +0 -0
  111. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_e2ee.py +0 -0
  112. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_grid.py +0 -0
  113. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_infer.py +0 -0
  114. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_layout.py +0 -0
  115. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_theming.py +0 -0
  116. {carterkit-0.5.2 → carterkit-0.6.0}/tests/test_tune.py +0 -0
  117. {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.5.2
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", request=True)
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=`/`request=`/`payload=`
75
- build an `action`; pass `sync=[...]`/`action={...}` (via `carterkit.bind`) for anything
76
- fancier. A handle comparison (`cpu > 90`) becomes a real visibility condition; `==`/`!=`
77
- stay normal Python, so use `.eq()`/`.neq()`. `help(carterkit.build.gauge)` prints any
78
- control's documentation, straight from the bundled docs.
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", request=True)
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 MeshSocket server skeleton;
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 MeshSocket service stub
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 a device
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 CarterClient
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 CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
146
- channel="home", role="device", name="my-hub") as c:
147
- c.on("toggle", lambda d: {"ok": True, **d})
148
- await c.broadcast("reading", {"temp_c": 21.4})
149
- await asyncio.sleep(60)
150
- # leaving the `async with` disconnects automatically
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
- End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when you
156
- pass an `e2ee_key`. Send a push to every device on a Connect+ account with
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", request=True)
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=`/`request=`/`payload=`
52
- build an `action`; pass `sync=[...]`/`action={...}` (via `carterkit.bind`) for anything
53
- fancier. A handle comparison (`cpu > 90`) becomes a real visibility condition; `==`/`!=`
54
- stay normal Python, so use `.eq()`/`.neq()`. `help(carterkit.build.gauge)` prints any
55
- control's documentation, straight from the bundled docs.
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", request=True)
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 MeshSocket server skeleton;
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 MeshSocket service stub
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 a device
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 CarterClient
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 CarterClient(gateway_url="ws://localhost:18080", token="<mesh token>",
123
- channel="home", role="device", name="my-hub") as c:
124
- c.on("toggle", lambda d: {"ok": True, **d})
125
- await c.broadcast("reading", {"temp_c": 21.4})
126
- await asyncio.sleep(60)
127
- # leaving the `async with` disconnects automatically
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
- End-to-end encryption (ChaCha20-Poly1305 + per-session salt) is transparent when you
133
- pass an `e2ee_key`. Send a push to every device on a Connect+ account with
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
- - ``CarterClient`` / ``notify_http`` connect over MeshSocket, push, send alerts
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
- PLACEABLE_CATEGORIES = {"controls", "display"}
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]:
@@ -63,7 +63,7 @@ def _cmd_gen(args) -> int:
63
63
  import carterkit
64
64
  with open(args.file) as f:
65
65
  layout = json.load(f)
66
- print(carterkit.codegen.generate_service_stub(layout))
66
+ print(carterkit.codegen.generate_service_stub(layout, layout_path=args.file))
67
67
  return 0
68
68
 
69
69
 
@@ -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, can_route=False)
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
- # Control-state sync frames are protocol, not app data consume them here so they
181
- # never reach the user's on_broadcast handler. Only an authority answers a request.
182
- if isinstance(data, dict) and data.get("msg_type") in ("control_sync_request", "control_snapshot"):
183
- if self._is_state_authority and data.get("msg_type") == "control_sync_request":
184
- await self._answer_control_sync(data)
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
- if self._broadcast_handler is not None:
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._sock.send("broadcast_request", self._seal({**data, "msg_type": msg_type}))
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.