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.
- {carterkit-0.6.0 → carterkit-0.7.0}/PKG-INFO +35 -1
- {carterkit-0.6.0 → carterkit-0.7.0}/README.md +34 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/__init__.py +3 -1
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/bind.py +84 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/catalog.py +11 -1
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/cli.py +46 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/client.py +232 -9
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/codegen.py +2 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/connection.py +2 -2
- carterkit-0.7.0/carterkit/contract.py +339 -0
- carterkit-0.7.0/carterkit/controldocs/actions.md +92 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/button.md +1 -1
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/chat.md +32 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/index.md +5 -3
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/layout-config.md +9 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/log-console.md +4 -0
- carterkit-0.7.0/carterkit/controldocs/sources.md +154 -0
- carterkit-0.7.0/carterkit/controldocs/sync.md +115 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/e2ee.py +3 -0
- carterkit-0.7.0/carterkit/explore.py +351 -0
- carterkit-0.7.0/carterkit/explore_html.py +506 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/hub.py +28 -4
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/layout.py +135 -1
- carterkit-0.7.0/carterkit/validate.py +433 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/PKG-INFO +35 -1
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/SOURCES.txt +8 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/pyproject.toml +1 -1
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_client.py +188 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_connection.py +1 -1
- carterkit-0.7.0/tests/test_contract.py +148 -0
- carterkit-0.7.0/tests/test_explore.py +172 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_layout.py +33 -0
- carterkit-0.7.0/tests/test_parity_acceptance.py +101 -0
- carterkit-0.7.0/tests/test_parity_authoring.py +118 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_validate.py +14 -1
- carterkit-0.6.0/carterkit/controldocs/actions.md +0 -51
- carterkit-0.6.0/carterkit/controldocs/sync.md +0 -57
- carterkit-0.6.0/carterkit/validate.py +0 -199
- {carterkit-0.6.0 → carterkit-0.7.0}/LICENSE +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/__main__.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/buffer.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/accordion.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/animations.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/appearance.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/box-plot.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/camera.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/canvas.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/cardList.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/carousel.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/chart.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/chord.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/color-picker.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/compass.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/control-def.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/date-picker.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/divider.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/flip-card.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/gantt.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/gauge.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/graph.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/grid-dimensions.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/group-def.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/haptics.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/heatmap.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/image.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/joystick.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/label.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/list.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/long-press.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/map.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/picker.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/pie-chart.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/pinboard.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/privacy.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/progress-ring.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/publishers.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/pulse.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/qr-code.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/radar.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sankey.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/segmented.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sensors.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/slider.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sortboard.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/spacer.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/sparkline.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/status-light.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/stepper.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/terms.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/text-input.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/theming.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/toggle.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/treemap.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/visibility.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controldocs/web-view.md +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/controls.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/declare.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/dynamic.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/grid.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/infer.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/py.typed +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/relay.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/theming.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit/tune.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/dependency_links.txt +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/entry_points.txt +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/requires.txt +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/carterkit.egg-info/top_level.txt +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/setup.cfg +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_bind.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_buffer.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_catalog.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_cli.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_codegen.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_controls.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_declare.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_dynamic.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_e2ee.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_grid.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_hub.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_infer.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_theming.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_tune.py +0 -0
- {carterkit-0.6.0 → carterkit-0.7.0}/tests/test_ui.py +0 -0
- {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.
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
42
|
-
ValueError on invalid
|
|
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
|
-
|
|
303
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 (
|
|
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 = {
|