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.
@@ -0,0 +1,9 @@
1
+ # Python build + cache
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
@@ -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,6 @@
1
+ """IP-less peripheral actors for TIBET/AInternet."""
2
+
3
+ from .protocol import Frame, PeripheralSession, SessionError
4
+
5
+ __all__ = ["Frame", "PeripheralSession", "SessionError"]
6
+
@@ -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
+