Identity IoT: initial release — boot manager, provisioning, sensor/relay
This commit is contained in:
212
provisioning_server.py
Normal file
212
provisioning_server.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user