carterkit 0.6.0__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 (125) hide show
  1. {carterkit-0.6.0 → carterkit-0.7.0}/PKG-INFO +35 -1
  2. {carterkit-0.6.0 → carterkit-0.7.0}/README.md +34 -0
  3. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/__init__.py +3 -1
  4. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/bind.py +84 -0
  5. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/catalog.py +11 -1
  6. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/cli.py +46 -0
  7. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/client.py +232 -9
  8. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/codegen.py +2 -0
  9. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/connection.py +2 -2
  10. carterkit-0.7.0/carterkit/contract.py +339 -0
  11. carterkit-0.7.0/carterkit/controldocs/actions.md +92 -0
  12. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/button.md +1 -1
  13. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/chat.md +32 -0
  14. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/index.md +5 -3
  15. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/layout-config.md +9 -0
  16. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/log-console.md +4 -0
  17. carterkit-0.7.0/carterkit/controldocs/sources.md +154 -0
  18. carterkit-0.7.0/carterkit/controldocs/sync.md +115 -0
  19. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/e2ee.py +3 -0
  20. carterkit-0.7.0/carterkit/explore.py +351 -0
  21. carterkit-0.7.0/carterkit/explore_html.py +506 -0
  22. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/hub.py +28 -4
  23. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/layout.py +135 -1
  24. carterkit-0.7.0/carterkit/validate.py +433 -0
  25. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/PKG-INFO +35 -1
  26. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/SOURCES.txt +8 -0
  27. {carterkit-0.6.0 → carterkit-0.7.0}/pyproject.toml +1 -1
  28. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_client.py +188 -0
  29. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_connection.py +1 -1
  30. carterkit-0.7.0/tests/test_contract.py +148 -0
  31. carterkit-0.7.0/tests/test_explore.py +172 -0
  32. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_layout.py +33 -0
  33. carterkit-0.7.0/tests/test_parity_acceptance.py +101 -0
  34. carterkit-0.7.0/tests/test_parity_authoring.py +118 -0
  35. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_validate.py +14 -1
  36. carterkit-0.6.0/carterkit/controldocs/actions.md +0 -51
  37. carterkit-0.6.0/carterkit/controldocs/sync.md +0 -57
  38. carterkit-0.6.0/carterkit/validate.py +0 -199
  39. {carterkit-0.6.0 → carterkit-0.7.0}/LICENSE +0 -0
  40. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/__main__.py +0 -0
  41. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/buffer.py +0 -0
  42. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/accordion.md +0 -0
  43. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/animations.md +0 -0
  44. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/appearance.md +0 -0
  45. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/box-plot.md +0 -0
  46. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/camera.md +0 -0
  47. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/canvas.md +0 -0
  48. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/cardList.md +0 -0
  49. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/carousel.md +0 -0
  50. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/chart.md +0 -0
  51. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/chord.md +0 -0
  52. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/color-picker.md +0 -0
  53. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/compass.md +0 -0
  54. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/control-def.md +0 -0
  55. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/date-picker.md +0 -0
  56. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/divider.md +0 -0
  57. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/flip-card.md +0 -0
  58. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/gantt.md +0 -0
  59. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/gauge.md +0 -0
  60. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/graph.md +0 -0
  61. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/grid-dimensions.md +0 -0
  62. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/group-def.md +0 -0
  63. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/haptics.md +0 -0
  64. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/heatmap.md +0 -0
  65. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/image.md +0 -0
  66. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/joystick.md +0 -0
  67. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/label.md +0 -0
  68. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/list.md +0 -0
  69. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/long-press.md +0 -0
  70. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/map.md +0 -0
  71. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/picker.md +0 -0
  72. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/pie-chart.md +0 -0
  73. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/pinboard.md +0 -0
  74. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/privacy.md +0 -0
  75. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/progress-ring.md +0 -0
  76. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/publishers.md +0 -0
  77. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/pulse.md +0 -0
  78. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/qr-code.md +0 -0
  79. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/radar.md +0 -0
  80. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sankey.md +0 -0
  81. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/segmented.md +0 -0
  82. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sensors.md +0 -0
  83. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/slider.md +0 -0
  84. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sortboard.md +0 -0
  85. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/spacer.md +0 -0
  86. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sparkline.md +0 -0
  87. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/status-light.md +0 -0
  88. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/stepper.md +0 -0
  89. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/terms.md +0 -0
  90. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/text-input.md +0 -0
  91. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/theming.md +0 -0
  92. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/toggle.md +0 -0
  93. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/treemap.md +0 -0
  94. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/visibility.md +0 -0
  95. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/web-view.md +0 -0
  96. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controls.py +0 -0
  97. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/declare.py +0 -0
  98. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/dynamic.py +0 -0
  99. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/grid.py +0 -0
  100. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/infer.py +0 -0
  101. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/py.typed +0 -0
  102. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/relay.py +0 -0
  103. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/theming.py +0 -0
  104. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/tune.py +0 -0
  105. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/dependency_links.txt +0 -0
  106. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/entry_points.txt +0 -0
  107. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/requires.txt +0 -0
  108. {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/top_level.txt +0 -0
  109. {carterkit-0.6.0 → carterkit-0.7.0}/setup.cfg +0 -0
  110. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_bind.py +0 -0
  111. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_buffer.py +0 -0
  112. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_catalog.py +0 -0
  113. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_cli.py +0 -0
  114. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_codegen.py +0 -0
  115. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_controls.py +0 -0
  116. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_declare.py +0 -0
  117. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_dynamic.py +0 -0
  118. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_e2ee.py +0 -0
  119. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_grid.py +0 -0
  120. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_hub.py +0 -0
  121. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_infer.py +0 -0
  122. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_theming.py +0 -0
  123. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_tune.py +0 -0
  124. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_ui.py +0 -0
  125. {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_validate_bindings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: carterkit
3
- Version: 0.6.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
@@ -86,6 +86,40 @@ documentation, straight from the bundled docs.
86
86
  > the same either way. Grid size (`cols`/`rows`) set on `Layout(...)` is the default for
87
87
  > every tab; override it per tab with `ui.tab("Name", rows=…)`.
88
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
+ ```
122
+
89
123
  **Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
90
124
  from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
91
125
  builder reads better for generated ones):
@@ -63,6 +63,40 @@ documentation, straight from the bundled docs.
63
63
  > the same either way. Grid size (`cols`/`rows`) set on `Layout(...)` is the default for
64
64
  > every tab; override it per tab with `ui.tab("Name", rows=…)`.
65
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
+
66
100
  **Prefer a declarative style?** A class veneer compiles to the *same* layout — ids come
67
101
  from attribute names, tabs/groups are nested classes (great for fixed dashboards; the flat
68
102
  builder reads better for generated ones):
@@ -22,7 +22,8 @@ Quick map:
22
22
  from importlib.resources import files
23
23
  from pathlib import Path
24
24
 
25
- 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
26
27
  from .buffer import LayoutBuffer, BufferError
27
28
  from .validate import validate_layout as _validate_layout, format_findings
28
29
  from .client import (CarterClient, notify_http, CarterNotifyError,
@@ -93,4 +94,5 @@ __all__ = [
93
94
  "build", "control", "bind", "Layout", "Fragment", "Control", "Condition",
94
95
  "Connection", "Hub", "HubError",
95
96
  "catalog", "grid", "codegen", "infer", "theming", "tune", "dynamic",
97
+ "contract", "extract_contract",
96
98
  ]
@@ -69,6 +69,90 @@ def command(name: str, *, payload: dict | None = None, method: str = "meshsocket
69
69
  "payload": body}
70
70
 
71
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
+
72
156
  def connection(url: str | None, *, channel: str = "home", name: str = "CAR-TER",
73
157
  role: str = "controller", token: str | None = None,
74
158
  hub: str | None = None, mode: str | None = None,
@@ -141,6 +141,16 @@ def parse_doc(content: str, node_id: str) -> Optional[dict]:
141
141
 
142
142
  if not meta["type"] or not meta["label"]:
143
143
  return None
144
+ # Fields tagged `group: fooConfig` live nested under that key in the JSON
145
+ # (the app decodes them as a config object). Synthesize the umbrella field
146
+ # when the doc doesn't declare it explicitly (heatmap does; the drag pack
147
+ # doesn't), so validators accept the nested key authors actually write.
148
+ declared = {f.get("name") for f in meta["fields"]}
149
+ for grp in dict.fromkeys(f["group"] for f in meta["fields"] if f.get("group")):
150
+ if grp not in declared:
151
+ meta["fields"].append(
152
+ {"name": grp, "type": "object",
153
+ "description": "grouped config object (see grouped fields)"})
144
154
  meta["body"] = body
145
155
  meta["examples"] = extract_examples(body)
146
156
  return meta
@@ -204,7 +214,7 @@ def extract_examples(body: str) -> list[dict]:
204
214
  # "layout" covers the structural placeables (divider, spacer) — real grid
205
215
  # citizens the app renders, previously missing from the catalog so typed
206
216
  # builders rejected them and servers had to inject raw dicts.
207
- PLACEABLE_CATEGORIES = {"controls", "display", "layout"}
217
+ PLACEABLE_CATEGORIES = {"controls", "display", "layout", "input"}
208
218
 
209
219
 
210
220
  def parse_all(docs_dir) -> dict[str, dict]:
@@ -67,6 +67,35 @@ def _cmd_gen(args) -> int:
67
67
  return 0
68
68
 
69
69
 
70
+ def _cmd_explore(args) -> int:
71
+ import asyncio
72
+ import webbrowser
73
+ from .explore import build_explorer
74
+
75
+ overrides = {k: v for k, v in (("channel", args.channel), ("token", args.token))
76
+ if v is not None}
77
+ explorer = build_explorer(args.source, device=args.device, port=args.port,
78
+ **overrides)
79
+
80
+ def ready(ex):
81
+ url = f"http://127.0.0.1:{ex.port}"
82
+ print(f"Layout Link → {url}")
83
+ conn = ex.hub.connection
84
+ if conn.kind in ("local", "selfhosted"):
85
+ print(f"pair the phone (CAR-TER → Settings → Studio Session → scan):")
86
+ print(f" {ex.hub.qr_json()}")
87
+ if ex.pull is not None and ex.hub.layout is None:
88
+ print("waiting for the phone to join — the layout appears the moment it does")
89
+ if not args.no_open:
90
+ webbrowser.open(url)
91
+
92
+ try:
93
+ asyncio.run(explorer.run(ready=ready))
94
+ except KeyboardInterrupt:
95
+ print("\nbye")
96
+ return 0
97
+
98
+
70
99
  def _cmd_relay(args) -> int:
71
100
  import asyncio
72
101
  from socket_server import MeshServer # bundled with meshsocket
@@ -108,6 +137,23 @@ def build_parser() -> argparse.ArgumentParser:
108
137
  c.add_argument("file")
109
138
  c.set_defaults(fn=_cmd_gen)
110
139
 
140
+ c = sub.add_parser(
141
+ "explore",
142
+ help="serve a live, typed endpoint explorer for a layout (Layout Link)")
143
+ c.add_argument("source", nargs="?",
144
+ help="a layout JSON to explore offline, a pairing/device JSON, "
145
+ "or a ws:// relay URL; omit for the zero-config local relay")
146
+ c.add_argument("--device", nargs="?", const="current", default=None,
147
+ metavar="FILE_OR_NAME",
148
+ help="pull a layout off the paired phone: a saved layout's "
149
+ "file/name, or (bare) whatever is live right now")
150
+ c.add_argument("--channel", help="mesh channel to join")
151
+ c.add_argument("--token", help="relay auth token / shared key")
152
+ c.add_argument("--port", type=int, default=8770, help="explorer web port")
153
+ c.add_argument("--no-open", action="store_true",
154
+ help="don't auto-open the browser")
155
+ c.set_defaults(fn=_cmd_explore)
156
+
111
157
  c = sub.add_parser("relay", help="run the bundled MeshSocket relay")
112
158
  c.add_argument("--host", default="0.0.0.0")
113
159
  c.add_argument("--port", type=int, default=8765)
@@ -32,27 +32,159 @@ class CarterNotifyError(Exception):
32
32
  self.detail = detail
33
33
 
34
34
 
35
- def notify_http(validator_url, session_jwt, title, body, *, channel=None, category=None,
36
- badge=None, sound="default", data=None, _send=None):
35
+ #: interruption-levels the relay accepts; "critical" needs Apple approval and is
36
+ #: rejected server-side, so fail fast here with the same story.
37
+ _INTERRUPTION_LEVELS = ("passive", "active", "time-sensitive")
38
+
39
+
40
+ def _normalize_sender(sender):
41
+ """Accept `"Monroe"`, `("Monroe", avatar_url)`, or `{"name": ..., "avatarURL"/
42
+ "avatar_url"/"avatar": ...}` → the wire `{"name", "avatarURL"?}` dict."""
43
+ if sender is None:
44
+ return None
45
+ if isinstance(sender, str):
46
+ out = {"name": sender}
47
+ elif isinstance(sender, (tuple, list)):
48
+ if not sender or len(sender) > 2:
49
+ raise ValueError("sender tuple must be (name,) or (name, avatar_url)")
50
+ out = {"name": sender[0]}
51
+ if len(sender) == 2 and sender[1]:
52
+ out["avatarURL"] = sender[1]
53
+ elif isinstance(sender, dict):
54
+ out = {"name": sender.get("name")}
55
+ avatar = sender.get("avatarURL") or sender.get("avatar_url") or sender.get("avatar")
56
+ if avatar:
57
+ out["avatarURL"] = avatar
58
+ else:
59
+ raise ValueError("sender must be a name, (name, avatar_url), or dict")
60
+ if not out.get("name") or len(out["name"]) > 64:
61
+ raise ValueError("sender name must be non-empty and <= 64 chars")
62
+ if len(out.get("avatarURL", "")) > 512:
63
+ raise ValueError("sender avatar URL must be <= 512 chars")
64
+ return out
65
+
66
+
67
+ def _normalize_actions(actions):
68
+ """Accept the dict form `{"ack": "Acknowledge"}` / `{"ack": ("Acknowledge", fn)}` /
69
+ `{"ack": {"name"/"title": ..., "func": fn, "destructive": bool}}` or a wire-style
70
+ list of dicts → `(wire_actions, callbacks)` where callbacks maps action id → fn
71
+ (empty when none were given)."""
72
+ if actions is None:
73
+ return None, {}
74
+ wire, callbacks = [], {}
75
+ if isinstance(actions, dict):
76
+ items = []
77
+ for aid, spec in actions.items():
78
+ if isinstance(spec, str):
79
+ items.append({"id": aid, "title": spec})
80
+ elif callable(spec):
81
+ items.append({"id": aid, "title": aid})
82
+ callbacks[aid] = spec
83
+ elif isinstance(spec, (tuple, list)) and len(spec) == 2:
84
+ items.append({"id": aid, "title": spec[0]})
85
+ callbacks[aid] = spec[1]
86
+ elif isinstance(spec, dict):
87
+ item = {"id": aid, "title": spec.get("title") or spec.get("name") or aid}
88
+ if spec.get("destructive"):
89
+ item["destructive"] = True
90
+ fn = spec.get("func") or spec.get("funct") or spec.get("callback")
91
+ if fn is not None:
92
+ callbacks[aid] = fn
93
+ items.append(item)
94
+ else:
95
+ raise ValueError(f"action {aid!r}: expected title, callable, (title, fn), or dict")
96
+ wire = items
97
+ elif isinstance(actions, (list, tuple)):
98
+ for a in actions:
99
+ if not isinstance(a, dict) or not a.get("id") or not a.get("title"):
100
+ raise ValueError("wire-style actions need dicts with id and title")
101
+ item = {"id": a["id"], "title": a["title"]}
102
+ if a.get("destructive"):
103
+ item["destructive"] = True
104
+ fn = a.get("func") or a.get("callback")
105
+ if fn is not None:
106
+ callbacks[a["id"]] = fn
107
+ wire.append(item)
108
+ else:
109
+ raise ValueError("actions must be a dict or a list of dicts")
110
+ if len(wire) > 4:
111
+ raise ValueError("at most 4 actions per notification")
112
+ for a in wire:
113
+ if len(a["id"]) > 64 or len(a["title"]) > 48:
114
+ raise ValueError(f"action {a['id']!r}: id <= 64 and title <= 48 chars")
115
+ return wire, callbacks
116
+
117
+
118
+ def notify_http(validator_url, session_jwt, title, body, *, subtitle=None, channel=None,
119
+ category=None, badge=None, sound="default", interruption=None,
120
+ relevance=None, thread_id=None, image=None, sender=None, actions=None,
121
+ notif_id=None, data=None, _send=None):
37
122
  """Send a one-shot push to every device on the account (POST /alerts/notify).
38
123
 
39
124
  Stdlib-only. `validator_url` is the Connect+ validator base URL; `session_jwt` is the
40
125
  Connect+ account session token (NOT the MeshSocket auth token). Returns the parsed
41
- `{"sent": N, "stale": M}` response. Raises CarterNotifyError on an HTTP error or
42
- ValueError on invalid title/body. `_send` is a test seam: a callable
43
- (url, headers, body_bytes) -> dict that bypasses the network."""
126
+ `{"sent": N, "stale": M, "notifId"?: id}` response. Raises CarterNotifyError on an
127
+ HTTP error or ValueError on an invalid field. `_send` is a test seam: a callable
128
+ (url, headers, body_bytes) -> dict that bypasses the network.
129
+
130
+ Personalization fields (all optional): `subtitle` (2nd alert line);
131
+ `interruption` "passive" | "active" | "time-sensitive" ("critical" requires Apple
132
+ approval and is rejected); `relevance` 0..1 orders stacked notifications;
133
+ `thread_id` groups notifications (use one id per layout/conversation); `image` is an
134
+ https URL the device downloads and attaches; `sender` renders the push as a
135
+ Communication Notification "from" that persona — name + circular avatar (see
136
+ `_normalize_sender` for accepted shapes); `actions` adds up to 4 buttons (wire-style
137
+ list of `{"id", "title", "destructive"?}` — callback dispatch lives on
138
+ `CarterClient.notify`, not here); `notif_id` is echoed back by button taps. `sound`
139
+ is a sound file name bundled in the app, "default", or "none" (silent) — remote
140
+ sound URLs are not a thing APNs supports."""
44
141
  if not title or len(title) > 256:
45
142
  raise ValueError("title must be non-empty and <= 256 chars")
46
143
  if not body or len(body) > 256:
47
144
  raise ValueError("body must be non-empty and <= 256 chars")
145
+ if subtitle is not None and len(subtitle) > 256:
146
+ raise ValueError("subtitle must be <= 256 chars")
147
+ if interruption is not None and interruption not in _INTERRUPTION_LEVELS:
148
+ if interruption == "critical":
149
+ raise ValueError('interruption "critical" requires Apple approval and is not enabled')
150
+ raise ValueError(f"interruption must be one of {_INTERRUPTION_LEVELS}")
151
+ if relevance is not None and not (0 <= relevance <= 1):
152
+ raise ValueError("relevance must be within 0..1")
153
+ if thread_id is not None and len(thread_id) > 128:
154
+ raise ValueError("thread_id must be <= 128 chars")
155
+ if image is not None and len(image) > 512:
156
+ raise ValueError("image URL must be <= 512 chars")
157
+ if notif_id is not None and len(notif_id) > 64:
158
+ raise ValueError("notif_id must be <= 64 chars")
159
+ sender = _normalize_sender(sender)
160
+ wire_actions, callbacks = _normalize_actions(actions)
161
+ if callbacks:
162
+ raise ValueError("action callbacks need a mesh connection — use "
163
+ "CarterClient.notify(); notify_http sends buttons only")
48
164
 
49
165
  payload = {"title": title, "body": body, "sound": sound}
166
+ if subtitle is not None:
167
+ payload["subtitle"] = subtitle
50
168
  if channel is not None:
51
169
  payload["channel"] = channel
52
170
  if category is not None:
53
171
  payload["category"] = category
54
172
  if badge is not None:
55
173
  payload["badge"] = badge
174
+ if interruption is not None:
175
+ payload["interruption"] = interruption
176
+ if relevance is not None:
177
+ payload["relevance"] = relevance
178
+ if thread_id is not None:
179
+ payload["threadId"] = thread_id
180
+ if image is not None:
181
+ payload["imageURL"] = image
182
+ if sender is not None:
183
+ payload["sender"] = sender
184
+ if wire_actions is not None:
185
+ payload["actions"] = wire_actions
186
+ if notif_id is not None:
187
+ payload["notifId"] = notif_id
56
188
  if data is not None:
57
189
  payload["data"] = data
58
190
 
@@ -117,6 +249,9 @@ class CarterClient:
117
249
  self._sock = MeshSocket(url=gateway_url, name=name, auth_token=token,
118
250
  channel=channel, role=role, can_broadcast=True,
119
251
  can_route=can_route, can_monitor=can_monitor)
252
+ #: The mesh channel — also the default tap-routing key notify() stamps on
253
+ #: pushes, so tapping one opens the pinned layout for this connection.
254
+ self.channel = channel
120
255
  # `room=True` matches the app's `mode: room`: a symmetric group cipher so the hub
121
256
  # shares an encrypted room with several members. Otherwise the directional 1:1 cipher.
122
257
  if e2ee_key:
@@ -146,6 +281,11 @@ class CarterClient:
146
281
  self._broadcast_registered = False
147
282
  self._join_handler = None
148
283
  self._ack_commands = False
284
+ # Notification action-button plumbing: per-send callbacks keyed by
285
+ # (notifId, actionId), plus an optional catch-all. Fed by the app's flat
286
+ # `notif_action` broadcast when a user taps a button on a push.
287
+ self._notif_callbacks = {}
288
+ self._notif_action_handler = None
149
289
 
150
290
  def _open(self, payload):
151
291
  if self._session and isinstance(payload, dict) and E2EESession.is_envelope(payload):
@@ -190,6 +330,18 @@ class CarterClient:
190
330
  _PROTOCOL_BROADCASTS = ("control_sync_request", "control_snapshot", "command_ack")
191
331
 
192
332
  async def _dispatch_broadcast(self, data):
333
+ # Notification action taps (the app's flat `notif_action` frame) are kit
334
+ # plane, like protocol frames: dispatched to the per-send callback
335
+ # registered by notify(actions=...) and the on_notif_action catch-all,
336
+ # never to on_broadcast handlers.
337
+ if isinstance(data, dict) and data.get("msg_type") == "notif_action":
338
+ fn = self._notif_callbacks.get((data.get("notifId"), data.get("actionId")))
339
+ for handler in (fn, self._notif_action_handler):
340
+ if handler is not None:
341
+ result = handler(data)
342
+ if asyncio.iscoroutine(result):
343
+ await result
344
+ return
193
345
  # Protocol frames are consumed here so they never reach the user's
194
346
  # on_broadcast handler. A sync request is answered by an authority AND
195
347
  # surfaced via on_sync_request (the join signal); snapshots/acks are for
@@ -299,17 +451,88 @@ class CarterClient:
299
451
  reply = await self._sock.request(command, self._seal(data), timeout=timeout)
300
452
  return self._open(reply) if reply is not None else None
301
453
 
302
- async def notify(self, title, body, *, channel=None, category=None,
303
- badge=None, sound="default", data=None):
454
+ def on_notif_action(self, handler):
455
+ """Catch-all for notification button taps: handler(data) gets the flat
456
+ `notif_action` frame `{"msg_type", "notifId", "actionId", "threadId"?}`.
457
+ Per-send callbacks passed via notify(actions=...) fire first."""
458
+ self._notif_action_handler = handler
459
+ self._ensure_broadcast_listener()
460
+
461
+ async def notify(self, title, body, *, subtitle=None, channel=None, category=None,
462
+ badge=None, sound="default", interruption=None, criticality=None,
463
+ relevance=None, thread_id=None, image=None, sender=None,
464
+ actions=None, notif_id=None, data=None, encrypt=None,
465
+ placeholder_title="", placeholder_body="New notification"):
304
466
  """Send a one-shot push to every device on the account. Requires `validator_url`
305
467
  and `session_jwt` to have been passed to the constructor (the mesh auth token is
306
- NOT the session JWT — distinct credentials). Returns `{"sent": N, "stale": M}`."""
468
+ NOT the session JWT — distinct credentials). Returns
469
+ `{"sent": N, "stale": M, "notifId"?: id}`.
470
+
471
+ On top of `notify_http`'s fields this adds the mesh-connected conveniences:
472
+
473
+ - `channel` defaults to this client's mesh channel, so tapping the push opens
474
+ the pinned layout for this connection.
475
+ - `actions` may carry callbacks — `{"ack": ("Acknowledge", fn)}` or
476
+ `{"ack": {"name": "Acknowledge", "func": fn}}`; when the user taps the
477
+ button, the app broadcasts `notif_action` on the channel and the callback
478
+ fires with the flat frame. Best-effort: taps only arrive while the app holds
479
+ a live connection on that channel. A `notif_id` is minted per send to key
480
+ the dispatch (pass your own to override).
481
+ - `sender` (persona) defaults `thread_id` to the sender's name so avatar
482
+ grouping and thread grouping agree.
483
+ - `criticality` is an alias for `interruption`.
484
+ - E2EE: in a room (`room=True` + e2ee_key) the content fields — title, body,
485
+ subtitle, image, sender — are sealed into the `enc` envelope the app's push
486
+ extension decrypts on-device; APNs and the relay carry only
487
+ `placeholder_title`/`placeholder_body`. That is the default in a room
488
+ (`encrypt=None`); pass `encrypt=False` to send in the clear, `encrypt=True`
489
+ to fail loudly when no room cipher is available. Delivery hints
490
+ (interruption/relevance/thread/sound/badge/channel/actions) always ride in
491
+ the clear."""
307
492
  if not self._validator_url or not self._session_jwt:
308
493
  raise CarterNotifyError(0, "notify() requires validator_url and session_jwt "
309
494
  "on the CarterClient constructor")
495
+ if interruption is None:
496
+ interruption = criticality
497
+ if channel is None:
498
+ channel = self.channel
499
+ sender = _normalize_sender(sender)
500
+ if sender is not None and thread_id is None:
501
+ thread_id = sender["name"]
502
+ wire_actions, callbacks = _normalize_actions(actions)
503
+ if callbacks:
504
+ if notif_id is None:
505
+ notif_id = "n" + uuid.uuid4().hex[:16]
506
+ for aid, fn in callbacks.items():
507
+ self._notif_callbacks[(notif_id, aid)] = fn
508
+ self._ensure_broadcast_listener()
509
+
510
+ can_seal = self._session is not None and getattr(self._session, "is_group", False)
511
+ if encrypt is None:
512
+ encrypt = can_seal
513
+ elif encrypt and not can_seal:
514
+ raise CarterNotifyError(0, "encrypted notifications need a room cipher "
515
+ "(e2ee_key + room=True) — the push extension "
516
+ "only opens the group construction")
517
+ if encrypt:
518
+ sealed = {"title": title, "body": body}
519
+ if subtitle is not None:
520
+ sealed["subtitle"] = subtitle
521
+ if image is not None:
522
+ sealed["imageURL"] = image
523
+ if sender is not None:
524
+ sealed["sender"] = sender
525
+ data = dict(data or {})
526
+ data["enc"] = self._session.seal(sealed)
527
+ title, body = placeholder_title or "CAR-TER", placeholder_body
528
+ subtitle = image = sender = None
529
+
310
530
  return await asyncio.to_thread(
311
531
  notify_http, self._validator_url, self._session_jwt, title, body,
312
- channel=channel, category=category, badge=badge, sound=sound, data=data)
532
+ subtitle=subtitle, channel=channel, category=category, badge=badge,
533
+ sound=sound, interruption=interruption, relevance=relevance,
534
+ thread_id=thread_id, image=image, sender=sender, actions=wire_actions,
535
+ notif_id=notif_id, data=data)
313
536
 
314
537
  async def refresh_device_token(self):
315
538
  """Re-mint this device's relay token from its refresh secret and apply it to the
@@ -60,6 +60,8 @@ def analyze_layout(layout: dict) -> dict:
60
60
  for s in ch.get("sync") or []:
61
61
  if not (isinstance(s, dict) and s.get("valuePath")):
62
62
  continue
63
+ if s.get("method") in ("mqtt", "http", "sensor"):
64
+ continue # app-direct (broker/REST/hardware) — no server serves it
63
65
  emits.setdefault(s.get("event") or "broadcast", set()).add(s["valuePath"])
64
66
  if not pushed and (s.get("event") or "broadcast") == "broadcast" and ch.get("id"):
65
67
  pushes.append((ch["id"], s["valuePath"]))
@@ -5,7 +5,7 @@ The ecosystem grew four shapes of "how do I connect":
5
5
  1. a layout's `connection` block (what the APP dials):
6
6
  ``{"url", "token"?, "mode"?, "e2eeKey"?, "identity": {"name","channel","role"}}``
7
7
  2. the QR pairing JSON (what the app scans): ``{"url","channel","role","token"?}``
8
- 3. the *Add Device* credential (what a HUB holds — the app's Members sheet emits
8
+ 3. the *Add Hub* credential (what a HUB holds — the app's Layout hub (Hubs → Add Hub) emits
9
9
  it): ``{"url","channel","token","role","refresh","did","k"?,"validator"?}``
10
10
  4. `CarterClient` constructor kwargs.
11
11
 
@@ -190,7 +190,7 @@ class Connection:
190
190
  if self.kind == "account":
191
191
  raise ValueError(
192
192
  "this connection is a Connect+ account-side block — it has no hub "
193
- "credential. Mint one on the phone (Members → Add Device) and pass "
193
+ "credential. Mint one on the phone (Layout hub → Hubs → Add Hub) and pass "
194
194
  "that JSON instead.")
195
195
  url = self.url if self.kind != "local" else f"ws://127.0.0.1:{self.port}"
196
196
  kw: dict = {