219 lines
7.2 KiB
Python
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 |