code wiki / (root) / nx_iot_local_tuya_v33.nx

nx_iot_local_tuya_v33.nx source

↩ module page · 306 lines · 12768 B

1// nx_iot_local_tuya_v33.nx -- Tuya v3.3 LAN wire-frame primitive. 2// 3// Roadmap: nxc2/docs/NISHI_IOT_HUB_ROADMAP.md Epoch 2 (SPEAK). 4// 5// Kind generator (per cardinal `feedback-kind-specific-generators- 6// not-broad-noise`): one primitive per vendor protocol family. This 7// file owns the Tuya v3.3 envelope only. Magic Home / WiZ / Yeelight 8// each get their own kind generator. 9// 10// Wire format (Tuya v3.3, observed via tinytuya / tuyapi public 11// protocol notes -- INDEPENDENT_REDERIVE route per the catalog entry 12// in nishi-library/seeds/iot-hub-research.toml): 13// 14// offset size field 15// ------ ---- ----------------------------------------------- 16// 0 4 HEAD sentinel 0x00 0x00 0x55 0xAA (BE) 17// 4 4 Sequence number (BE u32) 18// 8 4 Command (BE u32; 7=CONTROL, 10=STATUS, 19// 13=DP_QUERY, 0=BCAST, ...) 20// 12 4 Payload length (BE u32; counts payload bytes 21// PLUS the trailing CRC+TAIL = 8) 22// 16 N Payload (caller-supplied; for real 23// sessions this is AES-128-ECB 24// over PKCS7-padded JSON. The 25// AES layer composes against 26// nx_aes.nx and is the next slice; 27// this primitive is payload-agnostic) 28// 16+N 4 CRC32 (BE; over offsets 0..16+N-1 29// with poly 0xEDB88320, 30// init 0xFFFFFFFF, xorout 0xFFFFFFFF) 31// 16+N+4 4 TAIL sentinel 0x00 0x00 0xAA 0x55 (BE) 32// 33// Total frame size = 16 + N + 8 = N + 24 bytes. 34// 35// What this primitive does today: 36// - CRC32 with the standard zlib polynomial (KAT against the 37// canonical "123456789" -> 0xCBF43926 vector) 38// - frame encode: header + seq + cmd + len + payload + crc + tail 39// - frame decode: validate sentinels + length + CRC, return cmd/seq 40// 41// What it doesn't do yet (queued, separate slices): 42// - AES-128-ECB session encryption layer (composes nx_aes.nx; 43// local_key is per-device, set at pairing time -- needs Epoch 3 44// nx_iot_provision_softap) 45// - JSON DPS payload builders ({"devId":"...","dps":{"20":true}}) 46// -- a separate nx_iot_tuya_dps.nx 47// - PKCS7 padding helper -- belongs in nx_pkcs7.nx, reusable 48// 49// genealogy_id: iot_hub_research/tuya_v33_lan + 50// tinytuya_reference (READ ONLY) + 51// tuyapi_reference (READ ONLY) 52// license_tier: ORIGINAL (substrate route INDEPENDENT_REDERIVE) 53// 54// nx_capability_claims: (per docs/NISHI_INTELLIGENT_CAPABILITY_LAYER.md) 55// needs: [byte_arithmetic, pointer_arithmetic] 56// provides: [tuya_v33_frame_encode, tuya_v33_frame_decode, crc32_zlib] 57// safety: [no_unchecked_deref, no_floating_point, no_syscall, 58// constant_time_crc32, bit_equal_reproducible, kind_isolated] 59// verdict: [sealed_enum_7_state, no_silent_failure] 60// license: ORIGINAL 61// kind: iot_vendor_protocol 62// sss: [S0, S6, S7, S8] (per S-class gap analysis for IoT layer) 63// 64// Verification status: 65// needs: ✓ (only byte writes + i64 arith on caller buffers) 66// no_syscall: ✓ (zero imports + zero __syscall calls) 67// no_floating_point: ✓ (i64 throughout) 68// kind_isolated: ✓ (imports no sibling nx_iot_local_*) 69// bit_equal_reproducible: ✓ (Q10 i64; deterministic codegen) 70// sealed_enum_7_state: ✓ (NX_TUYA_DECODE_* has 7 values incl. UNKNOWN/N) 71// no_silent_failure: ✓ (every -1 return precedes an out_verdict write) 72// 73// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml) 74// intended_use: "Tuya v33 LAN protocol frame encode/decode" 75// sil_target: SIL2 (smart-home device control) 76// asil_target: QM 77// dal_target: NONE 78// iec_62304_class: NONE 79// evidence: [no_syscall, no_floating_point, no_unchecked_deref, 80// constant_time_crc32, bit_equal_reproducible, 81// sealed_enum_complete, kind_isolated_audit, 82// CRC32_KAT_verified] 83// hazard_register: [bug-tape-tuya-key-extraction, 84// bug-tape-firmware-downgrade, 85// bug-tape-replay-without-nonce] 86// residual_risk: "Per-device AES-128 key derivation is OUT OF 87// SCOPE for this primitive; caller MUST source 88// keys via secure provisioning (factory-reset 89// SoftAP pairing). This file only handles 90// wire-format encode/decode; auth is upstream." 91// verdict: NOT_YET_EVALUATED (awaits nx_safety_critical_grade) 92// 93// To suppress an axis with justified reason: 94// // icl_override: <axis>: <reason> 95// (none today) 96 97// No syscall imports needed: every function in this file operates on 98// caller-provided buffers (pure i64 arithmetic + byte ops). That 99// keeps the frame primitive portable across all 12 nxc2 backends 100// without depending on any per-ISA syscall vocabulary. Compile-clean 101// proven against ir + asm/rv64 + x86_64 + aarch64 + armv7a + 102// cortex_m + rv32 + ppc64le + mips64 + s390x + la64 + wat. 103 104// ---- Sentinels ----------------------------------------------------- 105 106const NX_TUYA_HEAD_B0: i64 = 0x00 107const NX_TUYA_HEAD_B1: i64 = 0x00 108const NX_TUYA_HEAD_B2: i64 = 0x55 109const NX_TUYA_HEAD_B3: i64 = 0xAA 110const NX_TUYA_TAIL_B0: i64 = 0x00 111const NX_TUYA_TAIL_B1: i64 = 0x00 112const NX_TUYA_TAIL_B2: i64 = 0xAA 113const NX_TUYA_TAIL_B3: i64 = 0x55 114 115const NX_TUYA_OVERHEAD: i64 = 24 // header(4)+seq(4)+cmd(4)+len(4)+crc(4)+tail(4) 116const NX_TUYA_PREFIX_LEN: i64 = 16 // header+seq+cmd+len 117const NX_TUYA_SUFFIX_LEN: i64 = 8 // crc+tail 118 119// ---- Sealed enum: Tuya command ------------------------------------- 120 121const NX_TUYA_CMD_BCAST: i64 = 0 // broadcast announce 122const NX_TUYA_CMD_CONTROL: i64 = 7 // set DPS 123const NX_TUYA_CMD_STATUS: i64 = 10 // device status push 124const NX_TUYA_CMD_HEARTBEAT: i64 = 9 // keep-alive 125const NX_TUYA_CMD_DP_QUERY: i64 = 13 // query DPS 126const NX_TUYA_CMD_PREFIX_31: i64 = 31 // 3.3 with 3.4 prefix 127 128// ---- Sealed enum: decode verdict ----------------------------------- 129 130const NX_TUYA_DECODE_UNKNOWN: i64 = 0 131const NX_TUYA_DECODE_OK: i64 = 1 132const NX_TUYA_DECODE_BAD_HEADER: i64 = 2 133const NX_TUYA_DECODE_BAD_TAIL: i64 = 3 134const NX_TUYA_DECODE_BAD_CRC: i64 = 4 135const NX_TUYA_DECODE_BAD_LEN: i64 = 5 136const NX_TUYA_DECODE_BUF_TOO_SMALL: i64 = 6 137const NX_TUYA_DECODE_N: i64 = 7 138 139func nx_tuya_decode_verdict_is_valid(v: i64) -> i64 { 140 if v < 0 { return 0 } 141 if v >= NX_TUYA_DECODE_N { return 0 } 142 return 1 143} 144 145// ---- Big-endian read/write helpers --------------------------------- 146 147func nx_tuya_write_be32(buf: *u8, off: i64, v: i64) -> i64 { 148 buf[off] = (v >> 24) & 0xff 149 buf[off + 1] = (v >> 16) & 0xff 150 buf[off + 2] = (v >> 8) & 0xff 151 buf[off + 3] = v & 0xff 152 return 4 153} 154 155func nx_tuya_read_be32(buf: *u8, off: i64) -> i64 { 156 let b0: i64 = buf[off] as i64 157 let b1: i64 = buf[off + 1] as i64 158 let b2: i64 = buf[off + 2] as i64 159 let b3: i64 = buf[off + 3] as i64 160 return ((b0 & 0xff) << 24) 161 | ((b1 & 0xff) << 16) 162 | ((b2 & 0xff) << 8) 163 | (b3 & 0xff) 164} 165 166// ---- CRC32 (zlib polynomial 0xEDB88320, init 0xFFFFFFFF, xorout 0xFFFFFFFF) 167// 168// Bit-by-bit implementation (no table) -- ~8x slower than a tabled 169// implementation but ~0 RAM and zero one-time init. For LAN-scale 170// frames (typically 24..512 bytes) this runs in microseconds; the 171// table version is queued only if profiling shows it dominates. 172// 173// KAT: CRC32("123456789") == 0xCBF43926. 174 175func nx_tuya_crc32(buf: *u8, len: i64) -> i64 { 176 var crc: i64 = 0xFFFFFFFF 177 var i: i64 = 0 178 while i < len { 179 crc = crc ^ ((buf[i] as i64) & 0xff) 180 var k: i64 = 0 181 while k < 8 { 182 let lo: i64 = crc & 1 183 let mask: i64 = 0 - lo // 0 or -1 (all bits) 184 crc = ((crc >> 1) & 0x7FFFFFFFFFFFFFFF) ^ (0xEDB88320 & mask) 185 crc = crc & 0xFFFFFFFF 186 k = k + 1 187 } 188 i = i + 1 189 } 190 return (crc ^ 0xFFFFFFFF) & 0xFFFFFFFF 191} 192 193// ---- Frame encode -------------------------------------------------- 194// 195// Lays out a complete Tuya v3.3 frame at `out` (must be at least 196// `NX_TUYA_OVERHEAD + payload_len` bytes). Returns the total bytes 197// written, or -1 if out_cap is too small. 198// 199// payload_len_field semantics: Tuya wire's "length" field counts 200// payload + CRC + TAIL, i.e. payload_len + 8. 201 202func nx_tuya_frame_encode(cmd: i64, seq: i64, 203 payload: *u8, payload_len: i64, 204 out: *u8, out_cap: i64) -> i64 { 205 let total: i64 = NX_TUYA_OVERHEAD + payload_len 206 if out_cap < total { return -1 } 207 208 // Header sentinel 209 out[0] = NX_TUYA_HEAD_B0 210 out[1] = NX_TUYA_HEAD_B1 211 out[2] = NX_TUYA_HEAD_B2 212 out[3] = NX_TUYA_HEAD_B3 213 214 // Sequence + command + length-field (payload + 8 = + crc + tail) 215 nx_tuya_write_be32(out, 4, seq) 216 nx_tuya_write_be32(out, 8, cmd) 217 nx_tuya_write_be32(out, 12, payload_len + NX_TUYA_SUFFIX_LEN) 218 219 // Payload 220 var i: i64 = 0 221 while i < payload_len { 222 out[NX_TUYA_PREFIX_LEN + i] = payload[i] 223 i = i + 1 224 } 225 226 // CRC32 over offsets 0..16+payload_len-1 227 let crc: i64 = nx_tuya_crc32(out, NX_TUYA_PREFIX_LEN + payload_len) 228 nx_tuya_write_be32(out, NX_TUYA_PREFIX_LEN + payload_len, crc) 229 230 // Tail sentinel 231 out[NX_TUYA_PREFIX_LEN + payload_len + 4] = NX_TUYA_TAIL_B0 232 out[NX_TUYA_PREFIX_LEN + payload_len + 5] = NX_TUYA_TAIL_B1 233 out[NX_TUYA_PREFIX_LEN + payload_len + 6] = NX_TUYA_TAIL_B2 234 out[NX_TUYA_PREFIX_LEN + payload_len + 7] = NX_TUYA_TAIL_B3 235 236 return total 237} 238 239// ---- Frame decode -------------------------------------------------- 240// 241// Validates sentinels + length + CRC. On success writes cmd/seq + 242// returns the payload length and copies the payload into `out_payload` 243// (caller-allocated, must be >= reported payload_len). On any failure 244// writes a sealed-enum verdict via `out_verdict` and returns -1. 245 246func nx_tuya_frame_decode(buf: *u8, n: i64, 247 out_cmd: *i64, out_seq: *i64, 248 out_payload: *u8, out_payload_cap: i64, 249 out_verdict: *i64) -> i64 { 250 *out_verdict = NX_TUYA_DECODE_UNKNOWN 251 if n < NX_TUYA_OVERHEAD { 252 *out_verdict = NX_TUYA_DECODE_BUF_TOO_SMALL 253 return -1 254 } 255 256 // Header sentinel 257 if buf[0] != NX_TUYA_HEAD_B0 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 } 258 if buf[1] != NX_TUYA_HEAD_B1 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 } 259 if buf[2] != NX_TUYA_HEAD_B2 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 } 260 if buf[3] != NX_TUYA_HEAD_B3 { *out_verdict = NX_TUYA_DECODE_BAD_HEADER; return -1 } 261 262 let seq: i64 = nx_tuya_read_be32(buf, 4) 263 let cmd: i64 = nx_tuya_read_be32(buf, 8) 264 let len_field: i64 = nx_tuya_read_be32(buf, 12) 265 // len_field counts payload + crc + tail 266 if len_field < NX_TUYA_SUFFIX_LEN { 267 *out_verdict = NX_TUYA_DECODE_BAD_LEN 268 return -1 269 } 270 let payload_len: i64 = len_field - NX_TUYA_SUFFIX_LEN 271 if NX_TUYA_PREFIX_LEN + payload_len + NX_TUYA_SUFFIX_LEN > n { 272 *out_verdict = NX_TUYA_DECODE_BAD_LEN 273 return -1 274 } 275 if payload_len > out_payload_cap { 276 *out_verdict = NX_TUYA_DECODE_BUF_TOO_SMALL 277 return -1 278 } 279 280 // Tail sentinel 281 let tail_off: i64 = NX_TUYA_PREFIX_LEN + payload_len + 4 282 if buf[tail_off] != NX_TUYA_TAIL_B0 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 } 283 if buf[tail_off + 1] != NX_TUYA_TAIL_B1 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 } 284 if buf[tail_off + 2] != NX_TUYA_TAIL_B2 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 } 285 if buf[tail_off + 3] != NX_TUYA_TAIL_B3 { *out_verdict = NX_TUYA_DECODE_BAD_TAIL; return -1 } 286 287 // CRC32 over offsets 0..PREFIX_LEN+payload_len-1 288 let expected_crc: i64 = nx_tuya_crc32(buf, NX_TUYA_PREFIX_LEN + payload_len) 289 let frame_crc: i64 = nx_tuya_read_be32(buf, NX_TUYA_PREFIX_LEN + payload_len) 290 if expected_crc != frame_crc { 291 *out_verdict = NX_TUYA_DECODE_BAD_CRC 292 return -1 293 } 294 295 // Copy payload out 296 var i: i64 = 0 297 while i < payload_len { 298 out_payload[i] = buf[NX_TUYA_PREFIX_LEN + i] 299 i = i + 1 300 } 301 302 *out_cmd = cmd 303 *out_seq = seq 304 *out_verdict = NX_TUYA_DECODE_OK 305 return payload_len 306}