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.
Files changed (131) hide show
  1. {carterkit-0.5.2 → carterkit-0.7.0}/PKG-INFO +100 -20
  2. carterkit-0.7.0/README.md +221 -0
  3. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/__init__.py +11 -2
  4. carterkit-0.7.0/carterkit/bind.py +183 -0
  5. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/buffer.py +36 -0
  6. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/catalog.py +18 -1
  7. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/cli.py +47 -1
  8. carterkit-0.7.0/carterkit/client.py +596 -0
  9. carterkit-0.7.0/carterkit/codegen.py +198 -0
  10. carterkit-0.7.0/carterkit/connection.py +218 -0
  11. carterkit-0.7.0/carterkit/contract.py +339 -0
  12. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/accordion.md +6 -0
  13. carterkit-0.7.0/carterkit/controldocs/actions.md +92 -0
  14. carterkit-0.7.0/carterkit/controldocs/box-plot.md +216 -0
  15. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/button.md +1 -1
  16. carterkit-0.7.0/carterkit/controldocs/camera.md +189 -0
  17. carterkit-0.7.0/carterkit/controldocs/canvas.md +180 -0
  18. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/cardList.md +15 -0
  19. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/carousel.md +6 -0
  20. carterkit-0.7.0/carterkit/controldocs/chart.md +346 -0
  21. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/chat.md +68 -0
  22. carterkit-0.7.0/carterkit/controldocs/chord.md +169 -0
  23. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/color-picker.md +3 -1
  24. carterkit-0.7.0/carterkit/controldocs/compass.md +143 -0
  25. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/control-def.md +2 -2
  26. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/flip-card.md +6 -0
  27. carterkit-0.7.0/carterkit/controldocs/gantt.md +203 -0
  28. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/gauge.md +2 -0
  29. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/graph.md +145 -1
  30. carterkit-0.7.0/carterkit/controldocs/heatmap.md +262 -0
  31. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/index.md +11 -5
  32. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/layout-config.md +9 -0
  33. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/log-console.md +4 -0
  34. carterkit-0.7.0/carterkit/controldocs/pie-chart.md +234 -0
  35. carterkit-0.7.0/carterkit/controldocs/pinboard.md +144 -0
  36. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/privacy.md +1 -0
  37. carterkit-0.7.0/carterkit/controldocs/publishers.md +68 -0
  38. carterkit-0.7.0/carterkit/controldocs/radar.md +201 -0
  39. carterkit-0.7.0/carterkit/controldocs/sankey.md +185 -0
  40. carterkit-0.7.0/carterkit/controldocs/sensors.md +61 -0
  41. carterkit-0.7.0/carterkit/controldocs/sortboard.md +233 -0
  42. carterkit-0.7.0/carterkit/controldocs/sources.md +154 -0
  43. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/sparkline.md +1 -0
  44. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/stepper.md +3 -1
  45. carterkit-0.7.0/carterkit/controldocs/sync.md +115 -0
  46. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/text-input.md +3 -1
  47. carterkit-0.7.0/carterkit/controldocs/treemap.md +189 -0
  48. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/declare.py +6 -4
  49. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/e2ee.py +3 -0
  50. carterkit-0.7.0/carterkit/explore.py +351 -0
  51. carterkit-0.7.0/carterkit/explore_html.py +506 -0
  52. carterkit-0.7.0/carterkit/hub.py +382 -0
  53. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/layout.py +242 -14
  54. carterkit-0.7.0/carterkit/validate.py +433 -0
  55. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/PKG-INFO +100 -20
  56. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/SOURCES.txt +28 -0
  57. {carterkit-0.5.2 → carterkit-0.7.0}/pyproject.toml +1 -1
  58. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_bind.py +28 -0
  59. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_cli.py +4 -2
  60. carterkit-0.7.0/tests/test_client.py +609 -0
  61. carterkit-0.7.0/tests/test_connection.py +97 -0
  62. carterkit-0.7.0/tests/test_contract.py +148 -0
  63. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_declare.py +3 -2
  64. carterkit-0.7.0/tests/test_explore.py +172 -0
  65. carterkit-0.7.0/tests/test_hub.py +303 -0
  66. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_layout.py +33 -0
  67. carterkit-0.7.0/tests/test_parity_acceptance.py +101 -0
  68. carterkit-0.7.0/tests/test_parity_authoring.py +118 -0
  69. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_ui.py +19 -3
  70. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_validate.py +14 -1
  71. carterkit-0.7.0/tests/test_validate_bindings.py +90 -0
  72. carterkit-0.5.2/README.md +0 -141
  73. carterkit-0.5.2/carterkit/bind.py +0 -40
  74. carterkit-0.5.2/carterkit/client.py +0 -298
  75. carterkit-0.5.2/carterkit/codegen.py +0 -188
  76. carterkit-0.5.2/carterkit/controldocs/actions.md +0 -51
  77. carterkit-0.5.2/carterkit/controldocs/sync.md +0 -50
  78. carterkit-0.5.2/carterkit/validate.py +0 -152
  79. carterkit-0.5.2/tests/test_client.py +0 -273
  80. carterkit-0.5.2/tests/test_validate_bindings.py +0 -40
  81. {carterkit-0.5.2 → carterkit-0.7.0}/LICENSE +0 -0
  82. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/__main__.py +0 -0
  83. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/animations.md +0 -0
  84. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/appearance.md +0 -0
  85. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/date-picker.md +0 -0
  86. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/divider.md +0 -0
  87. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/grid-dimensions.md +0 -0
  88. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/group-def.md +0 -0
  89. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/haptics.md +0 -0
  90. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/image.md +0 -0
  91. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/joystick.md +0 -0
  92. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/label.md +0 -0
  93. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/list.md +0 -0
  94. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/long-press.md +0 -0
  95. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/map.md +0 -0
  96. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/picker.md +0 -0
  97. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/progress-ring.md +0 -0
  98. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/pulse.md +0 -0
  99. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/qr-code.md +0 -0
  100. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/segmented.md +0 -0
  101. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/slider.md +0 -0
  102. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/spacer.md +0 -0
  103. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/status-light.md +0 -0
  104. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/terms.md +0 -0
  105. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/theming.md +0 -0
  106. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/toggle.md +0 -0
  107. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/visibility.md +0 -0
  108. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controldocs/web-view.md +0 -0
  109. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/controls.py +0 -0
  110. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/dynamic.py +0 -0
  111. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/grid.py +0 -0
  112. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/infer.py +0 -0
  113. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/py.typed +0 -0
  114. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/relay.py +0 -0
  115. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/theming.py +0 -0
  116. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit/tune.py +0 -0
  117. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/dependency_links.txt +0 -0
  118. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/entry_points.txt +0 -0
  119. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/requires.txt +0 -0
  120. {carterkit-0.5.2 → carterkit-0.7.0}/carterkit.egg-info/top_level.txt +0 -0
  121. {carterkit-0.5.2 → carterkit-0.7.0}/setup.cfg +0 -0
  122. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_buffer.py +0 -0
  123. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_catalog.py +0 -0
  124. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_codegen.py +0 -0
  125. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_controls.py +0 -0
  126. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_dynamic.py +0 -0
  127. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_e2ee.py +0 -0
  128. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_grid.py +0 -0
  129. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_infer.py +0 -0
  130. {carterkit-0.5.2 → carterkit-0.7.0}/tests/test_theming.py +0 -0
  131. {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.5.2
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", 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=…)`.
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", request=True)
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 MeshSocket server skeleton;
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 MeshSocket service stub
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 a device
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 CarterClient
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 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
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
- 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
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
+ [![PyPI](https://img.shields.io/pypi/v/carterkit.svg)](https://pypi.org/project/carterkit/)
4
+ [![Downloads](https://static.pepy.tech/badge/carterkit)](https://pepy.tech/project/carterkit)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/carterkit.svg)](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
- - ``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
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]: