Files
identity-micropython/provisioning_server.py

219 lines
7.2 KiB
Python

# ============================================================+
# 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 == '/' or path == '/info'):
info = ujson.dumps({
'sn': sn,
'ip': ip,
'status': 'awaiting_provision',
'type': 'identity-iot',
'port': HTTP_PORT,
})
_http_response(conn, '200 OK', info)
conn.close()
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',
'identity_iot_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