carterkit 0.5.0__tar.gz → 0.5.2__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 (96) hide show
  1. {carterkit-0.5.0 → carterkit-0.5.2}/PKG-INFO +1 -1
  2. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/__init__.py +4 -1
  3. carterkit-0.5.2/carterkit/client.py +298 -0
  4. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/index.md +1 -1
  5. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/label.md +46 -3
  6. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/log-console.md +5 -0
  7. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/text-input.md +70 -1
  8. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/e2ee.py +16 -3
  9. carterkit-0.5.2/carterkit/relay.py +101 -0
  10. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/PKG-INFO +1 -1
  11. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/SOURCES.txt +1 -0
  12. {carterkit-0.5.0 → carterkit-0.5.2}/pyproject.toml +1 -1
  13. carterkit-0.5.2/tests/test_client.py +273 -0
  14. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_e2ee.py +13 -0
  15. carterkit-0.5.0/carterkit/client.py +0 -146
  16. carterkit-0.5.0/tests/test_client.py +0 -153
  17. {carterkit-0.5.0 → carterkit-0.5.2}/LICENSE +0 -0
  18. {carterkit-0.5.0 → carterkit-0.5.2}/README.md +0 -0
  19. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/__main__.py +0 -0
  20. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/bind.py +0 -0
  21. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/buffer.py +0 -0
  22. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/catalog.py +0 -0
  23. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/cli.py +0 -0
  24. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/codegen.py +0 -0
  25. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/accordion.md +0 -0
  26. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/actions.md +0 -0
  27. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/animations.md +0 -0
  28. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/appearance.md +0 -0
  29. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/button.md +0 -0
  30. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/cardList.md +0 -0
  31. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/carousel.md +0 -0
  32. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/chat.md +0 -0
  33. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/color-picker.md +0 -0
  34. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/control-def.md +0 -0
  35. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/date-picker.md +0 -0
  36. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/divider.md +0 -0
  37. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/flip-card.md +0 -0
  38. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/gauge.md +0 -0
  39. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/graph.md +0 -0
  40. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/grid-dimensions.md +0 -0
  41. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/group-def.md +0 -0
  42. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/haptics.md +0 -0
  43. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/image.md +0 -0
  44. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/joystick.md +0 -0
  45. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/layout-config.md +0 -0
  46. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/list.md +0 -0
  47. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/long-press.md +0 -0
  48. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/map.md +0 -0
  49. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/picker.md +0 -0
  50. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/privacy.md +0 -0
  51. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/progress-ring.md +0 -0
  52. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/pulse.md +0 -0
  53. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/qr-code.md +0 -0
  54. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/segmented.md +0 -0
  55. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/slider.md +0 -0
  56. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/spacer.md +0 -0
  57. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/sparkline.md +0 -0
  58. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/status-light.md +0 -0
  59. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/stepper.md +0 -0
  60. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/sync.md +0 -0
  61. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/terms.md +0 -0
  62. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/theming.md +0 -0
  63. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/toggle.md +0 -0
  64. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/visibility.md +0 -0
  65. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/web-view.md +0 -0
  66. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controls.py +0 -0
  67. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/declare.py +0 -0
  68. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/dynamic.py +0 -0
  69. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/grid.py +0 -0
  70. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/infer.py +0 -0
  71. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/layout.py +0 -0
  72. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/py.typed +0 -0
  73. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/theming.py +0 -0
  74. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/tune.py +0 -0
  75. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/validate.py +0 -0
  76. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/dependency_links.txt +0 -0
  77. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/entry_points.txt +0 -0
  78. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/requires.txt +0 -0
  79. {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/top_level.txt +0 -0
  80. {carterkit-0.5.0 → carterkit-0.5.2}/setup.cfg +0 -0
  81. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_bind.py +0 -0
  82. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_buffer.py +0 -0
  83. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_catalog.py +0 -0
  84. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_cli.py +0 -0
  85. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_codegen.py +0 -0
  86. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_controls.py +0 -0
  87. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_declare.py +0 -0
  88. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_dynamic.py +0 -0
  89. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_grid.py +0 -0
  90. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_infer.py +0 -0
  91. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_layout.py +0 -0
  92. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_theming.py +0 -0
  93. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_tune.py +0 -0
  94. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_ui.py +0 -0
  95. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_validate.py +0 -0
  96. {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_validate_bindings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: carterkit
3
- Version: 0.5.0
3
+ Version: 0.5.2
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
@@ -21,7 +21,9 @@ from pathlib import Path
21
21
  from . import catalog, grid, codegen, infer, theming, tune, dynamic
22
22
  from .buffer import LayoutBuffer, BufferError
23
23
  from .validate import validate_layout as _validate_layout, format_findings
24
- from .client import CarterClient, notify_http, CarterNotifyError
24
+ from .client import (CarterClient, notify_http, CarterNotifyError,
25
+ device_refresh_http, CarterDeviceRevoked)
26
+ from .relay import LocalRelay, port_in_use, lan_ip
25
27
  from . import bind
26
28
  from .controls import build, control
27
29
  from .layout import Layout, Fragment, Control, Condition
@@ -78,6 +80,7 @@ def lint_dynamic_traffic(layout: dict, observed, catalog_: dict = None) -> list:
78
80
  __all__ = [
79
81
  "__version__", "PROTOCOL_VERSION",
80
82
  "CarterClient", "notify_http", "CarterNotifyError",
83
+ "device_refresh_http", "CarterDeviceRevoked",
81
84
  "LayoutBuffer", "BufferError",
82
85
  "controls", "doc", "doc_markdown", "examples", "validate_layout",
83
86
  "lint_dynamic_traffic", "format_findings", "controldocs_dir",
@@ -0,0 +1,298 @@
1
+ """carter_connect — minimal Connect+ hub client. Wraps MeshSocket + E2EE so a maker
2
+ connects hardware to the Connect+ relay in a few lines. Transparent encryption when an
3
+ e2ee_key is provided (broadcasts AND request replies); cleartext otherwise.
4
+
5
+ Also exposes `notify_http(...)` and `CarterClient.notify(...)` for sending a one-shot
6
+ push to every device on a Connect+ account (POST /alerts/notify). `notify_http` is
7
+ stdlib-only (urllib) so a cron job can fire a notification without the MeshSocket stack."""
8
+ import asyncio
9
+ import base64
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+ import urllib.error
15
+ import urllib.request
16
+ import uuid
17
+
18
+ try:
19
+ from meshsocket import MeshSocket # pip install meshsocket
20
+ from .e2ee import E2EESession
21
+ except ImportError: # keep notify_http importable without the MeshSocket/crypto stack
22
+ MeshSocket = None
23
+ E2EESession = None
24
+
25
+
26
+ class CarterNotifyError(Exception):
27
+ """Raised when /alerts/notify rejects a send. `status` is the HTTP code (0 for a
28
+ client-side/config error); `detail` is the server body or a description."""
29
+ def __init__(self, status, detail):
30
+ super().__init__(f"notify failed ({status}): {detail}")
31
+ self.status = status
32
+ self.detail = detail
33
+
34
+
35
+ def notify_http(validator_url, session_jwt, title, body, *, channel=None, category=None,
36
+ badge=None, sound="default", data=None, _send=None):
37
+ """Send a one-shot push to every device on the account (POST /alerts/notify).
38
+
39
+ Stdlib-only. `validator_url` is the Connect+ validator base URL; `session_jwt` is the
40
+ 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."""
44
+ if not title or len(title) > 256:
45
+ raise ValueError("title must be non-empty and <= 256 chars")
46
+ if not body or len(body) > 256:
47
+ raise ValueError("body must be non-empty and <= 256 chars")
48
+
49
+ payload = {"title": title, "body": body, "sound": sound}
50
+ if channel is not None:
51
+ payload["channel"] = channel
52
+ if category is not None:
53
+ payload["category"] = category
54
+ if badge is not None:
55
+ payload["badge"] = badge
56
+ if data is not None:
57
+ payload["data"] = data
58
+
59
+ url = validator_url.rstrip("/") + "/alerts/notify"
60
+ headers = {"Authorization": session_jwt, "Content-Type": "application/json"}
61
+ body_bytes = json.dumps(payload).encode()
62
+
63
+ if _send is not None:
64
+ return _send(url, headers, body_bytes)
65
+
66
+ req = urllib.request.Request(url, data=body_bytes, headers=headers, method="POST")
67
+ try:
68
+ with urllib.request.urlopen(req) as resp:
69
+ return json.loads(resp.read().decode())
70
+ except urllib.error.HTTPError as e:
71
+ raise CarterNotifyError(e.code, e.read().decode(errors="replace")) from None
72
+
73
+
74
+ class CarterDeviceRevoked(Exception):
75
+ """Raised when an external device's refresh is denied (HTTP 403) — the owner revoked the
76
+ device or their Connect+ lapsed. Terminal: the device should stop trying to reconnect."""
77
+
78
+
79
+ def device_refresh_http(validator_url, device_id, refresh_token, *, _send=None):
80
+ """Re-mint an external device's short-lived relay token (POST /devices/sessions/refresh).
81
+
82
+ Stdlib-only, mirroring `notify_http`. `validator_url` is the Connect+ validator base URL;
83
+ `device_id` + `refresh_token` are the long-lived credential handed to the device at mint
84
+ time. Returns the parsed `{"deviceToken": ..., "expiresAt": ...}`. Raises
85
+ CarterDeviceRevoked on HTTP 403 (revoked / owner lapsed); other HTTP errors propagate so a
86
+ caller can retry transient failures. `_send` is a test seam: (url, headers, body) -> dict."""
87
+ url = validator_url.rstrip("/") + "/devices/sessions/refresh"
88
+ headers = {"Content-Type": "application/json"}
89
+ body_bytes = json.dumps({"deviceId": device_id, "refreshToken": refresh_token}).encode()
90
+
91
+ def _do():
92
+ if _send is not None:
93
+ return _send(url, headers, body_bytes)
94
+ req = urllib.request.Request(url, data=body_bytes, headers=headers, method="POST")
95
+ with urllib.request.urlopen(req) as resp:
96
+ return json.loads(resp.read().decode())
97
+
98
+ try:
99
+ return _do()
100
+ except urllib.error.HTTPError as e:
101
+ if e.code == 403:
102
+ raise CarterDeviceRevoked(e.read().decode(errors="replace")) from None
103
+ raise CarterNotifyError(e.code, e.read().decode(errors="replace")) from None
104
+
105
+
106
+ class CarterClient:
107
+ def __init__(self, gateway_url, token, channel, role="device", name="hub", e2ee_key=None,
108
+ validator_url=None, session_jwt=None, room=False,
109
+ device_id=None, refresh_token=None, refresh_interval=2400):
110
+ if MeshSocket is None:
111
+ raise ImportError("MeshSocket is unavailable; run `pip install meshsocket`. "
112
+ "(notify_http does not need it.)")
113
+ self._sock = MeshSocket(url=gateway_url, name=name, auth_token=token,
114
+ channel=channel, role=role, can_broadcast=True, can_route=False)
115
+ # `room=True` matches the app's `mode: room`: a symmetric group cipher so the hub
116
+ # shares an encrypted room with several members. Otherwise the directional 1:1 cipher.
117
+ if e2ee_key:
118
+ secret = base64.b64decode(e2ee_key)
119
+ self._session = (E2EESession.group(secret) if room
120
+ else E2EESession(secret, is_device_side=(role in ("device", "hub"))))
121
+ else:
122
+ self._session = None
123
+ # Connect+ validator credentials for notify(); distinct from the mesh auth token.
124
+ self._validator_url = validator_url
125
+ self._session_jwt = session_jwt
126
+ # External-device self-refresh: a headless device provisioned via POST /devices holds
127
+ # a long-lived refresh secret and re-mints its short-lived relay token before expiry,
128
+ # updating the socket's auth_token so any reconnect uses the fresh one. Revocation
129
+ # (HTTP 403) surfaces as `revoked = True` and tears the socket down.
130
+ self._device_id = device_id
131
+ self._refresh_token = refresh_token
132
+ self._refresh_interval = refresh_interval
133
+ self._refresh_task = None
134
+ self.revoked = False
135
+ # Control-state authority (matches the app's Phase 2): when enabled, this hub answers
136
+ # a replica's control_sync_request with a snapshot of set_control_state() values.
137
+ self._control_state = {}
138
+ self._state_version = 0
139
+ self._is_state_authority = False
140
+ self._broadcast_handler = None
141
+ self._broadcast_registered = False
142
+
143
+ def _open(self, payload):
144
+ if self._session and isinstance(payload, dict) and E2EESession.is_envelope(payload):
145
+ return self._session.open(payload)
146
+ return payload
147
+
148
+ def _seal(self, data):
149
+ return self._session.seal(data) if (self._session and data is not None) else data
150
+
151
+ def on(self, msg_type, handler):
152
+ """Register a command handler. handler(data: dict) gets DECRYPTED data and may return a
153
+ dict reply (auto-encrypted). Sync or async handlers are supported."""
154
+ async def wrapper(payload):
155
+ data = self._open(payload)
156
+ result = handler(data)
157
+ if asyncio.iscoroutine(result):
158
+ result = await result
159
+ return self._seal(result) if result is not None else None
160
+ self._sock.on(msg_type, wrapper)
161
+
162
+ def on_broadcast(self, handler):
163
+ """Register a handler for relayed broadcasts. handler(data: dict) gets DECRYPTED data."""
164
+ self._broadcast_handler = handler
165
+ self._ensure_broadcast_listener()
166
+
167
+ def _ensure_broadcast_listener(self):
168
+ """Arm the single 'broadcast' socket listener (one handler per event) that dispatches
169
+ to the control-state responder and then the user's on_broadcast handler."""
170
+ if self._broadcast_registered:
171
+ return
172
+ self._broadcast_registered = True
173
+
174
+ async def wrapper(payload):
175
+ await self._dispatch_broadcast(self._open(payload))
176
+ return None
177
+ self._sock.on("broadcast", wrapper)
178
+
179
+ async def _dispatch_broadcast(self, data):
180
+ # Control-state sync frames are protocol, not app data — consume them here so they
181
+ # never reach the user's on_broadcast handler. Only an authority answers a request.
182
+ if isinstance(data, dict) and data.get("msg_type") in ("control_sync_request", "control_snapshot"):
183
+ if self._is_state_authority and data.get("msg_type") == "control_sync_request":
184
+ await self._answer_control_sync(data)
185
+ return
186
+ if self._broadcast_handler is not None:
187
+ result = self._broadcast_handler(data)
188
+ if asyncio.iscoroutine(result):
189
+ await result
190
+
191
+ def set_control_state(self, control_id, value):
192
+ """Record the authoritative current value of a control so the hub can answer a
193
+ replica's control_sync_request. Call this alongside your normal broadcast of the
194
+ value — it only updates the snapshot served to late joiners / reconnecting devices."""
195
+ self._control_state[control_id] = value
196
+
197
+ def enable_state_authority(self):
198
+ """Declare this hub the source of truth for control state. It will answer replicas'
199
+ control_sync_request broadcasts with a control_snapshot of set_control_state()
200
+ values — the hub side of the app's Phase 2 designated-authority sync."""
201
+ self._is_state_authority = True
202
+ self._ensure_broadcast_listener()
203
+
204
+ async def _answer_control_sync(self, data):
205
+ to = data.get("from")
206
+ if not to or not self._control_state:
207
+ return
208
+ self._state_version += 1
209
+ await self.broadcast("control_snapshot",
210
+ {"to": to, "v": self._state_version, "controls": dict(self._control_state)})
211
+
212
+ async def broadcast(self, msg_type, data):
213
+ await self._sock.send("broadcast_request", self._seal({**data, "msg_type": msg_type}))
214
+
215
+ async def chat(self, text, *, name="Server", role="device", channel=None, msg_id=None, **extra):
216
+ """Send a channel chat message a chat control will display, as a bubble from `name`.
217
+
218
+ Builds the exact shape the app requires — a unique `id`, a `sender.name`, an
219
+ ISO-8601 `timestamp`, and the `chat_message` type — which is easy to get subtly
220
+ wrong by hand (a missing `id` is silently dropped). Extra keys pass through
221
+ (e.g. `_from=` to tag your own echo). Returns the message id."""
222
+ mid = msg_id or uuid.uuid4().hex
223
+ payload = {
224
+ "id": mid,
225
+ "text": text,
226
+ "sender": {"name": name, "role": role},
227
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
228
+ }
229
+ if channel is not None:
230
+ payload["channel"] = channel
231
+ payload.update(extra)
232
+ await self.broadcast("chat_message", payload)
233
+ return mid
234
+
235
+ async def request(self, command, data, timeout=5.0):
236
+ reply = await self._sock.request(command, self._seal(data), timeout=timeout)
237
+ return self._open(reply) if reply is not None else None
238
+
239
+ async def notify(self, title, body, *, channel=None, category=None,
240
+ badge=None, sound="default", data=None):
241
+ """Send a one-shot push to every device on the account. Requires `validator_url`
242
+ and `session_jwt` to have been passed to the constructor (the mesh auth token is
243
+ NOT the session JWT — distinct credentials). Returns `{"sent": N, "stale": M}`."""
244
+ if not self._validator_url or not self._session_jwt:
245
+ raise CarterNotifyError(0, "notify() requires validator_url and session_jwt "
246
+ "on the CarterClient constructor")
247
+ return await asyncio.to_thread(
248
+ notify_http, self._validator_url, self._session_jwt, title, body,
249
+ channel=channel, category=category, badge=badge, sound=sound, data=data)
250
+
251
+ async def refresh_device_token(self):
252
+ """Re-mint this device's relay token from its refresh secret and apply it to the
253
+ socket, so any reconnect uses the fresh token. Returns the parsed response. Raises
254
+ CarterDeviceRevoked if the device was revoked or the owner's Connect+ lapsed."""
255
+ if not (self._validator_url and self._device_id and self._refresh_token):
256
+ raise CarterNotifyError(0, "refresh_device_token() needs validator_url, device_id, "
257
+ "and refresh_token on the CarterClient constructor")
258
+ res = await asyncio.to_thread(device_refresh_http, self._validator_url,
259
+ self._device_id, self._refresh_token)
260
+ token = res.get("deviceToken") if isinstance(res, dict) else None
261
+ if token:
262
+ self._sock.auth_token = token # MeshSocket re-sends this on every (re)connect
263
+ return res
264
+
265
+ async def _device_refresh_loop(self):
266
+ """Keep the short-lived device token fresh ahead of expiry. On revocation, stop the
267
+ socket and flag `revoked`; transient errors are retried on the next tick."""
268
+ while True:
269
+ await asyncio.sleep(self._refresh_interval)
270
+ try:
271
+ await self.refresh_device_token()
272
+ except CarterDeviceRevoked:
273
+ self.revoked = True
274
+ await self._sock.stop()
275
+ return
276
+ except Exception:
277
+ pass # transient (network/5xx) — retry next interval
278
+
279
+ async def connect(self):
280
+ await self._sock.start()
281
+ await self._sock.wait_until_ready()
282
+ # Auto-start the refresh loop only for a self-refreshing external device.
283
+ if self._device_id and self._refresh_token and self._validator_url and self._refresh_task is None:
284
+ self._refresh_task = asyncio.create_task(self._device_refresh_loop())
285
+
286
+ async def close(self):
287
+ if self._refresh_task is not None:
288
+ self._refresh_task.cancel()
289
+ self._refresh_task = None
290
+ await self._sock.stop()
291
+
292
+ async def __aenter__(self):
293
+ await self.connect()
294
+ return self
295
+
296
+ async def __aexit__(self, *exc):
297
+ await self.close()
298
+ return False
@@ -29,7 +29,7 @@ A layout defines **tabs**, each containing a **grid** of controls. Controls rang
29
29
 
30
30
  ## The Control System
31
31
 
32
- Every control in CAR-TER is declared as a JSON object with a `type`, `id`, and grid `position`. The system supports **28 control types** across three categories:
32
+ Every control in CAR-TER is declared as a JSON object with a `type`, `id`, and grid `position`. The system supports **27 control types** across three categories:
33
33
 
34
34
  ### Input Controls
35
35
  Buttons, toggles, sliders, steppers, pickers, date pickers, text inputs, color pickers, and segmented controls. These send user actions to your server. Container controls — **carousels**, **flip cards**, and **accordions** — arrange sets of groups into swipeable pages, flippable faces, and collapsible sections.
@@ -16,9 +16,9 @@ fields:
16
16
  description: SF Symbol before text
17
17
  - name: style
18
18
  type: enum
19
- values: [default, headline, title, caption, mono, large-mono]
19
+ values: [default, headline, title, caption, mono, large-mono, terminal]
20
20
  default: default
21
- description: Text style variant
21
+ description: Text style variant (terminal = ANSI terminal renderer)
22
22
  - name: tint
23
23
  type: color
24
24
  default: "#FFFFFF"
@@ -28,6 +28,10 @@ fields:
28
28
  values: [leading, center, trailing]
29
29
  default: leading
30
30
  description: Text alignment
31
+ - name: scrollable
32
+ type: bool
33
+ default: false
34
+ description: Fixed-height scrolling terminal/log view that keeps the latest line in view (pair with controlHeight)
31
35
  - name: formatValue
32
36
  type: string
33
37
  description: Value display format (see formatValue table)
@@ -58,8 +62,9 @@ Inherits all [[shared-properties]]. Key fields:
58
62
  | `label` | string | — | Alternative to `text` |
59
63
  | `icon` | string | — | SF Symbol before text |
60
64
  | `style` | string | `"default"` | See [[style-properties#Label Styles]] |
61
- | `tint` | string | white | Text and icon color |
65
+ | `tint` | string | white | Text and icon color (terminal: default text colour over the dark backdrop) |
62
66
  | `align` | string | `"leading"` | `"leading"`, `"center"`, `"trailing"` |
67
+ | `scrollable` | bool | `false` | Fixed-height scrolling terminal/log view (pair with `controlHeight`) |
63
68
  | `formatValue` | string | — | Value display format (see formatValue table below) |
64
69
 
65
70
  ## Format Values
@@ -86,6 +91,29 @@ Inherits all [[shared-properties]]. Key fields:
86
91
  | `"caption"` | `.caption` |
87
92
  | `"mono"` | `.subheadline` monospaced |
88
93
  | `"large-mono"` | `.title3` monospaced medium |
94
+ | `"terminal"` | Monospaced **ANSI terminal** — see [Terminal style](#terminal-style) |
95
+
96
+ ## Terminal style
97
+
98
+ `style: "terminal"` (and, as a shortcut, **`mono`/`large-mono` + `scrollable`**) renders the
99
+ text as a real terminal instead of a plain mono label — purpose-built for piping a captured
100
+ shell/tmux pane to the phone:
101
+
102
+ - **ANSI styling** — colour (16 / 256 / truecolor), **bold**, dim, *italic*, underline, and
103
+ reverse are parsed from the escape codes and rendered. Plain text (no escapes) renders
104
+ normally.
105
+ - **Pane-matched scaling** — the font auto-scales so the source's column width exactly fills
106
+ the view, so box-drawing and ASCII line up like the real pane. If a server prepends a
107
+ `CSI 8;rows;cols t` size report, that column count is used; otherwise the widest line is.
108
+ - **Dark backdrop** — drawn on its own dark background (independent of the app theme) so
109
+ ANSI colours have the contrast they were designed for.
110
+ - **Pinch to zoom** — pinch to scale the text past the fitted size; **double-tap** snaps back
111
+ to fit. Content wider/taller than the view scrolls both ways.
112
+ - **Sizing** — set `controlHeight` for the on-screen height; `fontSize` caps the fitted font
113
+ before zoom. Keeps the latest line in view and auto-scrolls on update.
114
+
115
+ Feed it like any synced label (a `.string` value); see the
116
+ [tmux-bridge](https://carterbeaudoin.net/CAR-TER) server for a complete example.
89
117
 
90
118
  ## Examples
91
119
 
@@ -127,6 +155,21 @@ Inherits all [[shared-properties]]. Key fields:
127
155
  }
128
156
  ```
129
157
 
158
+ ### Terminal snapshot (live pane)
159
+ ```json
160
+ {
161
+ "type": "label",
162
+ "id": "pane-snapshot",
163
+ "position": [1, 0],
164
+ "span": [5, 4],
165
+ "style": "terminal",
166
+ "scrollable": true,
167
+ "controlHeight": 360,
168
+ "text": "(waiting for pane…)",
169
+ "sync": [{ "method": "meshsocket", "type": "listen", "event": "broadcast", "filter": { "msg_type": "snapshot" }, "valuePath": "text" }]
170
+ }
171
+ ```
172
+
130
173
  ## Behavior
131
174
  - When synced, the `text` field serves as a placeholder until the first sync value arrives
132
175
  - Text updates animate with a smooth numeric content transition
@@ -75,6 +75,11 @@ themeFields:
75
75
 
76
76
  A scrolling terminal-style log viewer. Displays timestamped log lines pushed via sync, with color-coded severity levels and monospace rendering.
77
77
 
78
+ > For a full **ANSI terminal** — raw escape-code colour/bold, pane-width auto-scaling and
79
+ > pinch-to-zoom (e.g. mirroring a captured shell/tmux pane) — use a [[label]] with
80
+ > `style: "terminal"` instead. The log console is line-oriented (level colours), not an
81
+ > escape-code renderer.
82
+
78
83
  ## Type
79
84
  `"logConsole"`
80
85
 
@@ -22,6 +22,34 @@ fields:
22
22
  values: [default, search, multiline]
23
23
  default: default
24
24
  description: Display style variant
25
+ - name: sendButton
26
+ type: bool
27
+ default: false
28
+ description: Growing composer with a Send button (Return inserts a newline)
29
+ - name: minLines
30
+ type: number
31
+ default: 1
32
+ description: Composer starting line count
33
+ - name: maxLines
34
+ type: number
35
+ default: 6
36
+ description: Composer max lines before it scrolls internally
37
+ - name: autocorrect
38
+ type: bool
39
+ default: false
40
+ description: Default autocorrect/caps (off = ASCII keyboard, terminal-friendly)
41
+ - name: autocorrectToggle
42
+ type: bool
43
+ default: false
44
+ description: Inline Aa button that flips autocorrect on the live keyboard
45
+ - name: keyboard
46
+ type: enum
47
+ values: [ascii, default, url, email, numbers]
48
+ description: Keyboard type
49
+ - name: clearOnSubmit
50
+ type: bool
51
+ default: false
52
+ description: Clear the field after the return-key submit
25
53
  - name: tint
26
54
  type: color
27
55
  default: "#667eea"
@@ -87,6 +115,12 @@ Inherits all [[shared-properties]]. Key fields:
87
115
  | `defaultValue` | string | — | Initial text |
88
116
  | `style` | string | `"default"` | `"default"`, `"search"`, `"multiline"` |
89
117
  | `tint` | string | `"#667eea"` | Accent color |
118
+ | `sendButton` | bool | `false` | Turn the field into a growing composer with a Send button (Return = newline) |
119
+ | `minLines` | int | `1` | Composer starting line count |
120
+ | `maxLines` | int | `6` | Composer max lines before it scrolls internally |
121
+ | `autocorrect` | bool | `false` | Default autocorrect/caps; off ⇒ ASCII keyboard (terminal-friendly) |
122
+ | `autocorrectToggle` | bool | `false` | Show the inline **Aa** toggle |
123
+ | `keyboard` | string | — | `"ascii"`, `"default"`, `"url"`, `"email"`, `"numbers"` |
90
124
  | `clearOnSubmit` | bool | `false` | Clear the field after the return-key submit (for entry forms where the typed value shouldn't linger) |
91
125
  | `hideBackground` | bool | `false` | Remove glass background |
92
126
  | `hideLabel` | bool | `false` | Hide the header label |
@@ -102,6 +136,23 @@ Search-style field with magnifying glass icon and clear button.
102
136
  ### `"multiline"`
103
137
  Expands vertically for multi-line text entry.
104
138
 
139
+ ## Composer & keyboard
140
+
141
+ Set `sendButton: true` to turn the field into a **growing composer**: it grows from `minLines`
142
+ up to `maxLines` then scrolls inside, **Return inserts a newline**, and a **Send** button
143
+ submits. Ideal for chat or command entry.
144
+
145
+ Keyboard behaviour, tuned for command entry:
146
+
147
+ - **`autocorrect`** defaults to `false` — autocorrect/autocapitalization off with an ASCII
148
+ keyboard, so typed commands aren't "corrected." Set `true` for prose. `keyboard` overrides
149
+ the keyboard type explicitly.
150
+ - **`autocorrectToggle`** shows an inline **Aa** button that flips autocorrect at runtime and
151
+ reconfigures the **live** keyboard immediately (no refocus needed). It also appears on the
152
+ above-keyboard bar.
153
+ - An above-keyboard **Hide** button lets you dismiss the keyboard to reach the
154
+ controls/tabs underneath it.
155
+
105
156
  ## Example
106
157
 
107
158
  ```json
@@ -117,8 +168,26 @@ Expands vertically for multi-line text entry.
117
168
  }
118
169
  ```
119
170
 
171
+ ### Command composer
172
+ ```json
173
+ {
174
+ "type": "textInput",
175
+ "id": "pane-input",
176
+ "position": [7, 0],
177
+ "span": [1, 4],
178
+ "placeholder": "type a command…",
179
+ "icon": "chevron.right",
180
+ "sendButton": true,
181
+ "autocorrectToggle": true,
182
+ "autocorrect": false,
183
+ "maxLines": 6,
184
+ "action": { "method": "meshsocket", "mode": "broadcast", "event": "broadcast", "payload": { "msg_type": "command", "text": "{{value}}" } }
185
+ }
186
+ ```
187
+
120
188
  ## Behavior
121
- - Action fires when the user presses return/submit
189
+ - Default field: the action fires when the user presses return/submit
190
+ - Composer (`sendButton: true`): **Send** submits and **Return inserts a newline**
122
191
  - The `{{value}}` placeholder is replaced with the current text content
123
192
 
124
193
  ## Related
@@ -19,14 +19,27 @@ def _nonce(counter: int) -> bytes:
19
19
 
20
20
 
21
21
  class E2EESession:
22
- def __init__(self, secret: bytes, is_device_side: bool, seal_salt: bytes = None):
22
+ def __init__(self, secret: bytes, is_device_side: bool = False, seal_salt: bytes = None,
23
+ is_group: bool = False):
23
24
  self._secret = secret
24
- self._seal_info = b"d2c v2" if is_device_side else b"c2d v2"
25
- self._open_info = b"c2d v2" if is_device_side else b"d2c v2"
25
+ if is_group:
26
+ # Symmetric room mode: every member seals AND opens with the same "grp v2" label,
27
+ # so any member reads any other. Multi-sender nonce reuse is prevented because each
28
+ # sender carries an independent per-session salt (in the envelope).
29
+ self._seal_info = self._open_info = b"grp v2"
30
+ else:
31
+ self._seal_info = b"d2c v2" if is_device_side else b"c2d v2"
32
+ self._open_info = b"c2d v2" if is_device_side else b"d2c v2"
26
33
  self._seal_salt = seal_salt if seal_salt is not None else os.urandom(16)
27
34
  self._seal_key = derive_key(secret, self._seal_salt, self._seal_info)
28
35
  self._counter = 0
29
36
 
37
+ @classmethod
38
+ def group(cls, secret: bytes, seal_salt: bytes = None) -> "E2EESession":
39
+ """Symmetric room session matching the app's `mode: room` group cipher — used by a
40
+ hub that shares an encrypted room with several members."""
41
+ return cls(secret, is_group=True, seal_salt=seal_salt)
42
+
30
43
  def seal(self, payload: dict) -> dict:
31
44
  n = self._counter
32
45
  self._counter += 1
@@ -0,0 +1,101 @@
1
+ """Run a tiny embedded MeshSocket relay for local / LAN testing of a layout.
2
+
3
+ The relay is the WebSocket hub both the app and your server connect to. For real
4
+ self-hosting you point at the Connect+ relay (``wss://relay…``); for a quick local
5
+ test this spins one up in-process so there's nothing else to run.
6
+
7
+ It refuses to start on a port that's already in use — the usual cause of a confusing
8
+ "connects then immediately drops" loop: a second relay silently fails to bind, its
9
+ hub lands on the *first* one, and two same-named hubs evict each other every couple
10
+ of seconds. Failing fast with a clear message beats debugging that.
11
+
12
+ from carterkit import LocalRelay, CarterClient
13
+
14
+ async with LocalRelay(port=8765, key="dev-key") as relay:
15
+ hub = CarterClient("ws://127.0.0.1:8765", token="dev-key",
16
+ channel="home", role="device")
17
+ await hub.connect()
18
+ ... # push values; the app connects to the same relay
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import socket as _socket
24
+
25
+ try: # ships with the meshsocket dependency
26
+ from socket_server import MeshServer
27
+ except ImportError: # pragma: no cover - environment guard
28
+ MeshServer = None
29
+
30
+
31
+ def lan_ip(override: str | None = None) -> str:
32
+ """This machine's LAN IP — the address to bake into a layout so a phone can reach
33
+ the relay. Returns `override` if given, or falls back to 127.0.0.1 when offline."""
34
+ if override:
35
+ return override
36
+ s = _socket.socket(_socket.AF_INET, _socket.SOCK_DGRAM)
37
+ try:
38
+ s.connect(("8.8.8.8", 80)) # no packets sent; just picks the egress interface
39
+ return s.getsockname()[0]
40
+ except OSError:
41
+ return "127.0.0.1"
42
+ finally:
43
+ s.close()
44
+
45
+
46
+ def port_in_use(port: int, host: str = "127.0.0.1") -> bool:
47
+ """True if something is already accepting connections on ``(host, port)``.
48
+
49
+ A 0.0.0.0 listener also answers on 127.0.0.1, so the default probe catches the
50
+ common "another relay is already running" case."""
51
+ probe = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
52
+ probe.settimeout(0.3)
53
+ try:
54
+ return probe.connect_ex((host if host not in ("", "0.0.0.0") else "127.0.0.1", port)) == 0
55
+ finally:
56
+ probe.close()
57
+
58
+
59
+ class LocalRelay:
60
+ """An in-process MeshSocket relay with shared-key auth, for local testing.
61
+
62
+ ``key`` is the shared key clients must present (``""`` runs open, no auth).
63
+ ``on_join(name, ip)`` fires when a client authenticates — handy for pushing a
64
+ fresh snapshot to a device the moment it connects. Use it as an async context
65
+ manager, or call :meth:`start` / :meth:`stop` yourself. Raises ``RuntimeError``
66
+ if the port is already in use and ``ImportError`` if the server isn't installed.
67
+ """
68
+
69
+ def __init__(self, port: int = 8765, key: str = "", host: str = "0.0.0.0", on_join=None):
70
+ if MeshServer is None:
71
+ raise ImportError("LocalRelay needs the MeshSocket server; run `pip install meshsocket`.")
72
+ self.port, self.key, self.host, self.on_join = port, key, host, on_join
73
+ self._server = None
74
+ self._task: asyncio.Task | None = None
75
+
76
+ async def start(self) -> "LocalRelay":
77
+ if port_in_use(self.port, self.host):
78
+ raise RuntimeError(
79
+ f"port {self.port} is already in use — another relay or tester is probably "
80
+ f"running. Stop it (e.g. `pkill -f tester.py`) or pass a different port.")
81
+ auth = (lambda tok, ip: tok == self.key) if self.key else (lambda tok, ip: True)
82
+ on_auth = None
83
+ if self.on_join:
84
+ on_auth = lambda client, ip, tok: self.on_join(getattr(client, "name", "?"), ip)
85
+ self._server = MeshServer(host=self.host, port=self.port,
86
+ auth_handler=auth, on_authenticated=on_auth)
87
+ self._task = asyncio.create_task(self._server.start())
88
+ await asyncio.sleep(0.5) # let the listener bind before clients dial in
89
+ return self
90
+
91
+ async def stop(self) -> None:
92
+ if self._task is not None:
93
+ self._task.cancel()
94
+ self._task = None
95
+
96
+ async def __aenter__(self) -> "LocalRelay":
97
+ return await self.start()
98
+
99
+ async def __aexit__(self, *exc) -> bool:
100
+ await self.stop()
101
+ return False