Harden HID, Wi-Fi, and web server handling
This commit is contained in:
@@ -47,6 +47,7 @@ CONFIG_DESCRIPTOR = bytes((
|
|||||||
STRINGS = [None, "PolterHID", "Awareness trainer", "PHID-001"]
|
STRINGS = [None, "PolterHID", "Awareness trainer", "PHID-001"]
|
||||||
_GET_DESCRIPTOR = 0x06
|
_GET_DESCRIPTOR = 0x06
|
||||||
_DESCRIPTOR_TYPE_REPORT = 0x22
|
_DESCRIPTOR_TYPE_REPORT = 0x22
|
||||||
|
_MAX_PENDING_REPORTS = 8
|
||||||
_INSTANCE = None
|
_INSTANCE = None
|
||||||
|
|
||||||
|
|
||||||
@@ -117,6 +118,16 @@ class HID:
|
|||||||
self._busy = False
|
self._busy = False
|
||||||
|
|
||||||
def _send(self, report):
|
def _send(self, report):
|
||||||
|
if not self._ready:
|
||||||
|
return
|
||||||
|
report_id = report[0]
|
||||||
|
for index, pending in enumerate(self._pending):
|
||||||
|
if pending[0] == report_id:
|
||||||
|
self._pending[index] = report
|
||||||
|
self._flush()
|
||||||
|
return
|
||||||
|
if len(self._pending) >= _MAX_PENDING_REPORTS:
|
||||||
|
self._pending.pop(0)
|
||||||
self._pending.append(report)
|
self._pending.append(report)
|
||||||
self._flush()
|
self._flush()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""PolterHID application entry point."""
|
"""PolterHID application entry point."""
|
||||||
|
|
||||||
|
import machine
|
||||||
import network
|
import network
|
||||||
import time
|
import time
|
||||||
import urandom
|
import urandom
|
||||||
@@ -12,14 +13,24 @@ from settings import load, save
|
|||||||
from web import WebServer
|
from web import WebServer
|
||||||
|
|
||||||
KEY_HOLD_MS = 80
|
KEY_HOLD_MS = 80
|
||||||
|
WIFI_RETRY_MS = 10000
|
||||||
|
WIFI_RETRY_SETTLE_MS = 50
|
||||||
|
WDT_TIMEOUT_MS = 15000
|
||||||
|
|
||||||
|
|
||||||
def connect_wifi(settings):
|
def connect_wifi(settings):
|
||||||
wlan = network.WLAN(network.STA_IF)
|
wlan = network.WLAN(network.STA_IF)
|
||||||
|
try:
|
||||||
wlan.active(True)
|
wlan.active(True)
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
set_mdns_hostname(wlan)
|
set_mdns_hostname(wlan)
|
||||||
if not wlan.isconnected():
|
disable_wifi_power_save(wlan)
|
||||||
|
if not wifi_is_connected(wlan):
|
||||||
|
try:
|
||||||
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
|
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
return wlan
|
return wlan
|
||||||
|
|
||||||
|
|
||||||
@@ -27,9 +38,57 @@ def set_mdns_hostname(wlan):
|
|||||||
"""Set the hostname used by ESP32's built-in mDNS responder."""
|
"""Set the hostname used by ESP32's built-in mDNS responder."""
|
||||||
try:
|
try:
|
||||||
network.hostname("polterhid")
|
network.hostname("polterhid")
|
||||||
except AttributeError:
|
return
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
# Older ESP32 builds expose the same setting through the WLAN object.
|
# Older ESP32 builds expose the same setting through the WLAN object.
|
||||||
wlan.config(dhcp_hostname="polterhid")
|
wlan.config(dhcp_hostname="polterhid")
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def disable_wifi_power_save(wlan):
|
||||||
|
"""Keep the station responsive when the port exposes the PM setting."""
|
||||||
|
try:
|
||||||
|
wlan.config(pm=network.WLAN.PM_NONE)
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def wifi_is_connected(wlan):
|
||||||
|
"""Return a safe station connection state during transient WLAN errors."""
|
||||||
|
try:
|
||||||
|
return bool(wlan.isconnected())
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reconnect_wifi(wlan, settings):
|
||||||
|
"""Force a fresh non-blocking connection attempt after a short settle."""
|
||||||
|
try:
|
||||||
|
wlan.disconnect()
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
time.sleep_ms(WIFI_RETRY_SETTLE_MS)
|
||||||
|
try:
|
||||||
|
wlan.active(True)
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
set_mdns_hostname(wlan)
|
||||||
|
disable_wifi_power_save(wlan)
|
||||||
|
try:
|
||||||
|
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def start_watchdog():
|
||||||
|
"""Start the application watchdog when supported by the board/port."""
|
||||||
|
try:
|
||||||
|
return machine.WDT(timeout=WDT_TIMEOUT_MS)
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def seconds_to_ms(seconds):
|
def seconds_to_ms(seconds):
|
||||||
@@ -47,10 +106,15 @@ def choose_due(now, minimum, maximum):
|
|||||||
|
|
||||||
def ip_address(wlan):
|
def ip_address(wlan):
|
||||||
"""Return the station IPv4 address across supported MicroPython versions."""
|
"""Return the station IPv4 address across supported MicroPython versions."""
|
||||||
|
if not wifi_is_connected(wlan):
|
||||||
|
return None
|
||||||
try:
|
try:
|
||||||
return wlan.ipconfig("addr4")[0]
|
return wlan.ipconfig("addr4")[0]
|
||||||
except AttributeError:
|
except (AttributeError, OSError, ValueError):
|
||||||
|
try:
|
||||||
return wlan.ifconfig()[0]
|
return wlan.ifconfig()[0]
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def mac_address(wlan):
|
def mac_address(wlan):
|
||||||
@@ -66,7 +130,7 @@ def mac_address(wlan):
|
|||||||
|
|
||||||
def wifi_rssi(wlan):
|
def wifi_rssi(wlan):
|
||||||
"""Return RSSI in dBm when the current ESP32 port exposes it."""
|
"""Return RSSI in dBm when the current ESP32 port exposes it."""
|
||||||
if not wlan.isconnected():
|
if not wifi_is_connected(wlan):
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return wlan.status("rssi")
|
return wlan.status("rssi")
|
||||||
@@ -101,31 +165,36 @@ def main():
|
|||||||
next_mouse = choose_due(now, settings["mouse_min_seconds"], settings["mouse_max_seconds"])
|
next_mouse = choose_due(now, settings["mouse_min_seconds"], settings["mouse_max_seconds"])
|
||||||
release_at = None
|
release_at = None
|
||||||
consumer_release_at = None
|
consumer_release_at = None
|
||||||
# connect_wifi() already started the first attempt; do not call connect()
|
# connect_wifi() already started the first non-blocking attempt.
|
||||||
# again while the ESP32 station is still in STAT_CONNECTING state.
|
next_wifi_retry = time.ticks_add(now, WIFI_RETRY_MS)
|
||||||
next_wifi_retry = time.ticks_add(now, 10000)
|
|
||||||
next_display_network_refresh = now
|
next_display_network_refresh = now
|
||||||
display_address = None
|
display_address = None
|
||||||
display_mac = None
|
display_mac = None
|
||||||
display_rssi = None
|
display_rssi = None
|
||||||
|
watchdog = start_watchdog()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
now = time.ticks_ms()
|
now = time.ticks_ms()
|
||||||
|
if watchdog is not None:
|
||||||
|
try:
|
||||||
|
watchdog.feed()
|
||||||
|
except (AttributeError, OSError, ValueError):
|
||||||
|
watchdog = None
|
||||||
activity_led.tick(now)
|
activity_led.tick(now)
|
||||||
server.poll()
|
server.poll(now)
|
||||||
|
|
||||||
# Reconnect without blocking injections or the web server indefinitely.
|
# Reset even a stuck STAT_CONNECTING attempt every ten seconds. The only
|
||||||
if (not wlan.isconnected() and
|
# deliberate blocking is the 50 ms settle between disconnect/connect.
|
||||||
time.ticks_diff(now, next_wifi_retry) >= 0 and
|
if (not wifi_is_connected(wlan) and
|
||||||
wlan.status() != network.STAT_CONNECTING):
|
time.ticks_diff(now, next_wifi_retry) >= 0):
|
||||||
wlan.connect(settings["wifi_ssid"], settings["wifi_password"])
|
next_wifi_retry = time.ticks_add(now, WIFI_RETRY_MS)
|
||||||
next_wifi_retry = time.ticks_add(now, 10000)
|
reconnect_wifi(wlan, settings)
|
||||||
|
|
||||||
if display is not None:
|
if display is not None:
|
||||||
# The display itself is frame-driven; query WLAN state only once per
|
# The display itself is frame-driven; query WLAN state only once per
|
||||||
# second rather than for every 25 ms application-loop iteration.
|
# second rather than for every 25 ms application-loop iteration.
|
||||||
if time.ticks_diff(now, next_display_network_refresh) >= 0:
|
if time.ticks_diff(now, next_display_network_refresh) >= 0:
|
||||||
display_address = ip_address(wlan) if wlan.isconnected() else None
|
display_address = ip_address(wlan)
|
||||||
display_mac = mac_address(wlan)
|
display_mac = mac_address(wlan)
|
||||||
display_rssi = wifi_rssi(wlan)
|
display_rssi = wifi_rssi(wlan)
|
||||||
next_display_network_refresh = time.ticks_add(now, 1000)
|
next_display_network_refresh = time.ticks_add(now, 1000)
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
"""Small non-blocking password-protected HTTP configuration interface."""
|
"""Small non-blocking password-protected HTTP configuration interface."""
|
||||||
|
|
||||||
import ubinascii
|
import gc
|
||||||
import socket
|
import socket
|
||||||
|
import time
|
||||||
|
import ubinascii
|
||||||
|
|
||||||
|
|
||||||
|
CLIENT_TIMEOUT_MS = 5000
|
||||||
|
MAX_REQUEST_BYTES = 4096
|
||||||
|
|
||||||
|
|
||||||
class WebServer:
|
class WebServer:
|
||||||
@@ -11,33 +17,47 @@ class WebServer:
|
|||||||
self.on_volume = on_volume
|
self.on_volume = on_volume
|
||||||
self._client = None
|
self._client = None
|
||||||
self._buffer = b""
|
self._buffer = b""
|
||||||
|
self._last_activity = None
|
||||||
self._socket = socket.socket()
|
self._socket = socket.socket()
|
||||||
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
self._socket.bind(("0.0.0.0", 80))
|
self._socket.bind(("0.0.0.0", 80))
|
||||||
self._socket.listen(1)
|
self._socket.listen(1)
|
||||||
self._socket.settimeout(0)
|
self._socket.settimeout(0)
|
||||||
|
|
||||||
def poll(self):
|
def poll(self, now=None):
|
||||||
|
if now is None:
|
||||||
|
now = time.ticks_ms()
|
||||||
|
|
||||||
if self._client is None:
|
if self._client is None:
|
||||||
try:
|
try:
|
||||||
self._client, _ = self._socket.accept()
|
self._client, _ = self._socket.accept()
|
||||||
self._client.settimeout(0)
|
self._client.settimeout(0)
|
||||||
self._buffer = b""
|
|
||||||
except OSError:
|
except OSError:
|
||||||
return
|
if self._client is not None:
|
||||||
try:
|
|
||||||
data = self._client.recv(512)
|
|
||||||
if data:
|
|
||||||
self._buffer += data
|
|
||||||
elif not self._buffer:
|
|
||||||
self._close()
|
self._close()
|
||||||
return
|
return
|
||||||
except OSError:
|
self._buffer = b""
|
||||||
pass
|
self._last_activity = now
|
||||||
|
|
||||||
if len(self._buffer) > 4096:
|
try:
|
||||||
|
data = self._client.recv(512)
|
||||||
|
except OSError:
|
||||||
|
data = None
|
||||||
|
|
||||||
|
if data == b"":
|
||||||
|
self._close()
|
||||||
|
return
|
||||||
|
if data:
|
||||||
|
self._last_activity = now
|
||||||
|
if len(self._buffer) + len(data) > MAX_REQUEST_BYTES:
|
||||||
self._reply(413, "Request too large")
|
self._reply(413, "Request too large")
|
||||||
return
|
return
|
||||||
|
self._buffer += data
|
||||||
|
elif time.ticks_diff(now, self._last_activity) >= CLIENT_TIMEOUT_MS:
|
||||||
|
self._close()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
return
|
||||||
marker = self._buffer.find(b"\r\n\r\n")
|
marker = self._buffer.find(b"\r\n\r\n")
|
||||||
if marker < 0:
|
if marker < 0:
|
||||||
return
|
return
|
||||||
@@ -108,12 +128,16 @@ class WebServer:
|
|||||||
self._close()
|
self._close()
|
||||||
|
|
||||||
def _close(self):
|
def _close(self):
|
||||||
try:
|
client = self._client
|
||||||
self._client.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._client = None
|
self._client = None
|
||||||
self._buffer = b""
|
self._buffer = b""
|
||||||
|
self._last_activity = None
|
||||||
|
if client is not None:
|
||||||
|
try:
|
||||||
|
client.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
def _parse_form(body):
|
def _parse_form(body):
|
||||||
|
|||||||
Reference in New Issue
Block a user