From 829a3e5f44d19285516144a63764db049a909ca4 Mon Sep 17 00:00:00 2001 From: Niko Date: Sat, 6 Jun 2026 17:25:52 +0200 Subject: [PATCH] feat: boot manager, provisioning server, node stubs v0.3.0 --- .gitignore | 31 ++-- boot_manager.py | 311 +++++++++++++++++++++++++++++++++++++++++ config_example.json | 13 +- identity_iot_aes.py | 1 - lib/ssd1306.mpy | Bin 0 -> 1777 bytes main.py | 130 +---------------- node_access_control.py | 29 ++++ node_relay.py | 20 +++ node_sensor.py | 20 +++ provisioning_server.py | 212 ++++++++++++++++++++++++++++ 10 files changed, 622 insertions(+), 145 deletions(-) create mode 100644 boot_manager.py create mode 100644 lib/ssd1306.mpy create mode 100644 node_access_control.py create mode 100644 node_relay.py create mode 100644 node_sensor.py create mode 100644 provisioning_server.py diff --git a/.gitignore b/.gitignore index c5b0598..5c012cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,22 @@ -# MicroPython compiled -*.mpy - -# Secrets -wifi_config.py -config.py +# Runtime files generated on device — never commit config.json -tenant_*.py +identity_seed.bin +identity_pk.b64 +identity_jwt.txt +identity_node_uid.txt -# OS -.DS_Store -Thumbs.db +# Duplicates / backups +*.copy +*copy* -# IDE +# Dev junk +blink.py +oled_test.py +wifitest.py +test.py +test_*.py + +# VSCode .vscode/ -.idea/ -__pycache__/ \ No newline at end of file +*.code-workspace +.micropico diff --git a/boot_manager.py b/boot_manager.py new file mode 100644 index 0000000..cf97843 --- /dev/null +++ b/boot_manager.py @@ -0,0 +1,311 @@ +# ============================================================+ +# Identity IoT — Boot Manager +# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong +# (c) Copyright : WIDE di D. Papa - Naples - Italy +# ============================================================+ +# State machine: BOOT → CONFIG_CHECK → PROVISIONING | WIFI_CONNECT +# → CHECKIN → OPERATIONAL +# ============================================================+ + +import ujson +import utime +import network +import machine +import ubinascii +from machine import Pin, I2C + +# ---- Optional OLED ---- +try: + from ssd1306 import SSD1306_I2C + _i2c = I2C(0, sda=Pin(4), scl=Pin(5)) + _oled = SSD1306_I2C(128, 64, _i2c) + HAS_OLED = True +except Exception: + _oled = None + HAS_OLED = False + +# ---- Optional NFC (PN532 I2C) ---- +try: + from pn532 import PN532_I2C + HAS_NFC = True +except Exception: + HAS_NFC = False + +# ---- Optional sensor (DHT22 on GP15) ---- +try: + import dht + _dht = dht.DHT22(Pin(15)) + HAS_SENSOR = True +except Exception: + HAS_SENSOR = False + +# ---- Optional relay (GP16) ---- +try: + _relay = Pin(16, Pin.OUT) + HAS_RELAY = True +except Exception: + HAS_RELAY = False + +CONFIG_PATH = 'config.json' + +STATES = [ + 'BOOT', + 'CAPABILITY_DETECT', + 'CONFIG_CHECK', + 'PROVISIONING', + 'WIFI_CONNECT', + 'CHECKIN', + 'OPERATIONAL', + 'ERROR', +] + + +# ============================================================================= +# Display helpers +# ============================================================================= + +def _show(lines): + if not HAS_OLED or _oled is None: + print(' | '.join(str(l) for l in lines)) + return + _oled.fill(0) + for i, line in enumerate(lines[:5]): + _oled.text(str(line)[:16], 0, i * 12) + _oled.show() + + +def _show_state(state, detail=''): + _show(['Identity IoT', 'WIDE / AEL', '', state, detail]) + + +# ============================================================================= +# Capability detection +# ============================================================================= + +def detect_capabilities(): + caps = { + 'has_oled': HAS_OLED, + 'has_nfc': HAS_NFC, + 'has_sensor': HAS_SENSOR, + 'has_relay': HAS_RELAY, + } + print('[caps]', caps) + return caps + + +# ============================================================================= +# Config +# ============================================================================= + +def load_config(): + try: + with open(CONFIG_PATH) as f: + return ujson.load(f) + except OSError: + return None + + +def save_config(cfg): + with open(CONFIG_PATH, 'w') as f: + ujson.dump(cfg, f) + + +def validate_config(cfg): + required = ['wifi_ssid', 'wifi_pass', 'comm_key', 'comm_iv', + 'base_url', 'tenant_id', 'node_type', 'node_id'] + for k in required: + if k not in cfg: + print('[config] missing key:', k) + return False + return True + + +# ============================================================================= +# WiFi +# ============================================================================= + +def connect_wifi(cfg, timeout=30): + _show_state('WIFI_CONNECT', cfg['wifi_ssid']) + wlan = network.WLAN(network.STA_IF) + wlan.active(True) + utime.sleep(1) + + if not wlan.isconnected(): + wlan.connect(cfg['wifi_ssid'], cfg['wifi_pass']) + t = timeout + while t > 0: + if wlan.isconnected() and wlan.ifconfig()[0] != '0.0.0.0': + break + utime.sleep(1) + t -= 1 + + if not wlan.isconnected() or wlan.ifconfig()[0] == '0.0.0.0': + _show_state('ERROR', 'WiFi failed') + raise Exception('WiFi connect failed') + + if cfg.get('wifi_static'): + wlan.ifconfig(( + cfg['wifi_ip'], + cfg['wifi_mask'], + cfg['wifi_gw'], + cfg['wifi_dns'], + )) + + ip = wlan.ifconfig()[0] + print('[wifi] connected:', ip) + _show_state('WIFI_OK', ip) + utime.sleep(1) + return wlan + + +# ============================================================================= +# Checkin +# ============================================================================= + +def do_checkin(cfg): + _show_state('CHECKIN', '') + import urequests + from identity_iot_aes import aes_cbc_encrypt, aes_cbc_decrypt + + key = cfg['comm_key'].encode() if isinstance(cfg['comm_key'], str) else cfg['comm_key'] + iv = cfg['comm_iv'].encode() if isinstance(cfg['comm_iv'], str) else cfg['comm_iv'] + + sn = ubinascii.hexlify(machine.unique_id()).decode() + + payload = ujson.dumps({ + 'request': 'it_node_checkin', + 'node_id': cfg['node_id'], + 'tenant_id': cfg['tenant_id'], + 'node_type': cfg['node_type'], + 'node_sn': sn, + }) + + cipher = aes_cbc_encrypt(payload, key, iv) + body = ujson.dumps({'data': cipher}) + + for attempt in range(3): + try: + resp = urequests.post( + cfg['base_url'], + headers={'Content-Type': 'application/json'}, + data=body, + timeout=20, + ) + outer = ujson.loads(resp.text) + resp.close() + clear = aes_cbc_decrypt(outer['data'], key, iv) + result = ujson.loads(clear) + if result.get('status') == 'ok': + print('[checkin] ok') + _show_state('CHECKIN OK', cfg['node_label']) + utime.sleep(1) + return result + raise Exception('checkin status: ' + str(result.get('status'))) + except Exception as e: + print('[checkin] attempt', attempt + 1, ':', e) + utime.sleep(3) + + raise Exception('checkin failed after 3 attempts') + + +# ============================================================================= +# Boot Manager — main entry point +# ============================================================================= + +def run(): + state = 'BOOT' + + while True: + + # ------------------------------------------------------------------ + if state == 'BOOT': + _show(['Identity IoT', 'WIDE / AEL', '', 'Booting...', '']) + utime.sleep(1) + state = 'CAPABILITY_DETECT' + + # ------------------------------------------------------------------ + elif state == 'CAPABILITY_DETECT': + caps = detect_capabilities() + _show([ + 'Identity IoT', + 'NFC:' + ('Y' if caps['has_nfc'] else 'N') + + ' SNS:' + ('Y' if caps['has_sensor'] else 'N') + + ' RLY:' + ('Y' if caps['has_relay'] else 'N'), + '', + 'Checking config...', + '', + ]) + utime.sleep(1) + state = 'CONFIG_CHECK' + + # ------------------------------------------------------------------ + elif state == 'CONFIG_CHECK': + cfg = load_config() + if cfg and validate_config(cfg): + print('[config] found and valid') + state = 'WIFI_CONNECT' + else: + print('[config] missing or invalid — provisioning mode') + state = 'PROVISIONING' + + # ------------------------------------------------------------------ + elif state == 'PROVISIONING': + from provisioning_server import run_provisioning + _show_state('PROVISIONING', 'Waiting app...') + cfg = run_provisioning() # blocks until config received + save_config(cfg) + print('[provisioning] config saved — rebooting') + _show_state('CONFIG SAVED', 'Rebooting...') + utime.sleep(2) + machine.reset() + + # ------------------------------------------------------------------ + elif state == 'WIFI_CONNECT': + try: + connect_wifi(cfg) + state = 'CHECKIN' + except Exception as e: + print('[wifi] error:', e) + _show_state('ERROR', 'WiFi') + utime.sleep(10) + # retry + state = 'WIFI_CONNECT' + + # ------------------------------------------------------------------ + elif state == 'CHECKIN': + try: + do_checkin(cfg) + state = 'OPERATIONAL' + except Exception as e: + print('[checkin] error:', e) + _show_state('ERROR', 'Checkin') + utime.sleep(15) + state = 'CHECKIN' + + # ------------------------------------------------------------------ + elif state == 'OPERATIONAL': + node_type = cfg.get('node_type', 'sensor') + _show_state('OPERATIONAL', node_type) + + if node_type == 'access_control': + from node_access_control import run_access_control + run_access_control(cfg, caps) + + elif node_type == 'sensor': + from node_sensor import run_sensor + run_sensor(cfg, caps) + + elif node_type == 'relay': + from node_relay import run_relay + run_relay(cfg, caps) + + else: + print('[operational] unknown node_type:', node_type) + _show_state('ERROR', 'node_type?') + utime.sleep(30) + + # ------------------------------------------------------------------ + elif state == 'ERROR': + _show_state('ERROR', 'halted') + utime.sleep(60) + machine.reset() diff --git a/config_example.json b/config_example.json index b376bc4..32f55c0 100644 --- a/config_example.json +++ b/config_example.json @@ -6,10 +6,11 @@ "wifi_mask": "255.255.255.0", "wifi_gw": "192.168.1.1", "wifi_dns": "8.8.8.8", - "comm_key": "YOUR_32_CHAR_BOOTSTRAP_KEY......", - "comm_iv": "YOUR_16_CHAR_IV.", - "base_url": "https://api.parta.app/API/V1/identity_layer/identity_iot/", + "comm_key": "YOUR_32_CHAR_KEY________________", + "comm_iv": "YOUR_16_CHAR_IV_", + "base_url": "https://api.parta.app/API/V1/identity_tag/", "tenant_id": 1, - "node_type": "sensor", - "node_label": "pico-01" -} \ No newline at end of file + "node_id": 0, + "node_type": "access_control", + "node_label": "terminal-01" +} diff --git a/identity_iot_aes.py b/identity_iot_aes.py index 5a2335e..903139e 100644 --- a/identity_iot_aes.py +++ b/identity_iot_aes.py @@ -10,7 +10,6 @@ import ucryptolib import ubinascii - def _pkcs7_pad(data: bytes, block_size: int = 16) -> bytes: pad_len = block_size - (len(data) % block_size) return data + bytes([pad_len] * pad_len) diff --git a/lib/ssd1306.mpy b/lib/ssd1306.mpy new file mode 100644 index 0000000000000000000000000000000000000000..a280785d20a919549677ae5020b0cd3a45771ee7 GIT binary patch literal 1777 zcmaJ=OLNm!6uz<*#dYjNw!}qw5S$wlQd5#3f(tEeT6PlcGc-RjF%tbshPemD@%e1>gd^S~!!-=Csx2QUXb} zty)K`*4oW_JTgw_dB)k*~ec*hYM_v2C;(>(HHRZ6RK7Y;{_db_WG& zTX&5XoUKNu(lLEB%b;Pwm0jsy6Qf<>fe7kNKO>MJ{_*<1bRVNYptXDw6eGt4+X&b1Q zmP$%lOXjlWV!TvFBQ{d9<#bWea#`d!v$H=^PrjQ=kJ?D=EuLvg{Jc zTt)-M&_K~8>`t_tszUT}L_VF>lIjwQ99^4Sz5zL_?j`x6lDrl#E-7ft4p_B|rWCm5 zu!7^$@++A$rL*$r6^pz-im@e;$GNaxx5B98e z3!^M%31#pcp=aNnLTB)0LeJwXgf8L_2rc0c z3H=mbC3FRUMCcm+n9$GgCxiy^Vp&FfUx*dB1uyO^%M9Ni>I1XCEPHsLWBT}aaJ-;< zAFw`e9||RKlJCPQGlCfGml+(AJ@|y|#aL$XNx2V8GKYudek_{0Zzul?8}KXYKp?nl zNdtS96!_kff|d|?^v03~!%LPhXbA(a7Z^(#3SXN-)(O8Q3;_ipVOHQ{?3yKpVrZ?P zKU(~QXG1E8e%uAEgie4?QwY!lLa%|wE=V+mfgVt=e@!KOiTJmmmrw+PO(B>{K20PE z{T!4N7#~CE7cfbfBMizWf=?kS4Y?s z2I+yrjF`d@7zMp2O5T2Vz5O^vuI8hjk~wj>{kFsK&PGSag0RccXO=V; zUh3H!JT-|5=RB4)ez?iqz5STit}I2lu$p#yJ=I;cOyLw=HHA^}v?+`MhUD6AyUvK?9Cw^OR`UAX-h4Qfk23%5>`~+&%8E{BQ`2W>g5=9?7g^H8Gm$(KCiyen zSdUrT7s>t+crKFXHlVf#q)dU7>3vH&``nUd;D>wnXG=UwVKzUCQTqU7PY=-7F0#oZ zsXsVUcjm9#QcYn>B>Xv3nBd>*^5?s;hX@urV<(v6w7Y{D=YYUj_zli63ykA*VhZ{W Vx^7S7|LeHrj_cTu_3)O0{x3Og=8gaW literal 0 HcmV?d00001 diff --git a/main.py b/main.py index a2d8d4a..3debf1d 100644 --- a/main.py +++ b/main.py @@ -1,128 +1,8 @@ # ============================================================+ -# Identity IoT — main.py for Pico W2 -# Config from config.json — OLED display integrated +# Identity IoT — main.py +# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong +# (c) Copyright : WIDE di D. Papa - Naples - Italy # ============================================================+ -import network -import utime -import ujson -from machine import Pin, I2C -from ssd1306 import SSD1306_I2C -from identity_iot import IdentityIoT - -# ---- OLED ---- -i2c = I2C(0, sda=Pin(4), scl=Pin(5)) -oled = SSD1306_I2C(128, 64, i2c) - -def show(lines): - oled.fill(0) - for i, line in enumerate(lines): - oled.text(line[:16], 0, i * 12) - oled.show() - -def show_scroll(text, row=0, delay=0.04): - """Scroll long text on a single row.""" - if len(text) <= 16: - return - for i in range(len(text) - 15): - oled.fill_rect(0, row * 12, 128, 12, 0) - oled.text(text[i:i+16], 0, row * 12) - oled.show() - utime.sleep(delay) - -# ---- Config ---- -def load_config(): - with open('config.json') as f: - return ujson.load(f) - -def connect_wifi(cfg): - show(['Identity IoT', 'WIDE / AEL', '', 'Connecting...']) - wlan = network.WLAN(network.STA_IF) - wlan.active(True) - utime.sleep(2) - - if not wlan.isconnected(): - wlan.connect(cfg['wifi_ssid'], cfg['wifi_pass']) - timeout = 30 - while timeout > 0: - if wlan.isconnected() and wlan.ifconfig()[0] != '0.0.0.0': - break - utime.sleep(1) - timeout -= 1 - - if not wlan.isconnected() or wlan.ifconfig()[0] == '0.0.0.0': - show(['WiFi FAILED', '', 'Check config', '']) - raise Exception('WiFi failed') - - if cfg.get('wifi_static'): - wlan.ifconfig(( - cfg['wifi_ip'], - cfg['wifi_mask'], - cfg['wifi_gw'], - cfg['wifi_dns'], - )) - - ip = wlan.ifconfig()[0] - show(['WiFi OK', ip, '', '']) - utime.sleep(1) - return wlan - -# ---- Main ---- -def main(): - show(['Identity IoT', 'WIDE / AEL', 'v1.0', 'Boot...']) - utime.sleep(1) - - cfg = load_config() - connect_wifi(cfg) - - - - identity = IdentityIoT( - base_url = cfg['base_url'], - node_type = cfg.get('node_type', 'sensor'), - node_label = cfg.get('node_label', ''), - tenant_id = cfg['tenant_id'], - comm_key = cfg['comm_key'].encode(), - comm_iv = cfg['comm_iv'].encode(), - wifi_ssid = cfg['wifi_ssid'], - wifi_pass = cfg['wifi_pass'], - ) - - # Register con retry visibile - for attempt in range(3): - try: - show(['Registering...', 'attempt ' + str(attempt+1) + '/3', '', '']) - reg = identity.ensure_registered() - break - except Exception as e: - print('register failed:', e) - if attempt == 2: - show(['FAILED', 'Check network', '', '']) - raise - utime.sleep(3) - - # Register - - reg = identity.ensure_registered() - uid_short = reg.get('node_uid', '')[:12] + '...' - show(['Node ' + reg.get('result',''), uid_short, '', '']) - utime.sleep(1) - - # Authenticate - show(['Challenge...', '', '', '']) - utime.sleep(0.5) - show(['Signing...', '', 'Please wait', '']) - token = identity.authenticate() - - if token: - show(['VERIFIED', '', 'JWT issued', ':)']) - else: - show(['AUTH FAILED', '', 'Check backend', '']) - - utime.sleep(2) - - # Idle — show node UID - uid = identity.get_node_uid() - show(['Identity IoT', 'Active', uid[:16], uid[16:32]]) - -main() \ No newline at end of file +import boot_manager +boot_manager.run() diff --git a/node_access_control.py b/node_access_control.py new file mode 100644 index 0000000..0a075f2 --- /dev/null +++ b/node_access_control.py @@ -0,0 +1,29 @@ +# ============================================================+ +# Identity IoT — Node: Access Control +# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong +# (c) Copyright : WIDE di D. Papa - Naples - Italy +# ============================================================+ +# Reads NTAG424 badge via PN532, performs challenge/verify +# against identity_tag backend. +# ============================================================+ + +import utime + + +def run_access_control(cfg, caps): + """Main loop for access_control node type. Called from boot_manager.""" + print('[access_control] starting — NFC:', caps.get('has_nfc')) + + if not caps.get('has_nfc'): + print('[access_control] ERROR: no NFC hardware detected') + return + + # TODO: init PN532 + # TODO: wait for badge tap + # TODO: read UID from NTAG424 + # TODO: challenge/verify against cfg['base_url'] + # TODO: trigger relay on success if has_relay + + while True: + print('[access_control] waiting for badge...') + utime.sleep(2) diff --git a/node_relay.py b/node_relay.py new file mode 100644 index 0000000..4f6c79c --- /dev/null +++ b/node_relay.py @@ -0,0 +1,20 @@ +# ============================================================+ +# Identity IoT — Node: Relay +# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong +# (c) Copyright : WIDE di D. Papa - Naples - Italy +# ============================================================+ + +import utime + + +def run_relay(cfg, caps): + """Main loop for relay node type. Called from boot_manager.""" + print('[relay] starting — relay:', caps.get('has_relay')) + + # TODO: listen for trigger from backend (polling or badge) + # TODO: activate/deactivate relay pin + # TODO: schedule support + + while True: + print('[relay] waiting for trigger...') + utime.sleep(5) diff --git a/node_sensor.py b/node_sensor.py new file mode 100644 index 0000000..a19db80 --- /dev/null +++ b/node_sensor.py @@ -0,0 +1,20 @@ +# ============================================================+ +# Identity IoT — Node: Sensor +# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong +# (c) Copyright : WIDE di D. Papa - Naples - Italy +# ============================================================+ + +import utime + + +def run_sensor(cfg, caps): + """Main loop for sensor node type. Called from boot_manager.""" + print('[sensor] starting — sensor:', caps.get('has_sensor')) + + # TODO: read DHT22 / other sensor + # TODO: POST readings to backend via AES channel + # TODO: polling interval from cfg + + while True: + print('[sensor] polling...') + utime.sleep(10) diff --git a/provisioning_server.py b/provisioning_server.py new file mode 100644 index 0000000..44fd719 --- /dev/null +++ b/provisioning_server.py @@ -0,0 +1,212 @@ +# ============================================================+ +# Identity IoT — Provisioning Server +# (c) Copyright : ae Aeonian Engineering Limited - Hong Kong +# (c) Copyright : WIDE di D. Papa - Naples - Italy +# ============================================================+ +# Runs when no config.json found on flash. +# 1. Connects WiFi in AP mode (fallback) or waits on existing LAN +# 2. Broadcasts UDP beacon: "identity-iot|{sn}" +# 3. Exposes HTTP on port 8080: +# GET / → node info (plaintext) +# POST /provision → AES-encrypted config payload → saves config.json +# ============================================================+ + +import network +import socket +import ujson +import ubinascii +import machine +import utime + +from identity_iot_aes import aes_cbc_encrypt, aes_cbc_decrypt + +# Hardcoded provisioning keys — same in Flutter app +# Change only with coordinated firmware + app update +PROG_KEY = b'IdentityIoTProv!Key_WIDE_AEL_32b' # 32 bytes +PROG_IV = b'IdentityIoTIV16b' # 16 bytes + +UDP_PORT = 5000 +HTTP_PORT = 8080 +BEACON_INTERVAL_MS = 2000 + + +def _get_sn(): + return ubinascii.hexlify(machine.unique_id()).decode() + + +def _get_ip(): + wlan = network.WLAN(network.STA_IF) + if wlan.isconnected(): + return wlan.ifconfig()[0] + return '0.0.0.0' + + +def _start_ap(): + """Fallback: start AP so app can connect even without existing WiFi.""" + sn = _get_sn() + ssid = 'IdentityIoT-' + sn[:8] + ap = network.WLAN(network.AP_IF) + ap.active(True) + ap.config(essid=ssid, password='provision', security=4) # WPA2 + utime.sleep(2) + print('[provisioning] AP started:', ssid, '— ip:', ap.ifconfig()[0]) + return ap.ifconfig()[0] + + +def _beacon_loop(udp_sock, beacon_bytes, stop_flag): + """Send UDP broadcast beacon. Called between HTTP polls.""" + try: + udp_sock.sendto(beacon_bytes, ('255.255.255.255', UDP_PORT)) + except Exception as e: + print('[beacon] error:', e) + + +def _parse_http_request(conn): + """Read HTTP request from socket. Returns (method, path, body).""" + try: + data = b'' + conn.settimeout(5) + while True: + chunk = conn.recv(1024) + if not chunk: + break + data += chunk + if b'\r\n\r\n' in data: + # Check if we have full body + header_end = data.index(b'\r\n\r\n') + 4 + headers_raw = data[:header_end].decode('utf-8', 'ignore') + body = data[header_end:] + content_length = 0 + for line in headers_raw.split('\r\n'): + if line.lower().startswith('content-length:'): + content_length = int(line.split(':')[1].strip()) + while len(body) < content_length: + chunk = conn.recv(1024) + if not chunk: + break + body += chunk + first_line = headers_raw.split('\r\n')[0] + parts = first_line.split(' ') + method = parts[0] if len(parts) > 0 else 'GET' + path = parts[1] if len(parts) > 1 else '/' + return method, path, body.decode('utf-8', 'ignore') + except Exception as e: + print('[http] parse error:', e) + return 'GET', '/', '' + + +def _http_response(conn, status, body): + resp = ( + 'HTTP/1.1 ' + status + '\r\n' + 'Content-Type: application/json\r\n' + 'Content-Length: ' + str(len(body)) + '\r\n' + 'Connection: close\r\n' + '\r\n' + body + ) + conn.sendall(resp.encode()) + + +def run_provisioning(): + """ + Block until a valid config is received from the Flutter app. + Returns config dict ready to save. + """ + sn = _get_sn() + print('[provisioning] SN:', sn) + + # Try STA first, fall back to AP + wlan_sta = network.WLAN(network.STA_IF) + if not wlan_sta.isconnected(): + ip = _start_ap() + else: + ip = wlan_sta.ifconfig()[0] + print('[provisioning] using existing WiFi, ip:', ip) + + beacon_msg = ujson.dumps({ + 'type': 'identity-iot', + 'sn': sn, + 'ip': ip, + 'port': HTTP_PORT, + }).encode() + + # UDP socket for beacon + udp = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + udp.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + udp.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + except Exception: + pass + udp.setblocking(False) + + # HTTP server socket + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(('0.0.0.0', HTTP_PORT)) + srv.listen(1) + srv.setblocking(False) + + print('[provisioning] HTTP listening on', ip + ':' + str(HTTP_PORT)) + print('[provisioning] UDP beacon on port', UDP_PORT) + + last_beacon = 0 + received_cfg = None + + while received_cfg is None: + + # Beacon + now = utime.ticks_ms() + if utime.ticks_diff(now, last_beacon) >= BEACON_INTERVAL_MS: + try: + udp.sendto(beacon_msg, ('255.255.255.255', UDP_PORT)) + except Exception: + pass + last_beacon = now + + # HTTP accept (non-blocking) + try: + conn, addr = srv.accept() + print('[provisioning] connection from', addr) + method, path, body = _parse_http_request(conn) + + if method == 'GET' and path == '/': + info = ujson.dumps({'sn': sn, 'ip': ip, 'status': 'awaiting_provision'}) + _http_response(conn, '200 OK', info) + + elif method == 'POST' and path == '/provision': + try: + outer = ujson.loads(body) + cipher_b64 = outer.get('data', '') + if not cipher_b64: + raise Exception('missing data') + + clear = aes_cbc_decrypt(cipher_b64, PROG_KEY, PROG_IV) + cfg = ujson.loads(clear) + + # Basic validation + required = ['wifi_ssid', 'wifi_pass', 'comm_key', 'comm_iv', + 'base_url', 'tenant_id', 'node_type', 'node_id'] + for k in required: + if k not in cfg: + raise Exception('missing field: ' + k) + + _http_response(conn, '200 OK', ujson.dumps({'status': 'ok', 'sn': sn})) + conn.close() + received_cfg = cfg + print('[provisioning] config received OK') + + except Exception as e: + print('[provisioning] provision error:', e) + _http_response(conn, '400 Bad Request', ujson.dumps({'status': 'error', 'msg': str(e)})) + conn.close() + + else: + _http_response(conn, '404 Not Found', '{}') + conn.close() + + except OSError: + # No connection yet — normal in non-blocking mode + utime.sleep_ms(100) + + udp.close() + srv.close() + return received_cfg