nx_iot_scene_sync.nx source
↩ module page · 300 lines · 12480 B
1// nx_iot_scene_sync.nx -- the "lights stay in sync, self-fixing" organ.
2//
3// Roadmap: NISHI_IOT_HUB_ROADMAP.md Epoch 4 (SYNCHRONIZE). Closes the
4// operator's ORIGINAL complaint -- "the lights blink and desync all the
5// time" -- as a REPEATABLE, SELF-FIXING loop, not a one-off.
6//
7// THE DESYNC PROBLEM (operator, 2026-05-16 + 2026-06-20 "i still want the
8// lights to sync that are blinking as a repeatable self fixing thing"):
9// A scene (all lights = same colour/brightness/on) drifts apart because
10// (a) lights get the command at slightly different times (skew), and
11// (b) a light that drops WiFi + re-pairs comes back at its DEFAULT state
12// -- out of sync with the rest -- and stays wrong until a human re-sets it.
13//
14// THE FIX (two layers, both already in the hub):
15// 1. PREVENT skew: apply a scene ATOMICALLY -- all lights at the same
16// tick (nx_iot_clock_master drains a scene all-or-nothing). The
17// daemon wires that; this organ holds the SINGLE SOURCE OF TRUTH for
18// what the scene SHOULD be.
19// 2. SELF-FIX drift: this organ tracks, per device, which scene EPOCH it
20// last applied. A device that dropped + healed (nx_iot_watchdog ->
21// re-provision -> nx_iot_anchor re-adopt) is marked desynced; the loop
22// re-applies the current desired scene to it -> back in sync. Every
23// heal -> auto re-sync. REPEATABLE: each drop is fixed the same way,
24// and the MEASURED `syncs_restored` counts the manual re-sets spared.
25//
26// WHAT THIS ORGAN IS: the pure SYNC-STATE machine. desired scene + per
27// device applied-epoch + convergence (all_in_sync / needs_resync /
28// resync). No network, no device writes -- the driver (kasa pt_light
29// etc., already brick-safe) applies the computed scene; the clock master
30// makes it atomic; the watchdog triggers the heal. Pure-buffer, so it
31// gate-proves offline + is portable. Single-responsibility (#9): it owns
32// "are the lights in sync, and what brings a stray one back".
33//
34// MEASURED-EXCEED (feedback-no-wave-measured-exceed; mirrors anchor's
35// duplicates_prevented + watchdog's recover_count): `syncs_restored` is
36// the count of times a drifted light was auto-pulled back to the scene --
37// the manual re-sets the operator did NOT have to do. The gate asserts it.
38//
39// genealogy_id: NISHI_IOT_HUB_ROADMAP.md Epoch 4 + project-iot-hub-multi-vendor-kickoff-2026-05-16
40// license_tier: ORIGINAL
41//
42// nx_capability_claims:
43// needs: [pointer_arithmetic]
44// provides: [desired_scene_truth, per_device_sync_state,
45// detect_desync, auto_resync_on_heal, all_in_sync_check,
46// measured_syncs_restored, scene_change_resync]
47// safety: [no_unchecked_deref, no_floating_point, no_syscall,
48// bounded_iteration, no_device_write_in_this_organ, kind_isolated]
49// verdict: [sealed_enum_sync, no_silent_failure]
50// license: ORIGINAL
51// kind: iot_runtime_primitive
52// sss: [S0 (bit-equal scene state), S3 (atomic-scene foundation,
53// w/ clock master), S6 (no cloud), S7 (sealed-enum verdict)]
54
55// No imports. Pure caller-buffer; the daemon wires it to clock_master
56// (atomic apply) + watchdog (heal trigger) + the vendor drivers (apply).
57
58// ---- Sealed enum: sync verdict ------------------------------------
59
60const NX_IOT_SYNC_UNKNOWN: i64 = 0
61const NX_IOT_SYNC_OK: i64 = 1 // device matches the desired scene
62const NX_IOT_SYNC_NEEDS_RESYNC: i64 = 2 // device drifted -> re-apply needed
63const NX_IOT_SYNC_FULL: i64 = 3 // registry at capacity
64const NX_IOT_SYNC_NOT_FOUND: i64 = 4
65const NX_IOT_SYNC_BAD_ARG: i64 = 5
66const NX_IOT_SYNC_N: i64 = 6
67
68func nx_iot_sync_verdict_is_valid(v: i64) -> i64 {
69 if v < 0 { return 0 }
70 if v >= NX_IOT_SYNC_N { return 0 }
71 return 1
72}
73
74// An epoch a device has never matched (fresh / just-healed / dropped).
75const NX_IOT_SYNC_EPOCH_NONE: i64 = -1
76
77// ---- Desired scene + counters (caller allocates one) --------------
78//
79// The desired scene is the SINGLE SOURCE OF TRUTH: what every device in
80// this group should show. epoch bumps on every scene change so a device
81// that hasn't applied the latest epoch is, by definition, out of sync.
82
83struct IotSceneReg {
84 cap: i64, // max devices in the parallel devs array
85 count: i64, // devices tracked
86 epoch: i64, // current scene epoch (>=1 after first set)
87 s_on: i64, // desired on/off
88 s_brightness: i64, // desired brightness 0..100
89 s_hue: i64, // desired hue
90 s_sat: i64, // desired saturation
91 s_color_temp: i64, // desired colour temperature (K)
92 syncs_restored: i64, // MEASURED: drifted lights auto-pulled back to scene
93 desyncs_seen: i64, // drops/heals that knocked a device out of sync
94 scene_changes: i64, // how many times the desired scene was set
95}
96
97// Per-device state lives in a caller array, stride 2 i64 per device:
98// devs[slot*2 + 0] = logical_id (the anchor's stable id)
99// devs[slot*2 + 1] = applied_epoch (the scene epoch this device last
100// applied; EPOCH_NONE = out of sync)
101const NX_IOT_SYNC_DEV_STRIDE: i64 = 2
102
103func nx_iot_sync_dev_lid(devs: *i64, slot: i64) -> i64 {
104 return devs[slot * NX_IOT_SYNC_DEV_STRIDE + 0]
105}
106func nx_iot_sync_dev_epoch(devs: *i64, slot: i64) -> i64 {
107 return devs[slot * NX_IOT_SYNC_DEV_STRIDE + 1]
108}
109
110func nx_iot_sync_find(reg: *IotSceneReg, devs: *i64, logical_id: i64) -> i64 {
111 var i: i64 = 0
112 var found: i64 = -1
113 while i < reg.count {
114 if found == -1 {
115 if nx_iot_sync_dev_lid(devs, i) == logical_id { found = i }
116 }
117 i = i + 1
118 }
119 return found
120}
121
122// ---- Init ---------------------------------------------------------
123
124func nx_iot_scene_init(reg: *IotSceneReg, cap: i64) -> i64 {
125 if reg == (0 as *IotSceneReg) { return NX_IOT_SYNC_BAD_ARG }
126 if cap <= 0 { return NX_IOT_SYNC_BAD_ARG }
127 reg.cap = cap
128 reg.count = 0
129 reg.epoch = 0
130 reg.s_on = 0
131 reg.s_brightness = 0
132 reg.s_hue = 0
133 reg.s_sat = 0
134 reg.s_color_temp = 0
135 reg.syncs_restored = 0
136 reg.desyncs_seen = 0
137 reg.scene_changes = 0
138 return NX_IOT_SYNC_OK
139}
140
141// ---- Set the desired scene (the single source of truth) -----------
142//
143// Bumps epoch -> every tracked device is now "behind" until it re-applies.
144// The daemon then drives an ATOMIC apply to all devices via the clock
145// master (all at the same tick = no skew).
146
147func nx_iot_scene_set(reg: *IotSceneReg, on: i64, bri: i64,
148 hue: i64, sat: i64, color_temp: i64) -> i64 {
149 if reg == (0 as *IotSceneReg) { return NX_IOT_SYNC_BAD_ARG }
150 reg.epoch = reg.epoch + 1
151 reg.s_on = on
152 reg.s_brightness = bri
153 reg.s_hue = hue
154 reg.s_sat = sat
155 reg.s_color_temp = color_temp
156 reg.scene_changes = reg.scene_changes + 1
157 return NX_IOT_SYNC_OK
158}
159
160// ---- Track a device ------------------------------------------------
161//
162// New devices start out of sync (EPOCH_NONE) -- they must apply the scene.
163
164func nx_iot_scene_add_device(reg: *IotSceneReg, devs: *i64,
165 logical_id: i64) -> i64 {
166 if reg == (0 as *IotSceneReg) { return NX_IOT_SYNC_BAD_ARG }
167 let ex: i64 = nx_iot_sync_find(reg, devs, logical_id)
168 if ex >= 0 { return NX_IOT_SYNC_OK } // idempotent (#10)
169 if reg.count >= reg.cap { return NX_IOT_SYNC_FULL }
170 let ns: i64 = reg.count
171 devs[ns * NX_IOT_SYNC_DEV_STRIDE + 0] = logical_id
172 devs[ns * NX_IOT_SYNC_DEV_STRIDE + 1] = NX_IOT_SYNC_EPOCH_NONE
173 reg.count = ns + 1
174 return NX_IOT_SYNC_OK
175}
176
177// ---- Sync queries --------------------------------------------------
178
179// 1 if the device has applied the current scene epoch.
180func nx_iot_scene_in_sync(reg: *IotSceneReg, devs: *i64, logical_id: i64) -> i64 {
181 let slot: i64 = nx_iot_sync_find(reg, devs, logical_id)
182 if slot < 0 { return 0 }
183 if nx_iot_sync_dev_epoch(devs, slot) == reg.epoch { return 1 }
184 return 0
185}
186
187// 1 if the device is behind the current scene and needs the scene re-applied.
188func nx_iot_scene_needs_resync(reg: *IotSceneReg, devs: *i64, logical_id: i64) -> i64 {
189 let slot: i64 = nx_iot_sync_find(reg, devs, logical_id)
190 if slot < 0 { return 0 }
191 if nx_iot_sync_dev_epoch(devs, slot) != reg.epoch { return 1 }
192 return 0
193}
194
195// ---- Mark a device's state ----------------------------------------
196
197// The device successfully applied the current scene -> it is in sync.
198func nx_iot_scene_mark_applied(reg: *IotSceneReg, devs: *i64, logical_id: i64) -> i64 {
199 let slot: i64 = nx_iot_sync_find(reg, devs, logical_id)
200 if slot < 0 { return NX_IOT_SYNC_NOT_FOUND }
201 devs[slot * NX_IOT_SYNC_DEV_STRIDE + 1] = reg.epoch
202 return NX_IOT_SYNC_OK
203}
204
205// The device dropped + healed (re-paired) -> it reverted to its default and
206// is now OUT OF SYNC. The watchdog/anchor heal path calls this; it bumps
207// the desync tally.
208func nx_iot_scene_mark_desynced(reg: *IotSceneReg, devs: *i64, logical_id: i64) -> i64 {
209 let slot: i64 = nx_iot_sync_find(reg, devs, logical_id)
210 if slot < 0 { return NX_IOT_SYNC_NOT_FOUND }
211 devs[slot * NX_IOT_SYNC_DEV_STRIDE + 1] = NX_IOT_SYNC_EPOCH_NONE
212 reg.desyncs_seen = reg.desyncs_seen + 1
213 return NX_IOT_SYNC_OK
214}
215
216// ---- THE SELF-FIX: re-sync a drifted device -----------------------
217//
218// If the device is behind the desired scene, pull it back (record it as
219// applied) and count the restore. Returns 1 if a re-sync happened, 0 if it
220// was already in sync, -1 on bad device. The daemon pairs this with the
221// driver call that actually re-applies the scene bytes + the clock master.
222
223func nx_iot_scene_resync(reg: *IotSceneReg, devs: *i64, logical_id: i64) -> i64 {
224 let slot: i64 = nx_iot_sync_find(reg, devs, logical_id)
225 if slot < 0 { return -1 }
226 if nx_iot_sync_dev_epoch(devs, slot) == reg.epoch { return 0 } // already in sync
227 devs[slot * NX_IOT_SYNC_DEV_STRIDE + 1] = reg.epoch
228 reg.syncs_restored = reg.syncs_restored + 1
229 return 1
230}
231
232// ---- Fleet-level convergence --------------------------------------
233
234// 1 if EVERY tracked device has applied the current scene (the lights are
235// synced). 0 if any device is behind. Empty fleet -> 1 (vacuously synced).
236func nx_iot_scene_all_in_sync(reg: *IotSceneReg, devs: *i64) -> i64 {
237 if reg == (0 as *IotSceneReg) { return 0 }
238 var i: i64 = 0
239 while i < reg.count {
240 if nx_iot_sync_dev_epoch(devs, i) != reg.epoch { return 0 }
241 i = i + 1
242 }
243 return 1
244}
245
246func nx_iot_scene_count_desynced(reg: *IotSceneReg, devs: *i64) -> i64 {
247 if reg == (0 as *IotSceneReg) { return -1 }
248 var i: i64 = 0
249 var n: i64 = 0
250 while i < reg.count {
251 if nx_iot_sync_dev_epoch(devs, i) != reg.epoch { n = n + 1 }
252 i = i + 1
253 }
254 return n
255}
256
257// Re-sync EVERY drifted device in one pass; returns how many were restored.
258// This is the "bring the whole room back in sync" sweep the daemon runs
259// after a heal or a scene change.
260func nx_iot_scene_resync_all(reg: *IotSceneReg, devs: *i64) -> i64 {
261 if reg == (0 as *IotSceneReg) { return -1 }
262 var i: i64 = 0
263 var restored: i64 = 0
264 while i < reg.count {
265 if nx_iot_sync_dev_epoch(devs, i) != reg.epoch {
266 devs[i * NX_IOT_SYNC_DEV_STRIDE + 1] = reg.epoch
267 reg.syncs_restored = reg.syncs_restored + 1
268 restored = restored + 1
269 }
270 i = i + 1
271 }
272 return restored
273}
274
275// ---- Observers (daemon / UI / gate) -------------------------------
276
277func nx_iot_scene_epoch(reg: *IotSceneReg) -> i64 {
278 if reg == (0 as *IotSceneReg) { return -1 }
279 return reg.epoch
280}
281func nx_iot_scene_count(reg: *IotSceneReg) -> i64 {
282 if reg == (0 as *IotSceneReg) { return -1 }
283 return reg.count
284}
285func nx_iot_scene_syncs_restored(reg: *IotSceneReg) -> i64 {
286 if reg == (0 as *IotSceneReg) { return -1 }
287 return reg.syncs_restored
288}
289func nx_iot_scene_desyncs_seen(reg: *IotSceneReg) -> i64 {
290 if reg == (0 as *IotSceneReg) { return -1 }
291 return reg.desyncs_seen
292}
293func nx_iot_scene_get_brightness(reg: *IotSceneReg) -> i64 {
294 if reg == (0 as *IotSceneReg) { return -1 }
295 return reg.s_brightness
296}
297func nx_iot_scene_get_on(reg: *IotSceneReg) -> i64 {
298 if reg == (0 as *IotSceneReg) { return -1 }
299 return reg.s_on
300}