code wiki / (root) / nx_iot_scene_sync.nx

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}