tibet-peripheral 0.1.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.
- tibet_peripheral-0.1.0/.gitignore +9 -0
- tibet_peripheral-0.1.0/LICENSE +21 -0
- tibet_peripheral-0.1.0/PKG-INFO +91 -0
- tibet_peripheral-0.1.0/README.md +70 -0
- tibet_peripheral-0.1.0/docs/AINS_LOCAL_REGISTRY.md +67 -0
- tibet_peripheral-0.1.0/docs/ARCHITECTURE.md +85 -0
- tibet_peripheral-0.1.0/docs/CONFORMANCE.md +33 -0
- tibet_peripheral-0.1.0/docs/PUBLIC_URL_DEMO.md +48 -0
- tibet_peripheral-0.1.0/docs/ROADMAP.md +48 -0
- tibet_peripheral-0.1.0/docs/ROOT_IMPLEMENTATION_PLAN.md +175 -0
- tibet_peripheral-0.1.0/examples/arduino/light_node/light_node.ino +99 -0
- tibet_peripheral-0.1.0/examples/light.aint.local-ains.json +23 -0
- tibet_peripheral-0.1.0/examples/light.aint.route.json +32 -0
- tibet_peripheral-0.1.0/pyproject.toml +36 -0
- tibet_peripheral-0.1.0/src/tibet_peripheral/__init__.py +6 -0
- tibet_peripheral-0.1.0/src/tibet_peripheral/bridge.py +105 -0
- tibet_peripheral-0.1.0/src/tibet_peripheral/cli.py +33 -0
- tibet_peripheral-0.1.0/src/tibet_peripheral/protocol.py +79 -0
- tibet_peripheral-0.1.0/src/tibet_peripheral/route.py +51 -0
- tibet_peripheral-0.1.0/src/tibet_peripheral/serial_line.py +72 -0
- tibet_peripheral-0.1.0/tests/test_protocol.py +36 -0
- tibet_peripheral-0.1.0/tests/test_route.py +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jasper van de Meent (Humotica) and the AInternet contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tibet-peripheral
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: IP-less peripheral actors for TIBET/AInternet: serial, Bluetooth, BLE, LoRa and smart-device executors bound under a box/root.
|
|
5
|
+
Author-email: Jasper van de Meent <jasper@humotica.nl>, Codex <codex@humotica.nl>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ainternet,arduino,ble,bluetooth,iot,peripheral,rvp,serial,tibet
|
|
9
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Communications
|
|
17
|
+
Classifier: Topic :: Home Automation
|
|
18
|
+
Classifier: Topic :: System :: Hardware
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# tibet-peripheral
|
|
23
|
+
|
|
24
|
+
IP-less peripheral actors for TIBET/AInternet.
|
|
25
|
+
|
|
26
|
+
This package is a sandbox-born skeleton for devices that cannot, should not, or
|
|
27
|
+
must not speak IP themselves: Arduino lights, cheap toys, BLE buttons, LoRa
|
|
28
|
+
sensors, smart devices, RVP peripherals, doorbells, relays, and other physical
|
|
29
|
+
actuators.
|
|
30
|
+
|
|
31
|
+
The peripheral is not the root of trust. The box/bridge is the cryptographic and
|
|
32
|
+
policy authority. The peripheral is a bound executor that must stay in the
|
|
33
|
+
causal rhythm.
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
internet / overlay / cmail / local button
|
|
37
|
+
-> box or bridge verifies intent, grant, presence, and relation
|
|
38
|
+
-> tiny peripheral frame over serial / BT / BLE / LoRa
|
|
39
|
+
-> IP-less device acts
|
|
40
|
+
-> ACK returns
|
|
41
|
+
-> bridge emits receipt
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Core Rule
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
The peripheral does not have to calculate trust.
|
|
48
|
+
It has to stay in time.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
If the tether breaks, the session is dead. When it comes back, it must rebind.
|
|
52
|
+
That makes uptime and sequence continuity part of the safety model.
|
|
53
|
+
|
|
54
|
+
## What Exists Here
|
|
55
|
+
|
|
56
|
+
- `tibet_peripheral.protocol` — tiny line frame codec and session guard.
|
|
57
|
+
- `tibet_peripheral.serial_line` — POSIX serial line helper, stdlib only.
|
|
58
|
+
- `tibet_peripheral.bridge` — local HTTP bridge for light control.
|
|
59
|
+
- `examples/arduino/light_node/light_node.ino` — Arduino Uno onboard LED sketch.
|
|
60
|
+
- `docs/ARCHITECTURE.md` — doctrine and binding model.
|
|
61
|
+
- `docs/ROADMAP.md` — route toward real TIBET overlay integration.
|
|
62
|
+
|
|
63
|
+
## Proof Of Concept
|
|
64
|
+
|
|
65
|
+
For a classic Arduino Uno R3 / ATmega328P:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python3 -m tibet_peripheral.bridge --serial /dev/ttyUSB0
|
|
69
|
+
curl http://127.0.0.1:8765/light/l
|
|
70
|
+
curl http://127.0.0.1:8765/light/on
|
|
71
|
+
curl http://127.0.0.1:8765/light/off
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The Arduino has no IP address. The bridge does.
|
|
75
|
+
|
|
76
|
+
For an already-flashed sketch that reacts to commands but does not print ACK
|
|
77
|
+
lines, start in fire-and-watch mode:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
python3 -m tibet_peripheral.bridge --serial /dev/ttyUSB0 --no-ack
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
That proves reachability. For causal receipts, flash the example firmware so the
|
|
84
|
+
device returns `OK ...` after each command.
|
|
85
|
+
|
|
86
|
+
## Naming
|
|
87
|
+
|
|
88
|
+
`light.aint` or `jis:peripheral:light:<hash>` names the routed actor. The
|
|
89
|
+
Arduino itself only sees a short session id and monotonic sequence. The public
|
|
90
|
+
`.aint` is a route/identity wrapper owned by the box or app relation, not a
|
|
91
|
+
claim that an 8-bit MCU runs the whole trust stack.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# tibet-peripheral
|
|
2
|
+
|
|
3
|
+
IP-less peripheral actors for TIBET/AInternet.
|
|
4
|
+
|
|
5
|
+
This package is a sandbox-born skeleton for devices that cannot, should not, or
|
|
6
|
+
must not speak IP themselves: Arduino lights, cheap toys, BLE buttons, LoRa
|
|
7
|
+
sensors, smart devices, RVP peripherals, doorbells, relays, and other physical
|
|
8
|
+
actuators.
|
|
9
|
+
|
|
10
|
+
The peripheral is not the root of trust. The box/bridge is the cryptographic and
|
|
11
|
+
policy authority. The peripheral is a bound executor that must stay in the
|
|
12
|
+
causal rhythm.
|
|
13
|
+
|
|
14
|
+
```text
|
|
15
|
+
internet / overlay / cmail / local button
|
|
16
|
+
-> box or bridge verifies intent, grant, presence, and relation
|
|
17
|
+
-> tiny peripheral frame over serial / BT / BLE / LoRa
|
|
18
|
+
-> IP-less device acts
|
|
19
|
+
-> ACK returns
|
|
20
|
+
-> bridge emits receipt
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Core Rule
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
The peripheral does not have to calculate trust.
|
|
27
|
+
It has to stay in time.
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If the tether breaks, the session is dead. When it comes back, it must rebind.
|
|
31
|
+
That makes uptime and sequence continuity part of the safety model.
|
|
32
|
+
|
|
33
|
+
## What Exists Here
|
|
34
|
+
|
|
35
|
+
- `tibet_peripheral.protocol` — tiny line frame codec and session guard.
|
|
36
|
+
- `tibet_peripheral.serial_line` — POSIX serial line helper, stdlib only.
|
|
37
|
+
- `tibet_peripheral.bridge` — local HTTP bridge for light control.
|
|
38
|
+
- `examples/arduino/light_node/light_node.ino` — Arduino Uno onboard LED sketch.
|
|
39
|
+
- `docs/ARCHITECTURE.md` — doctrine and binding model.
|
|
40
|
+
- `docs/ROADMAP.md` — route toward real TIBET overlay integration.
|
|
41
|
+
|
|
42
|
+
## Proof Of Concept
|
|
43
|
+
|
|
44
|
+
For a classic Arduino Uno R3 / ATmega328P:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
python3 -m tibet_peripheral.bridge --serial /dev/ttyUSB0
|
|
48
|
+
curl http://127.0.0.1:8765/light/l
|
|
49
|
+
curl http://127.0.0.1:8765/light/on
|
|
50
|
+
curl http://127.0.0.1:8765/light/off
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The Arduino has no IP address. The bridge does.
|
|
54
|
+
|
|
55
|
+
For an already-flashed sketch that reacts to commands but does not print ACK
|
|
56
|
+
lines, start in fire-and-watch mode:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
python3 -m tibet_peripheral.bridge --serial /dev/ttyUSB0 --no-ack
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
That proves reachability. For causal receipts, flash the example firmware so the
|
|
63
|
+
device returns `OK ...` after each command.
|
|
64
|
+
|
|
65
|
+
## Naming
|
|
66
|
+
|
|
67
|
+
`light.aint` or `jis:peripheral:light:<hash>` names the routed actor. The
|
|
68
|
+
Arduino itself only sees a short session id and monotonic sequence. The public
|
|
69
|
+
`.aint` is a route/identity wrapper owned by the box or app relation, not a
|
|
70
|
+
claim that an 8-bit MCU runs the whole trust stack.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Local AINS Registry For Peripherals
|
|
2
|
+
|
|
3
|
+
`light.aint` does not have to be a central public AINS identity first.
|
|
4
|
+
|
|
5
|
+
For peripherals, the correct order is:
|
|
6
|
+
|
|
7
|
+
```text
|
|
8
|
+
local binding -> local route -> optional public alias/window
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
A cheap device, toy, button, light, or RVP peripheral often has no secure key and
|
|
14
|
+
no IP address. Publishing it centrally as if it were a full actor lies about the
|
|
15
|
+
floor.
|
|
16
|
+
|
|
17
|
+
Instead, the box can locally register:
|
|
18
|
+
|
|
19
|
+
```text
|
|
20
|
+
light.aint = this bound peripheral route under jasper.aint
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That local record is enough for:
|
|
24
|
+
|
|
25
|
+
- cockpit/iab-tui display;
|
|
26
|
+
- overlay route decision;
|
|
27
|
+
- public URL policy mapping;
|
|
28
|
+
- receipts;
|
|
29
|
+
- offline/dormant state.
|
|
30
|
+
|
|
31
|
+
## Central AINS Role
|
|
32
|
+
|
|
33
|
+
Central AINS may later publish a discoverable alias:
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
jasper-light-<hash>.aint
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
or a human-friendly temporary alias:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
camp-light-demo.aint
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
But the central registry should be a directory/window, not the trust root.
|
|
46
|
+
|
|
47
|
+
## Record Split
|
|
48
|
+
|
|
49
|
+
Use two records:
|
|
50
|
+
|
|
51
|
+
1. local AINS/alias entry says the name exists and points to a route record;
|
|
52
|
+
2. route record says how this box can currently reach it.
|
|
53
|
+
|
|
54
|
+
This avoids pretending that a peripheral has an internet endpoint.
|
|
55
|
+
|
|
56
|
+
## No-IP Invariant
|
|
57
|
+
|
|
58
|
+
For this class of actor:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"device_ip": null
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
is a feature, not missing data.
|
|
67
|
+
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
Smart devices normally become reachable by gaining an IP stack or joining a
|
|
6
|
+
vendor cloud. That expands the attack surface and moves authority away from the
|
|
7
|
+
owner.
|
|
8
|
+
|
|
9
|
+
TIBET peripheral flips this:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
the bridge is online
|
|
13
|
+
the peripheral is not
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The peripheral is reachable through a box/root relation, not through a public
|
|
17
|
+
socket on the device.
|
|
18
|
+
|
|
19
|
+
## Roles
|
|
20
|
+
|
|
21
|
+
| Role | Meaning |
|
|
22
|
+
|---|---|
|
|
23
|
+
| Peripheral | Tiny actuator/sensor. May be 8-bit and unable to run modern crypto. |
|
|
24
|
+
| Bridge | Phone/laptop/box carrier with IP/overlay access and local tether to peripheral. |
|
|
25
|
+
| Box/root | Authority for identity, grants, receipts, and policy. |
|
|
26
|
+
| `.aint` | Route/identity name for the peripheral relation, e.g. `light.aint`. |
|
|
27
|
+
|
|
28
|
+
## Binding
|
|
29
|
+
|
|
30
|
+
The owner can choose to trust a peripheral enough to read or actuate it.
|
|
31
|
+
|
|
32
|
+
That binding may come from:
|
|
33
|
+
|
|
34
|
+
- physical plug-in;
|
|
35
|
+
- Bluetooth pairing;
|
|
36
|
+
- app-based vendor pairing;
|
|
37
|
+
- QR/NFC/tap;
|
|
38
|
+
- local button gesture;
|
|
39
|
+
- serial proof;
|
|
40
|
+
- a signed card or bridge receipt.
|
|
41
|
+
|
|
42
|
+
The point is not that the peripheral proves everything. The point is that the
|
|
43
|
+
box records: this peripheral is bound under this owner/root and may perform
|
|
44
|
+
these bounded actions while the tether is alive.
|
|
45
|
+
|
|
46
|
+
## Causal Continuity
|
|
47
|
+
|
|
48
|
+
For very constrained devices, uptime and tether continuity are part of the
|
|
49
|
+
safety model.
|
|
50
|
+
|
|
51
|
+
```text
|
|
52
|
+
attached -> alive -> acts in monotonic sequence
|
|
53
|
+
tether breaks -> dormant/unproven
|
|
54
|
+
returns later -> rebind required
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
A tiny device does not get to silently resume high-trust authority after being
|
|
58
|
+
offline. The bridge should start a new session id and require an explicit
|
|
59
|
+
rebinding gesture or policy decision.
|
|
60
|
+
|
|
61
|
+
## Why Not Full Crypto On Arduino Uno
|
|
62
|
+
|
|
63
|
+
ATmega328P is 8-bit, 16 MHz, with 2 KB SRAM. Full Ed25519/X25519/TBZ is possible
|
|
64
|
+
only with painful constraints and is not the right proof.
|
|
65
|
+
|
|
66
|
+
Better split:
|
|
67
|
+
|
|
68
|
+
```text
|
|
69
|
+
box/bridge: crypto, policy, relation, receipt
|
|
70
|
+
peripheral: sequence, allowed command, ACK rhythm
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
For ESP32, nRF52840, RP2040 plus radio, or secure-element boards, a stronger
|
|
74
|
+
device-native key can be added later.
|
|
75
|
+
|
|
76
|
+
## Public URL
|
|
77
|
+
|
|
78
|
+
A public URL such as `/light/on` must not mean the peripheral is public. It means
|
|
79
|
+
the public surface reaches a policy gate which may issue a bounded command to a
|
|
80
|
+
bound peripheral.
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
public URL -> intent gate -> box policy -> bridge -> peripheral frame -> ACK -> receipt
|
|
84
|
+
```
|
|
85
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Conformance Seeds
|
|
2
|
+
|
|
3
|
+
## P1: No-IP Peripheral
|
|
4
|
+
|
|
5
|
+
Given `light.aint` resolves to a peripheral route, the route record must carry
|
|
6
|
+
`device_ip: null`. A conforming implementation must not invent an IP endpoint
|
|
7
|
+
for the device.
|
|
8
|
+
|
|
9
|
+
## P2: Local Authority
|
|
10
|
+
|
|
11
|
+
Given a central AINS record exists for `light.aint`, local box route authority
|
|
12
|
+
still decides whether the peripheral is attached and commandable.
|
|
13
|
+
|
|
14
|
+
## P3: Capability Gate
|
|
15
|
+
|
|
16
|
+
Given a caller requests `light.set:on`, the gate must deny if the route lacks
|
|
17
|
+
`light.set` or if caller grant/presence is insufficient.
|
|
18
|
+
|
|
19
|
+
## P4: Public URL Is Policy Surface
|
|
20
|
+
|
|
21
|
+
Given `/light/on`, the public handler must map to a fixed intent and target. It
|
|
22
|
+
must not forward arbitrary path text to serial.
|
|
23
|
+
|
|
24
|
+
## P5: Offline Rebind
|
|
25
|
+
|
|
26
|
+
Given the serial/BT tether breaks, state must become `dormant` or `unproven`.
|
|
27
|
+
Later reappearance must create a new session and require a rebind gesture/policy.
|
|
28
|
+
|
|
29
|
+
## P6: ACK Ladder
|
|
30
|
+
|
|
31
|
+
`sent-no-ack` is allowed for reachability demos only. Stronger actions require
|
|
32
|
+
firmware ACK and signed bridge receipt.
|
|
33
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Public URL Demo
|
|
2
|
+
|
|
3
|
+
The demo sentence:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
ainternet.org/light/on turns on an IP-less light.
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The security sentence:
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
The URL does not reach the Arduino. It reaches a policy gate that may command a
|
|
13
|
+
locally bound peripheral.
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Flow
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
GET /light/on
|
|
20
|
+
-> route target: light.aint
|
|
21
|
+
-> intent: light.set:on
|
|
22
|
+
-> resolve local peripheral route
|
|
23
|
+
-> check grant/presence/posture
|
|
24
|
+
-> call local bridge on 127.0.0.1
|
|
25
|
+
-> write receipt
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Demo JSON
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"ok": true,
|
|
33
|
+
"target": "light.aint",
|
|
34
|
+
"intent": "light.set:on",
|
|
35
|
+
"device_ip": null,
|
|
36
|
+
"route": "ainternet.org -> policy gate -> .109 bridge -> usb-serial",
|
|
37
|
+
"receipt_mode": "sent-no-ack",
|
|
38
|
+
"note": "peripheral is IP-less; bridge is the carrier"
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Must Not
|
|
43
|
+
|
|
44
|
+
- Do not expose `127.0.0.1:8766` publicly.
|
|
45
|
+
- Do not let URL path choose arbitrary serial commands.
|
|
46
|
+
- Do not call this a full device identity unless the peripheral holds a key.
|
|
47
|
+
- Do not silently resume after disconnect/reboot.
|
|
48
|
+
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
## v0: Local Proof
|
|
4
|
+
|
|
5
|
+
- USB serial or Bluetooth serial to Arduino Uno.
|
|
6
|
+
- Local HTTP bridge on `127.0.0.1`.
|
|
7
|
+
- Commands: `PING`, `L`, `ON`, `OFF`, `STATUS`.
|
|
8
|
+
- ACK receipt as JSON.
|
|
9
|
+
|
|
10
|
+
## v0.1: Box Carrier
|
|
11
|
+
|
|
12
|
+
- Run bridge on `.109`.
|
|
13
|
+
- Expose only localhost.
|
|
14
|
+
- Let IAB/overlay route call the bridge after sealed command verification.
|
|
15
|
+
- Record bridge receipt in the box ledger.
|
|
16
|
+
|
|
17
|
+
## v0.2: Session Binding
|
|
18
|
+
|
|
19
|
+
- On attach, create `session_id`.
|
|
20
|
+
- Require boot banner or local gesture.
|
|
21
|
+
- Monotonic sequence enforced by bridge and optionally by firmware.
|
|
22
|
+
- Any disconnect/reboot marks peripheral `dormant/unproven`.
|
|
23
|
+
|
|
24
|
+
## v0.3: Capability Grants
|
|
25
|
+
|
|
26
|
+
- Map public actions to grants:
|
|
27
|
+
- `light.read`
|
|
28
|
+
- `light.toggle`
|
|
29
|
+
- `light.set`
|
|
30
|
+
- `sensor.read`
|
|
31
|
+
- Add relation assurance requirement per action.
|
|
32
|
+
|
|
33
|
+
## v0.4: Carriers
|
|
34
|
+
|
|
35
|
+
- USB serial.
|
|
36
|
+
- RFCOMM / HC-05 style Bluetooth serial.
|
|
37
|
+
- BLE GATT characteristic.
|
|
38
|
+
- LoRa serial modem.
|
|
39
|
+
- Android companion app carrier.
|
|
40
|
+
|
|
41
|
+
## v1: PyPI Candidate
|
|
42
|
+
|
|
43
|
+
- Remove `Private :: Do Not Upload`.
|
|
44
|
+
- Add formal schemas.
|
|
45
|
+
- Add conformance vectors.
|
|
46
|
+
- Add signed receipt integration.
|
|
47
|
+
- Add examples for ESP32/nRF boards.
|
|
48
|
+
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Root Implementation Plan
|
|
2
|
+
|
|
3
|
+
This is the handoff for promoting the live `light.aint` PoC from sandbox bridge
|
|
4
|
+
to box/overlay integration.
|
|
5
|
+
|
|
6
|
+
Current live proof on `.109`:
|
|
7
|
+
|
|
8
|
+
```text
|
|
9
|
+
127.0.0.1:8766/light/l
|
|
10
|
+
-> tibet-peripheral bridge
|
|
11
|
+
-> /dev/ttyUSB0
|
|
12
|
+
-> Arduino Uno R3 / ATmega328P
|
|
13
|
+
-> onboard LED TX/activity
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The current Arduino sketch is fire-and-watch: it reacts enough to show TX/serial
|
|
17
|
+
activity, but does not return ACK lines. The bridge therefore runs with
|
|
18
|
+
`--no-ack` and emits `receipt_mode=sent-no-ack`.
|
|
19
|
+
|
|
20
|
+
## Goal
|
|
21
|
+
|
|
22
|
+
Make `light.aint` a local-first peripheral actor:
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
public or overlay request
|
|
26
|
+
-> policy/grant gate
|
|
27
|
+
-> local route resolve: light.aint -> peripheral route
|
|
28
|
+
-> bridge action: /light/on|off|l|status
|
|
29
|
+
-> receipt
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The Arduino must remain IP-less.
|
|
33
|
+
|
|
34
|
+
## Core Work
|
|
35
|
+
|
|
36
|
+
1. Add local peripheral route store.
|
|
37
|
+
|
|
38
|
+
Suggested path:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
$RUN/peripherals/<aint>.route.json
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Record shape:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"kind": "org.tibet.peripheral.route.v1",
|
|
49
|
+
"aint": "light.aint",
|
|
50
|
+
"owner": "jasper.aint",
|
|
51
|
+
"bridge": {
|
|
52
|
+
"url": "http://127.0.0.1:8766",
|
|
53
|
+
"carrier": "usb-serial",
|
|
54
|
+
"serial": "/dev/ttyUSB0",
|
|
55
|
+
"device_ip": null
|
|
56
|
+
},
|
|
57
|
+
"state": "attached",
|
|
58
|
+
"assurance": "local-usb-tether",
|
|
59
|
+
"capabilities": ["light.read", "light.toggle", "light.set"],
|
|
60
|
+
"offline_rule": "disconnect_or_reboot_requires_rebind"
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
2. Teach local AINS/known resolver about peripheral records.
|
|
65
|
+
|
|
66
|
+
`light.aint` should resolve locally even if it is never published centrally.
|
|
67
|
+
Central AINS may later advertise a route hint, but the authority is local.
|
|
68
|
+
|
|
69
|
+
Resolver output should make the distinction explicit:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"aint": "light.aint",
|
|
74
|
+
"entity_type": "peripheral",
|
|
75
|
+
"authority": "local-box-root",
|
|
76
|
+
"route_kind": "org.tibet.peripheral.route.v1",
|
|
77
|
+
"device_ip": null,
|
|
78
|
+
"state": "attached"
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
3. Add bridge action adapter.
|
|
83
|
+
|
|
84
|
+
Minimal mapping:
|
|
85
|
+
|
|
86
|
+
```text
|
|
87
|
+
light.read -> GET http://127.0.0.1:8766/light/status
|
|
88
|
+
light.toggle -> GET http://127.0.0.1:8766/light/l
|
|
89
|
+
light.set:on -> GET http://127.0.0.1:8766/light/on
|
|
90
|
+
light.set:off -> GET http://127.0.0.1:8766/light/off
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
4. Add policy gate before action.
|
|
94
|
+
|
|
95
|
+
Do not let public URL call the bridge directly. Public URL calls a policy gate.
|
|
96
|
+
|
|
97
|
+
Required checks:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
target resolves locally to peripheral route
|
|
101
|
+
route.state == attached
|
|
102
|
+
caller/owner grant allows capability
|
|
103
|
+
presence/posture requirement for this action satisfied
|
|
104
|
+
bridge URL is localhost or box-owned
|
|
105
|
+
command is in route.capabilities
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
5. Emit receipts.
|
|
109
|
+
|
|
110
|
+
Current receipt mode:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"target": "light.aint",
|
|
115
|
+
"device_ip": null,
|
|
116
|
+
"receipt_mode": "sent-no-ack",
|
|
117
|
+
"seq": 7,
|
|
118
|
+
"session": "8391aa2c",
|
|
119
|
+
"command": "ON"
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
After firmware ACK:
|
|
124
|
+
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"target": "light.aint",
|
|
128
|
+
"device_ip": null,
|
|
129
|
+
"receipt_mode": "ack",
|
|
130
|
+
"seq": 8,
|
|
131
|
+
"session": "new-session",
|
|
132
|
+
"command": "ON",
|
|
133
|
+
"ack": "OK ON"
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
6. Offline/rebind rule.
|
|
138
|
+
|
|
139
|
+
If `/dev/ttyUSB0` disappears, bridge exits, Arduino reboots, or ACK rhythm
|
|
140
|
+
breaks:
|
|
141
|
+
|
|
142
|
+
```text
|
|
143
|
+
attached -> dormant/unproven
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Reattach requires a new session id and a binding gesture:
|
|
147
|
+
|
|
148
|
+
- physical plug-in event;
|
|
149
|
+
- local button/light gesture;
|
|
150
|
+
- operator confirmation;
|
|
151
|
+
- future BT pairing proof.
|
|
152
|
+
|
|
153
|
+
## Public URL
|
|
154
|
+
|
|
155
|
+
`ainternet.org/light/on` is acceptable only as a demo/policy surface:
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
GET /light/on
|
|
159
|
+
-> maps to intent light.set:on
|
|
160
|
+
-> target light.aint
|
|
161
|
+
-> policy gate
|
|
162
|
+
-> local bridge
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
It must not expose the Arduino or bridge to the network.
|
|
166
|
+
|
|
167
|
+
## Later
|
|
168
|
+
|
|
169
|
+
- Flash ACK firmware from `examples/arduino/light_node/light_node.ino`.
|
|
170
|
+
- Add HMAC/counter mode if the peripheral gets a tiny secret.
|
|
171
|
+
- Add BLE/RFCOMM carrier.
|
|
172
|
+
- Add signed bridge receipts.
|
|
173
|
+
- Add conformance vectors for route resolve, no-IP invariant, offline rebind,
|
|
174
|
+
and public URL policy.
|
|
175
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// tibet-peripheral light node for Arduino Uno R3 / ATmega328P.
|
|
2
|
+
//
|
|
3
|
+
// It is not a crypto node. It is a tiny bound executor. The bridge verifies
|
|
4
|
+
// outer intent and sends only commands this peripheral is allowed to perform.
|
|
5
|
+
|
|
6
|
+
const int LED_PIN = LED_BUILTIN;
|
|
7
|
+
bool led_on = false;
|
|
8
|
+
String line = "";
|
|
9
|
+
|
|
10
|
+
void setLed(bool on) {
|
|
11
|
+
led_on = on;
|
|
12
|
+
digitalWrite(LED_PIN, on ? HIGH : LOW);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
void blinkMany(int count, int onMs, int offMs) {
|
|
16
|
+
bool restore = led_on;
|
|
17
|
+
for (int i = 0; i < count; i++) {
|
|
18
|
+
digitalWrite(LED_PIN, HIGH);
|
|
19
|
+
delay(onMs);
|
|
20
|
+
digitalWrite(LED_PIN, LOW);
|
|
21
|
+
delay(offMs);
|
|
22
|
+
}
|
|
23
|
+
digitalWrite(LED_PIN, restore ? HIGH : LOW);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
void proofL() {
|
|
27
|
+
blinkMany(5, 60, 60);
|
|
28
|
+
delay(220);
|
|
29
|
+
blinkMany(1, 180, 120);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
String field(String s, int idx) {
|
|
33
|
+
int start = 0;
|
|
34
|
+
for (int i = 0; i < idx; i++) {
|
|
35
|
+
start = s.indexOf('|', start);
|
|
36
|
+
if (start < 0) return "";
|
|
37
|
+
start++;
|
|
38
|
+
}
|
|
39
|
+
int end = s.indexOf('|', start);
|
|
40
|
+
if (end < 0) end = s.length();
|
|
41
|
+
return s.substring(start, end);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
void handleCommand(String raw) {
|
|
45
|
+
raw.trim();
|
|
46
|
+
raw.toUpperCase();
|
|
47
|
+
String cmd = raw;
|
|
48
|
+
|
|
49
|
+
// TPER1|session|seq|cmd|arg, but keep bare-command compatibility.
|
|
50
|
+
if (raw.startsWith("TPER1|")) {
|
|
51
|
+
cmd = field(raw, 3);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (cmd == "PING") {
|
|
55
|
+
Serial.println("OK PONG");
|
|
56
|
+
} else if (cmd == "L") {
|
|
57
|
+
proofL();
|
|
58
|
+
Serial.println("OK L");
|
|
59
|
+
} else if (cmd == "ON") {
|
|
60
|
+
setLed(true);
|
|
61
|
+
Serial.println("OK ON");
|
|
62
|
+
} else if (cmd == "OFF") {
|
|
63
|
+
setLed(false);
|
|
64
|
+
Serial.println("OK OFF");
|
|
65
|
+
} else if (cmd == "BLINK") {
|
|
66
|
+
proofL();
|
|
67
|
+
Serial.println("OK BLINK");
|
|
68
|
+
} else if (cmd == "STATUS") {
|
|
69
|
+
Serial.print("OK STATUS ");
|
|
70
|
+
Serial.println(led_on ? "ON" : "OFF");
|
|
71
|
+
} else if (cmd.length() == 0) {
|
|
72
|
+
// ignore empty line
|
|
73
|
+
} else {
|
|
74
|
+
Serial.println("ERR UNKNOWN");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
void setup() {
|
|
79
|
+
pinMode(LED_PIN, OUTPUT);
|
|
80
|
+
setLed(false);
|
|
81
|
+
Serial.begin(9600);
|
|
82
|
+
Serial.println("READY tibet-peripheral light-node v0");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
void loop() {
|
|
86
|
+
while (Serial.available() > 0) {
|
|
87
|
+
char c = (char)Serial.read();
|
|
88
|
+
if (c == '\n' || c == '\r') {
|
|
89
|
+
handleCommand(line);
|
|
90
|
+
line = "";
|
|
91
|
+
} else if (line.length() < 80) {
|
|
92
|
+
line += c;
|
|
93
|
+
} else {
|
|
94
|
+
line = "";
|
|
95
|
+
Serial.println("ERR TOO_LONG");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"domains": {
|
|
3
|
+
"light.aint": {
|
|
4
|
+
"status": "active",
|
|
5
|
+
"entity_type": "peripheral",
|
|
6
|
+
"owner": "jasper.aint",
|
|
7
|
+
"route_record": "examples/light.aint.route.json",
|
|
8
|
+
"capabilities": [
|
|
9
|
+
"light.read",
|
|
10
|
+
"light.toggle",
|
|
11
|
+
"light.set"
|
|
12
|
+
],
|
|
13
|
+
"route": {
|
|
14
|
+
"kind": "org.tibet.peripheral.route.v1",
|
|
15
|
+
"bridge": "local",
|
|
16
|
+
"carrier": "usb-serial",
|
|
17
|
+
"box_required": true
|
|
18
|
+
},
|
|
19
|
+
"note": "Local registry alias. The box/root remains authority; AINS is a lookup, not the trust root."
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "org.tibet.peripheral.route.v1",
|
|
3
|
+
"aint": "light.aint",
|
|
4
|
+
"owner": "jasper.aint",
|
|
5
|
+
"subject": {
|
|
6
|
+
"class": "peripheral",
|
|
7
|
+
"hardware": "Arduino Uno R3 / ATmega328P",
|
|
8
|
+
"description": "Onboard LED bound through JTM-T15/.109 serial bridge"
|
|
9
|
+
},
|
|
10
|
+
"bridge": {
|
|
11
|
+
"node": "JTM-T15",
|
|
12
|
+
"node_hint": "192.168.4.109",
|
|
13
|
+
"url": "http://127.0.0.1:8766",
|
|
14
|
+
"carrier": "usb-serial",
|
|
15
|
+
"serial": "/dev/ttyUSB0",
|
|
16
|
+
"device_ip": null
|
|
17
|
+
},
|
|
18
|
+
"state": "attached",
|
|
19
|
+
"assurance": "local-usb-tether",
|
|
20
|
+
"capabilities": [
|
|
21
|
+
"light.read",
|
|
22
|
+
"light.toggle",
|
|
23
|
+
"light.set"
|
|
24
|
+
],
|
|
25
|
+
"offline_rule": "disconnect_or_reboot_requires_rebind",
|
|
26
|
+
"public_url_policy": {
|
|
27
|
+
"allowed": true,
|
|
28
|
+
"example": "/light/on",
|
|
29
|
+
"note": "public URL reaches a policy gate; the Arduino is not public"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling==1.27.0"] # pin: hatchling >=1.28 emits Metadata-2.5, which older PyPI/pip trip over
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tibet-peripheral"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "IP-less peripheral actors for TIBET/AInternet: serial, Bluetooth, BLE, LoRa and smart-device executors bound under a box/root."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Jasper van de Meent", email = "jasper@humotica.nl" },
|
|
14
|
+
{ name = "Codex", email = "codex@humotica.nl" },
|
|
15
|
+
]
|
|
16
|
+
keywords = ["tibet", "ainternet", "iot", "bluetooth", "ble", "serial", "arduino", "rvp", "peripheral"]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Topic :: Communications",
|
|
26
|
+
"Topic :: Home Automation",
|
|
27
|
+
"Topic :: System :: Hardware",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
tibet-peripheral = "tibet_peripheral.cli:main"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/tibet_peripheral"]
|
|
36
|
+
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Local HTTP bridge for an IP-less light peripheral."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import secrets
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .protocol import PeripheralSession
|
|
12
|
+
from .serial_line import SerialLine
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LightBridge:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
serial: SerialLine,
|
|
19
|
+
actor: str = "light.aint",
|
|
20
|
+
session_id: str | None = None,
|
|
21
|
+
wait_ack: bool = True,
|
|
22
|
+
) -> None:
|
|
23
|
+
self.serial = serial
|
|
24
|
+
self.actor = actor
|
|
25
|
+
self.wait_ack = wait_ack
|
|
26
|
+
self.session = PeripheralSession(session_id or secrets.token_hex(4))
|
|
27
|
+
|
|
28
|
+
def command(self, cmd: str) -> dict:
|
|
29
|
+
frame = self.session.next_frame(cmd)
|
|
30
|
+
# Compatibility: old Arduino sketches may understand bare commands.
|
|
31
|
+
wire = cmd.upper() if cmd.upper() in {"PING", "L", "ON", "OFF", "BLINK", "STATUS"} else frame.encode()
|
|
32
|
+
ack = self.serial.line(wire, wait_ack=self.wait_ack)
|
|
33
|
+
self.session.accept_ack(frame, ack)
|
|
34
|
+
return {
|
|
35
|
+
"ok": True,
|
|
36
|
+
"target": self.actor,
|
|
37
|
+
"device_ip": None,
|
|
38
|
+
"receipt_mode": "ack" if self.wait_ack else "sent-no-ack",
|
|
39
|
+
"session": frame.session,
|
|
40
|
+
"seq": frame.seq,
|
|
41
|
+
"command": cmd.upper(),
|
|
42
|
+
"ack": ack,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
BRIDGE: Optional[LightBridge] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Handler(BaseHTTPRequestHandler):
|
|
50
|
+
def log_message(self, fmt: str, *args: object) -> None:
|
|
51
|
+
print("%s - %s" % (self.address_string(), fmt % args))
|
|
52
|
+
|
|
53
|
+
def _json(self, status: int, body: dict) -> None:
|
|
54
|
+
data = json.dumps(body, sort_keys=True).encode("utf-8")
|
|
55
|
+
self.send_response(status)
|
|
56
|
+
self.send_header("content-type", "application/json")
|
|
57
|
+
self.send_header("content-length", str(len(data)))
|
|
58
|
+
self.end_headers()
|
|
59
|
+
self.wfile.write(data)
|
|
60
|
+
|
|
61
|
+
def do_GET(self) -> None:
|
|
62
|
+
route = self.path.split("?", 1)[0].rstrip("/")
|
|
63
|
+
mapping = {
|
|
64
|
+
"/light/ping": "PING",
|
|
65
|
+
"/light/l": "L",
|
|
66
|
+
"/light/blink": "BLINK",
|
|
67
|
+
"/light/on": "ON",
|
|
68
|
+
"/light/off": "OFF",
|
|
69
|
+
"/light/status": "STATUS",
|
|
70
|
+
}
|
|
71
|
+
cmd = mapping.get(route)
|
|
72
|
+
if cmd is None:
|
|
73
|
+
self._json(404, {"ok": False, "error": "unknown route"})
|
|
74
|
+
return
|
|
75
|
+
if BRIDGE is None:
|
|
76
|
+
self._json(503, {"ok": False, "error": "bridge not ready"})
|
|
77
|
+
return
|
|
78
|
+
try:
|
|
79
|
+
self._json(200, BRIDGE.command(cmd))
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
self._json(502, {"ok": False, "target": BRIDGE.actor, "command": cmd, "error": str(exc)})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def run_http(serial_path: str, baud: int, host: str, port: int, actor: str, wait_ack: bool = True) -> None:
|
|
85
|
+
global BRIDGE
|
|
86
|
+
BRIDGE = LightBridge(SerialLine(serial_path, baud), actor=actor, wait_ack=wait_ack)
|
|
87
|
+
print(f"tibet-peripheral bridge: http://{host}:{port}/light/l -> {serial_path} as {actor}")
|
|
88
|
+
ThreadingHTTPServer((host, port), Handler).serve_forever()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str] | None = None) -> int:
|
|
92
|
+
ap = argparse.ArgumentParser()
|
|
93
|
+
ap.add_argument("--serial", required=True)
|
|
94
|
+
ap.add_argument("--baud", type=int, default=9600)
|
|
95
|
+
ap.add_argument("--host", default="127.0.0.1")
|
|
96
|
+
ap.add_argument("--port", type=int, default=8765)
|
|
97
|
+
ap.add_argument("--actor", default="light.aint")
|
|
98
|
+
ap.add_argument("--no-ack", action="store_true", help="send command and return without waiting for firmware ACK")
|
|
99
|
+
args = ap.parse_args(argv)
|
|
100
|
+
run_http(args.serial, args.baud, args.host, args.port, args.actor, wait_ack=not args.no_ack)
|
|
101
|
+
return 0
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
if __name__ == "__main__":
|
|
105
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""CLI entrypoint."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
|
|
7
|
+
from .bridge import main as bridge_main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main(argv: list[str] | None = None) -> int:
|
|
11
|
+
ap = argparse.ArgumentParser(prog="tibet-peripheral")
|
|
12
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
13
|
+
b = sub.add_parser("bridge")
|
|
14
|
+
b.add_argument("--serial", required=True)
|
|
15
|
+
b.add_argument("--baud", type=int, default=9600)
|
|
16
|
+
b.add_argument("--host", default="127.0.0.1")
|
|
17
|
+
b.add_argument("--port", type=int, default=8765)
|
|
18
|
+
b.add_argument("--actor", default="light.aint")
|
|
19
|
+
b.add_argument("--no-ack", action="store_true")
|
|
20
|
+
args = ap.parse_args(argv)
|
|
21
|
+
if args.cmd == "bridge":
|
|
22
|
+
return bridge_main([
|
|
23
|
+
"--serial", args.serial,
|
|
24
|
+
"--baud", str(args.baud),
|
|
25
|
+
"--host", args.host,
|
|
26
|
+
"--port", str(args.port),
|
|
27
|
+
"--actor", args.actor,
|
|
28
|
+
] + (["--no-ack"] if args.no_ack else []))
|
|
29
|
+
raise AssertionError(args.cmd)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
if __name__ == "__main__":
|
|
33
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Tiny causal frame protocol for constrained peripherals.
|
|
2
|
+
|
|
3
|
+
This is deliberately not full TIBET crypto. The bridge/box owns crypto and
|
|
4
|
+
policy. The peripheral receives a small command only after the bridge has
|
|
5
|
+
verified the outer sealed intent. The peripheral proves continuity by staying
|
|
6
|
+
inside a session id and monotonic sequence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MAGIC = "TPER1"
|
|
15
|
+
MAX_TOKEN = 48
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SessionError(ValueError):
|
|
19
|
+
"""Frame failed the local peripheral session guard."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Frame:
|
|
24
|
+
session: str
|
|
25
|
+
seq: int
|
|
26
|
+
cmd: str
|
|
27
|
+
arg: str = ""
|
|
28
|
+
|
|
29
|
+
def encode(self) -> str:
|
|
30
|
+
for name, value in (
|
|
31
|
+
("session", self.session),
|
|
32
|
+
("cmd", self.cmd),
|
|
33
|
+
("arg", self.arg),
|
|
34
|
+
):
|
|
35
|
+
if "|" in value or "\n" in value or "\r" in value:
|
|
36
|
+
raise ValueError(f"{name} contains a frame separator")
|
|
37
|
+
if len(value) > MAX_TOKEN:
|
|
38
|
+
raise ValueError(f"{name} too long")
|
|
39
|
+
if self.seq < 0:
|
|
40
|
+
raise ValueError("seq must be non-negative")
|
|
41
|
+
return f"{MAGIC}|{self.session}|{self.seq}|{self.cmd.upper()}|{self.arg}\n"
|
|
42
|
+
|
|
43
|
+
@classmethod
|
|
44
|
+
def decode(cls, line: str) -> "Frame":
|
|
45
|
+
parts = line.strip().split("|")
|
|
46
|
+
if len(parts) != 5 or parts[0] != MAGIC:
|
|
47
|
+
raise ValueError("not a TPER1 frame")
|
|
48
|
+
try:
|
|
49
|
+
seq = int(parts[2])
|
|
50
|
+
except ValueError as exc:
|
|
51
|
+
raise ValueError("bad seq") from exc
|
|
52
|
+
return cls(parts[1], seq, parts[3].upper(), parts[4])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class PeripheralSession:
|
|
56
|
+
"""Fail-closed continuity guard for one attached peripheral."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, session: str, allowed: set[str] | None = None) -> None:
|
|
59
|
+
if not session:
|
|
60
|
+
raise ValueError("session required")
|
|
61
|
+
self.session = session
|
|
62
|
+
self.last_seq = -1
|
|
63
|
+
self.allowed = allowed or {"PING", "L", "ON", "OFF", "BLINK", "STATUS"}
|
|
64
|
+
|
|
65
|
+
def next_frame(self, cmd: str, arg: str = "") -> Frame:
|
|
66
|
+
cmd = cmd.upper()
|
|
67
|
+
if cmd not in self.allowed:
|
|
68
|
+
raise SessionError(f"command not allowed: {cmd}")
|
|
69
|
+
return Frame(self.session, self.last_seq + 1, cmd, arg)
|
|
70
|
+
|
|
71
|
+
def accept_ack(self, frame: Frame, ack: str) -> None:
|
|
72
|
+
if frame.session != self.session:
|
|
73
|
+
raise SessionError("ack for wrong session")
|
|
74
|
+
if frame.seq != self.last_seq + 1:
|
|
75
|
+
raise SessionError("ack seq is not next")
|
|
76
|
+
if not ack.startswith("OK"):
|
|
77
|
+
raise SessionError(f"peripheral rejected command: {ack}")
|
|
78
|
+
self.last_seq = frame.seq
|
|
79
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Peripheral route records.
|
|
2
|
+
|
|
3
|
+
These records are intentionally local-first. AINS may help find a route, but the
|
|
4
|
+
box/root decides whether a peripheral is bound and what it may do.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class PeripheralRoute:
|
|
16
|
+
aint: str
|
|
17
|
+
owner: str
|
|
18
|
+
bridge_url: str
|
|
19
|
+
carrier: str
|
|
20
|
+
device_ip: None = None
|
|
21
|
+
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
|
22
|
+
state: str = "attached"
|
|
23
|
+
assurance: str = "local-tether"
|
|
24
|
+
|
|
25
|
+
@classmethod
|
|
26
|
+
def from_dict(cls, data: dict) -> "PeripheralRoute":
|
|
27
|
+
if data.get("kind") != "org.tibet.peripheral.route.v1":
|
|
28
|
+
raise ValueError("not a peripheral route v1 record")
|
|
29
|
+
aint = str(data.get("aint") or "")
|
|
30
|
+
owner = str(data.get("owner") or "")
|
|
31
|
+
bridge = data.get("bridge") or {}
|
|
32
|
+
if not aint or not owner or not bridge.get("url"):
|
|
33
|
+
raise ValueError("route record missing aint, owner, or bridge.url")
|
|
34
|
+
return cls(
|
|
35
|
+
aint=aint,
|
|
36
|
+
owner=owner,
|
|
37
|
+
bridge_url=str(bridge["url"]),
|
|
38
|
+
carrier=str(bridge.get("carrier") or "unknown"),
|
|
39
|
+
device_ip=None,
|
|
40
|
+
capabilities=tuple(data.get("capabilities") or ()),
|
|
41
|
+
state=str(data.get("state") or "attached"),
|
|
42
|
+
assurance=str(data.get("assurance") or "local-tether"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
def can(self, capability: str) -> bool:
|
|
46
|
+
return capability in self.capabilities
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def load_route(path: str | Path) -> PeripheralRoute:
|
|
50
|
+
return PeripheralRoute.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
|
|
51
|
+
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Small POSIX serial helper, stdlib only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import select
|
|
7
|
+
import termios
|
|
8
|
+
import time
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
BAUD = {
|
|
12
|
+
9600: termios.B9600,
|
|
13
|
+
19200: termios.B19200,
|
|
14
|
+
38400: termios.B38400,
|
|
15
|
+
57600: termios.B57600,
|
|
16
|
+
115200: termios.B115200,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SerialLine:
|
|
21
|
+
def __init__(self, path: str, baud: int = 9600, timeout: float = 2.0, reset_delay: float = 1.8) -> None:
|
|
22
|
+
if baud not in BAUD:
|
|
23
|
+
raise ValueError(f"unsupported baud: {baud}")
|
|
24
|
+
self.path = path
|
|
25
|
+
self.timeout = timeout
|
|
26
|
+
self.fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
|
|
27
|
+
attrs = termios.tcgetattr(self.fd)
|
|
28
|
+
attrs[0] = 0
|
|
29
|
+
attrs[1] = 0
|
|
30
|
+
attrs[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
|
|
31
|
+
attrs[3] = 0
|
|
32
|
+
attrs[4] = BAUD[baud]
|
|
33
|
+
attrs[5] = BAUD[baud]
|
|
34
|
+
attrs[6][termios.VMIN] = 0
|
|
35
|
+
attrs[6][termios.VTIME] = 0
|
|
36
|
+
termios.tcsetattr(self.fd, termios.TCSANOW, attrs)
|
|
37
|
+
if reset_delay:
|
|
38
|
+
time.sleep(reset_delay)
|
|
39
|
+
self.drain()
|
|
40
|
+
|
|
41
|
+
def drain(self) -> None:
|
|
42
|
+
while True:
|
|
43
|
+
ready, _, _ = select.select([self.fd], [], [], 0)
|
|
44
|
+
if not ready:
|
|
45
|
+
return
|
|
46
|
+
try:
|
|
47
|
+
if not os.read(self.fd, 4096):
|
|
48
|
+
return
|
|
49
|
+
except BlockingIOError:
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
def line(self, text: str, wait_ack: bool = True) -> str:
|
|
53
|
+
payload = text if text.endswith("\n") else text + "\n"
|
|
54
|
+
os.write(self.fd, payload.encode("ascii"))
|
|
55
|
+
if not wait_ack:
|
|
56
|
+
return "OK SENT_NO_ACK"
|
|
57
|
+
end = time.time() + self.timeout
|
|
58
|
+
buf = b""
|
|
59
|
+
while time.time() < end:
|
|
60
|
+
ready, _, _ = select.select([self.fd], [], [], 0.05)
|
|
61
|
+
if not ready:
|
|
62
|
+
continue
|
|
63
|
+
chunk = os.read(self.fd, 1024)
|
|
64
|
+
if not chunk:
|
|
65
|
+
continue
|
|
66
|
+
buf += chunk
|
|
67
|
+
if b"\n" in buf:
|
|
68
|
+
return buf.splitlines()[0].decode("ascii", errors="replace").strip()
|
|
69
|
+
raise TimeoutError(f"no ACK for {text.strip()}")
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
os.close(self.fd)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from tibet_peripheral.protocol import Frame, PeripheralSession, SessionError
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_frame_roundtrip():
|
|
5
|
+
f = Frame("abcd", 7, "on")
|
|
6
|
+
assert f.encode() == "TPER1|abcd|7|ON|\n"
|
|
7
|
+
assert Frame.decode(f.encode()) == Frame("abcd", 7, "ON", "")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_session_accepts_next_ack():
|
|
11
|
+
s = PeripheralSession("s1")
|
|
12
|
+
f = s.next_frame("L")
|
|
13
|
+
s.accept_ack(f, "OK L")
|
|
14
|
+
assert s.last_seq == 0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_session_rejects_bad_ack():
|
|
18
|
+
s = PeripheralSession("s1")
|
|
19
|
+
f = s.next_frame("ON")
|
|
20
|
+
try:
|
|
21
|
+
s.accept_ack(f, "ERR UNKNOWN")
|
|
22
|
+
except SessionError:
|
|
23
|
+
pass
|
|
24
|
+
else:
|
|
25
|
+
raise AssertionError("expected SessionError")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_disallow_command():
|
|
29
|
+
s = PeripheralSession("s1", allowed={"PING"})
|
|
30
|
+
try:
|
|
31
|
+
s.next_frame("ON")
|
|
32
|
+
except SessionError:
|
|
33
|
+
pass
|
|
34
|
+
else:
|
|
35
|
+
raise AssertionError("expected SessionError")
|
|
36
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from tibet_peripheral.route import PeripheralRoute, load_route
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_route_loads(tmp_path: Path):
|
|
8
|
+
p = tmp_path / "light.route.json"
|
|
9
|
+
p.write_text(json.dumps({
|
|
10
|
+
"kind": "org.tibet.peripheral.route.v1",
|
|
11
|
+
"aint": "light.aint",
|
|
12
|
+
"owner": "jasper.aint",
|
|
13
|
+
"bridge": {"url": "http://127.0.0.1:8766", "carrier": "usb-serial"},
|
|
14
|
+
"capabilities": ["light.read", "light.toggle"],
|
|
15
|
+
"state": "attached",
|
|
16
|
+
}))
|
|
17
|
+
route = load_route(p)
|
|
18
|
+
assert route.aint == "light.aint"
|
|
19
|
+
assert route.device_ip is None
|
|
20
|
+
assert route.can("light.toggle")
|
|
21
|
+
assert not route.can("door.unlock")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_route_rejects_wrong_kind():
|
|
25
|
+
try:
|
|
26
|
+
PeripheralRoute.from_dict({"kind": "x"})
|
|
27
|
+
except ValueError:
|
|
28
|
+
pass
|
|
29
|
+
else:
|
|
30
|
+
raise AssertionError("expected ValueError")
|
|
31
|
+
|