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.
- {carterkit-0.5.0 → carterkit-0.5.2}/PKG-INFO +1 -1
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/__init__.py +4 -1
- carterkit-0.5.2/carterkit/client.py +298 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/index.md +1 -1
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/label.md +46 -3
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/log-console.md +5 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/text-input.md +70 -1
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/e2ee.py +16 -3
- carterkit-0.5.2/carterkit/relay.py +101 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/PKG-INFO +1 -1
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/SOURCES.txt +1 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/pyproject.toml +1 -1
- carterkit-0.5.2/tests/test_client.py +273 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_e2ee.py +13 -0
- carterkit-0.5.0/carterkit/client.py +0 -146
- carterkit-0.5.0/tests/test_client.py +0 -153
- {carterkit-0.5.0 → carterkit-0.5.2}/LICENSE +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/README.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/__main__.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/bind.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/buffer.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/catalog.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/cli.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/codegen.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/accordion.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/actions.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/animations.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/appearance.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/button.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/cardList.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/carousel.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/chat.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/color-picker.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/control-def.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/date-picker.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/divider.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/flip-card.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/gauge.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/graph.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/grid-dimensions.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/group-def.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/haptics.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/image.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/joystick.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/layout-config.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/list.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/long-press.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/map.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/picker.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/privacy.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/progress-ring.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/pulse.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/qr-code.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/segmented.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/slider.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/spacer.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/sparkline.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/status-light.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/stepper.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/sync.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/terms.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/theming.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/toggle.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/visibility.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controldocs/web-view.md +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/controls.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/declare.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/dynamic.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/grid.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/infer.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/layout.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/py.typed +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/theming.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/tune.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit/validate.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/dependency_links.txt +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/entry_points.txt +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/requires.txt +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/carterkit.egg-info/top_level.txt +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/setup.cfg +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_bind.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_buffer.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_catalog.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_cli.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_codegen.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_controls.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_declare.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_dynamic.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_grid.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_infer.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_layout.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_theming.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_tune.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_ui.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_validate.py +0 -0
- {carterkit-0.5.0 → carterkit-0.5.2}/tests/test_validate_bindings.py +0 -0
|
@@ -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 **
|
|
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
|
-
-
|
|
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
|
-
|
|
25
|
-
|
|
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
|