From 6ee36ee45b3181b82f3fecfbb99377135c0dce98 Mon Sep 17 00:00:00 2001 From: acidburns Date: Wed, 11 Feb 2026 21:16:23 +0100 Subject: [PATCH] Add Pi Zero headless serial bridge with AP portal and daily RTC-based logs --- README.md | Bin 642 -> 534 bytes config/dnsmasq.conf | 9 + config/hostapd.conf | 16 + install.sh | 59 ++++ requirements.txt | 4 + src/__pycache__/app_state.cpython-312.pyc | Bin 0 -> 2275 bytes src/__pycache__/main.cpython-312.pyc | Bin 0 -> 6686 bytes .../network_manager.cpython-312.pyc | Bin 0 -> 18185 bytes src/__pycache__/rtc_sync.cpython-312.pyc | Bin 0 -> 4817 bytes src/__pycache__/serial_bridge.cpython-312.pyc | Bin 0 -> 11213 bytes src/__pycache__/webapp.cpython-312.pyc | Bin 0 -> 5715 bytes src/app_state.py | 37 +++ src/main.py | 127 ++++++++ src/network_manager.py | 297 ++++++++++++++++++ src/rtc_sync.py | 68 ++++ src/serial_bridge.py | 186 +++++++++++ src/webapp.py | 85 +++++ static/style.css | 79 +++++ systemd/serial-bridge.service | 17 + systemd/serial-dnsmasq.service | 12 + systemd/serial-hostapd.service | 13 + templates/index.html | 119 +++++++ templates/serial.html | 45 +++ 23 files changed, 1173 insertions(+) create mode 100644 config/dnsmasq.conf create mode 100644 config/hostapd.conf create mode 100644 install.sh create mode 100644 requirements.txt create mode 100644 src/__pycache__/app_state.cpython-312.pyc create mode 100644 src/__pycache__/main.cpython-312.pyc create mode 100644 src/__pycache__/network_manager.cpython-312.pyc create mode 100644 src/__pycache__/rtc_sync.cpython-312.pyc create mode 100644 src/__pycache__/serial_bridge.cpython-312.pyc create mode 100644 src/__pycache__/webapp.cpython-312.pyc create mode 100644 src/app_state.py create mode 100644 src/main.py create mode 100644 src/network_manager.py create mode 100644 src/rtc_sync.py create mode 100644 src/serial_bridge.py create mode 100644 src/webapp.py create mode 100644 static/style.css create mode 100644 systemd/serial-bridge.service create mode 100644 systemd/serial-dnsmasq.service create mode 100644 systemd/serial-hostapd.service create mode 100644 templates/index.html create mode 100644 templates/serial.html diff --git a/README.md b/README.md index d2a8dc274c6c4476648f4fafe5771789eecee606..081a0046bde8ab7a2870246577b59cc8228468f2 100644 GIT binary patch literal 534 zcmY+BK~KXl42AFc6;Ij)gw|?+5bQQ6Xa_)J9YS!SX=_$P+Ej@L+rKB-28f<4`}2Fw z>i}7Hf?GS+xO7}E33KgxMn>goxg@95lyq&#`|Y44=QfxzUdUNIp?CJ3DjBOGp7@4Q z8KzEP9p1DtrLI0Onc<-DmDfZ>QaTph=L~osF6Tn8KAE)W);y4+dohPTsC@sKU zRB3HZk-yGoC&zfqFR>FrxjX!erGep3LY`m4izD4acQtCwW&>lFc;oPr;F31BrL^aC zymvUb*Pf5d5Y!+_4Ew*FDoa$J37nJBODAGsMOH%#7ch)%NJ zCOUh(V0+0-e%6KZvu}e(^m;*QDF%f6LD^o8t54)*pE{ zFKxMbeD=P5m)9aZF~LtLFe=G0qaNt^$3V;t#L4-2B_QttogUkNk?XJmkL&&4qKn_J zyPf^wkGQxpN0yiH!oR;cqg}s=fL(w_QXn#nwQz@su|0K zSi4TEEa2D^xHHIL*Tc&CGi6crUIqQLPbIB)&EQqDP> "${WPA_CONF}" +grep -q '^update_config=' "${WPA_CONF}" || echo 'update_config=1' >> "${WPA_CONF}" + +systemctl disable --now hostapd.service || true +systemctl disable --now dnsmasq.service || true + +install -d -m 755 /home/pi +chown pi:pi /home/pi || true + +systemctl daemon-reload +systemctl enable serial-bridge.service +systemctl restart serial-bridge.service + +echo "Installation abgeschlossen. Logs: journalctl -u serial-bridge -f" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..686d2ee --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +Flask==3.1.0 +pyserial==3.5 +ntplib==0.4.0 +waitress==3.0.2 diff --git a/src/__pycache__/app_state.cpython-312.pyc b/src/__pycache__/app_state.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4f2df537c1cad387f7e908c2db77f4d7bcd8eb63 GIT binary patch literal 2275 zcma)7O>7%Q6rTO_+D>f8N$MnR5}VjbF^Eu5A{8k`iU_R)C>&Z2*~@CPJ5JrMcg@V2 zkdi|VMG8_Y!l|i*XgDBIwTDPt;09+dM6MKf1ZpKt+?*Q8C@0>Gf0{;6bu7Pm-+Qxf zXTJC5?XR&|6v6n3-6;O7BJ?K*ouRdr#v&-Ukb@k_MJ36QO0pqK9Lug!QVmt;imR1E zMyRA4x`ePKqYKDUuOmmhC%599RMoQz-~V*CcOe(Nv*dUah$->5dg5hN38EP`fw6Zth>J7k?zMk2R80937K*0rdmgp}?6Cf#7hvMyz_dQGimtWnVis+?i{1*; zApi*~WfrFpsY1;Xrqo)&46!KWBQP-`K7n*399*y}ZeSKHJMhUG>$&RrtDZ?|(V;6X zRuE!w*vJa8Y;0D%py=jh(hokS5?m%sbs?op@4P}2u~~@VphCPwTj@lBIRt->8_OVW zp>=cx=Hp`)NoZYKM`k-K2(8P2+NB1$7r#yhB(#RI=nX`rRVdsgc$Ql}RhH1IbXQu; zDk@z$C+o%OA*UcPD*~Bj`X!|%f~Zx z@qY_AFE8eG!nZ+sxZtlQF)q6GOhn=$tt38oC0tbKwypIN;fbc4@OFyB)U(R;vL9Sa zKFw|0#Os7wAdVz85{W-PmA*4~d#*MywLdYtH!)kAdS!p=+}_l=+W5pB|F-|s(Zau{ zvpWm>xtYD(Of@}wkO&XPn+n8fA`nlwIHdf9H|Iy@&nUl~(dOUM^Ah2F^1f)Ao>ju8 z$)YCjiWQgZy{7qb#d2F7!4y-MecxpX)3iL#hnrmVJ!+c&MIcjv5789}FBX#Jg7ZoE zXNU}ffG?NpTLIx8%ofMvTziQNUP>(FFJFPCOmYD3q6SDL?hJ`=jSIev{s7{0^muS& z3t&OH(*}GLW{V;8Q&u)#BT^ZMt)Q`?I~1ul|hylj+VW7V{C-)BT`h zO8Dgzw6REVnPAI-509XUg@Uy*2mIhICIuI~L$>1ao^lwTYbEh*@+LrDiS#sxx*|!^ fBlPkg=;R|bRu3V$rzww1@j3$0PyilqC<6TpZc6Tt literal 0 HcmV?d00001 diff --git a/src/__pycache__/main.cpython-312.pyc b/src/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..336dbaae8b8845d45c9bb5b9071848116371fdc4 GIT binary patch literal 6686 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 M;Vl+%U6hso1`GyTnE(I) literal 0 HcmV?d00001 diff --git a/src/__pycache__/network_manager.cpython-312.pyc b/src/__pycache__/network_manager.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e453501bba0b7944d5c44c5d6149e65a33c3244 GIT binary patch literal 18185 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/rtc_sync.cpython-312.pyc b/src/__pycache__/rtc_sync.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..90a446e264d85eeec930a505cb4c9ccb1579d7e1 GIT binary patch literal 4817 zcmeHLT}&I<6~6Q1Ukt`^48bA9gm{TJ3xtq{g0lG|B{XW-O<<#jt>iksW8&X&X2t;= z!$uVkAZ-PCg4JrZY+q`WNTpKM<}p(DVWqx|0~K^ORU@tXkT-L2bpx)~|tCAOgXIjj*(aG6F|g zfu|h&^VAfvQu8z%jykDDFjHQz#AC5GM-k!?3#)h4w&Jr=&gp{+6hy@rPbaX?z@ztgTwD$52Zq*f~1*bR8_M} zN;pV``_VA)DJ3101?fJB&61#Gq|{)z^hkJoyjBkWx7vX$kQ|dELEwy%W1+?Dv^Z!L zpMj7GvdjzyI0-LPLW7X1fCPArmxV;6tXsw{eNeWYvJ~#X1IiPraC>?}vj&6FR8$ED zvkm16wUc}O-q-WmQw`-b;8R8GD6sdEcNM6;j;h#4h}`jm$qmCaQ~0hB zj)Ohg<;n4 zXT#jo?9?J%IrEf$aaDc)$7{}zY7I#TKpfuD2^zS%uJ14~pf6CP?5A(6sAFji);SV0 z?`|9X@wL|wq(M0G@LdZ5% z=(;Q&vjaqjrI+!TxO*JcBPKD!z!A zNl{-&@dY@|a(6785X13pwoTqPwY7mSy+YSsW^~h)&}FiRhbCl2Nczy*d*nlhJMWWs4HV;9S8;V;o9v5#IS6#wCi!4V13nG|2iT=!R9K{f4k21~ zCXEmuL}<&%npsvr0$2t$xFJHG6iqL)MK43wlkt=#|Eflq3F7gBU}pOm-yS(aj64kl zaK`CgcQzNC&1=q<4fi3nu`N%3Aufw*+j-S{L3LkH9T#3Y-E$MO6VDsEp5C09San{U z;Ww(Ba~EbWEQX#}9o=x$&Rv_mw(dAya2!|tm!Dl;b6nkUpW1L9oL3eeJbLiFeqf3I zJpNf681*{?>viuG>fTwcyP-O7sMZ^Xo$p2|^h@2&PeAViv3(1gmEL@k&9VF0Gg#+7 zo{pCHgnf+rK$+x}QnVyFE~W>5LsFt8WSHcY>Qdi+)|um#o!A*e*eWZ4xJ6wjO*vD{ zQznKc<#+Zn!`bn%$_h16GqvQ*qY#mw*ml;lUuPQuH-T ztg%c0&7!Y4Vn-`wrF$c zw9+biAZIlYE6m=8RBF;XWgRNkW6T&C=WdfRCg2`?g^vlr$pTq`92HZ#GedU7D%q^d zf4j|}Z1Yo}f1uZYz1Kga@#t__wk;#!meLzYT9M%U^k-{O{EZK1y}R)M-3jpEel+BR z#6$uVB*WQiU;zwLVV@ZHoyeMe?+y0_vaX$80}Oh70k>v`d?gXRquD^%mxu}}MYBl4 zy^H{Uj zbQlQmMlr(t0Uc&A7gI#Yd=NjgEfs;Va!@lYCI1u1L$b-%*v`E09A5YM3m*Tfr*)=( zqyEUk)T1fD5b-nd>Af#n=ciWdZ_W(7aMjI^uh+L0>RVS`CuT0c@Elq9oGf@w<}d&1 zyT3X2%X4d_Qc(r()o7xy_aUvY4sn;GOCCZKx~Av6Lsj;;SE1^;PMARUAIf{Yc> z3H*Od@&i-m|Jg=N9E_k`jQhL7yzcOr0@M~eAq&9qd!qkXG5SjZT)qRyo{)WLOp0Q_ z*S$n{a&#Ju-hm2_`FBH4%uPq9?n2DY2d4;A5sg>qY1l<8SG2qu*iiNdSZ4 z@fuKkQWxr|EoZ{`K@-U&0<6>zn@R&f0wyErGK=vbAe&}M%A-1i9q?R62QV_`(mEO% zU)y_Sw{z9oHS3%)&s+ZB^ZaqU|wMg4i)A}@W~C|s;N*?2nIEKFqjl+CV{*o7`&GWB@Bs#Lp2WKj)ZBr#zj+# zgvp0i+b1UBR|!R+?;0P7lAiJMcVI^&4aR$tI+5&LvNQ*D0G71P<{er zn!IK?lYjBA2)6AGU78PuC zY?`g6o6KTnX*l1tbh~i0V~b$N>w`|ynLNyfYDIW@>yMSNN6i zimmWwuQ6rwpw-m5JYF?M^F{K8$F5}VcV=sq@$@(Nxut7 z&mb&^((n@o{<5Lpt#JYP^U^n$vTZ$QyvBLyeGs6l$rw5zelX0J#P%g|{fV^xksSJp TRR5VY{mtCNbbd{M=u7+yLSaW3 literal 0 HcmV?d00001 diff --git a/src/__pycache__/serial_bridge.cpython-312.pyc b/src/__pycache__/serial_bridge.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a7ec64c87c9dd3f56eba5d6f773588d3d8915d01 GIT binary patch literal 11213 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 literal 0 HcmV?d00001 diff --git a/src/__pycache__/webapp.cpython-312.pyc b/src/__pycache__/webapp.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a05f4ec3cc4279469cc91deb5b6a0c57e663cd9 GIT binary patch literal 5715 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 Q;2-ug9M}H?LC9A4Z<%4t#Q*>R literal 0 HcmV?d00001 diff --git a/src/app_state.py b/src/app_state.py new file mode 100644 index 0000000..7088a87 --- /dev/null +++ b/src/app_state.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field +from threading import Lock +from typing import Dict, List + + +@dataclass +class AppState: + ap_mode: bool = False + wifi_connected: bool = False + internet_available: bool = False + connecting: bool = False + status_message: str = "Startup" + last_error: str = "" + known_ssids: List[str] = field(default_factory=list) + ap_grace_until: float = 0.0 + lock: Lock = field(default_factory=Lock, repr=False) + + def update_status(self, message: str, error: str = "") -> None: + with self.lock: + self.status_message = message + self.last_error = error + + def set_known_ssids(self, ssids: List[str]) -> None: + with self.lock: + self.known_ssids = ssids + + def snapshot(self) -> Dict[str, object]: + with self.lock: + return { + "ap_mode": self.ap_mode, + "wifi_connected": self.wifi_connected, + "internet_available": self.internet_available, + "connecting": self.connecting, + "status_message": self.status_message, + "last_error": self.last_error, + "known_ssids": list(self.known_ssids), + } \ No newline at end of file diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..a02c473 --- /dev/null +++ b/src/main.py @@ -0,0 +1,127 @@ +import logging +import signal +import threading +import time +from typing import Optional + +from app_state import AppState +from network_manager import NetworkManager +from rtc_sync import RTCAndNTPManager +from serial_bridge import SerialBridge, SerialBroadcaster +from webapp import WebPortal + + +LOG = logging.getLogger("serial-bridge") + + +class Supervisor(threading.Thread): + def __init__(self, state: AppState, nm: NetworkManager, rtc: RTCAndNTPManager) -> None: + super().__init__(daemon=True) + self.state = state + self.nm = nm + self.rtc = rtc + self._stop_event = threading.Event() + self._ntp_synced = False + + def stop(self) -> None: + self._stop_event.set() + + def run(self) -> None: + while not self._stop_event.is_set(): + try: + self.nm.refresh_state() + snapshot = self.state.snapshot() + + should_have_ap = not (snapshot["wifi_connected"] and snapshot["internet_available"]) + in_grace = time.time() < self.state.ap_grace_until + + if should_have_ap and not snapshot["ap_mode"] and not snapshot["connecting"] and not in_grace: + LOG.warning("No active internet, enabling AP fallback") + self.nm.start_ap() + + if not should_have_ap and snapshot["ap_mode"]: + LOG.info("Internet restored, disabling AP") + self.nm.stop_ap() + + if snapshot["internet_available"] and not self._ntp_synced: + ok, msg = self.rtc.sync_ntp_and_rtc() + if ok: + LOG.info(msg) + self._ntp_synced = True + else: + LOG.warning("NTP/RTC sync failed: %s", msg) + except Exception as exc: + LOG.exception("Supervisor loop failed: %s", exc) + + self._stop_event.wait(15) + + +def configure_logging() -> None: + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + + +def main() -> None: + configure_logging() + state = AppState() + + nm = NetworkManager(state=state) + rtc = RTCAndNTPManager(state=state) + broadcaster = SerialBroadcaster() + bridge = SerialBridge(broadcaster=broadcaster) + supervisor = Supervisor(state=state, nm=nm, rtc=rtc) + + state.update_status("Initializing", "") + + rtc_ok = False + for attempt in range(1, 4): + ok_rtc, msg_rtc = rtc.sync_from_rtc() + if ok_rtc: + rtc_ok = True + LOG.info(msg_rtc) + break + LOG.warning("RTC read attempt %s failed: %s", attempt, msg_rtc) + time.sleep(1) + if not rtc_ok: + LOG.warning("Continuing with current system time because RTC sync did not succeed") + + nm.refresh_state() + snap = state.snapshot() + + if snap["wifi_connected"] and snap["internet_available"]: + LOG.info("Wi-Fi + internet detected, staying in client mode") + ok_ntp, msg_ntp = rtc.sync_ntp_and_rtc() + if ok_ntp: + LOG.info(msg_ntp) + else: + LOG.warning("Initial NTP sync failed: %s", msg_ntp) + else: + LOG.warning("No Wi-Fi internet, starting AP fallback") + nm.start_ap() + + bridge.start() + supervisor.start() + + stop_event = threading.Event() + + def _handle_signal(_sig: int, _frame: Optional[object]) -> None: + stop_event.set() + + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + web = WebPortal(state=state, network_manager=nm, broadcaster=broadcaster) + + try: + web.run(host="0.0.0.0", port=80) + finally: + bridge.stop() + supervisor.stop() + bridge.join(timeout=5) + supervisor.join(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/src/network_manager.py b/src/network_manager.py new file mode 100644 index 0000000..cbd6955 --- /dev/null +++ b/src/network_manager.py @@ -0,0 +1,297 @@ +import re +import shlex +import socket +import subprocess +import time +from typing import Dict, List, Optional, Tuple + +from app_state import AppState + + +class NetworkManager: + def __init__( + self, + state: AppState, + interface: str = "wlan0", + hostapd_unit: str = "serial-hostapd.service", + dnsmasq_unit: str = "serial-dnsmasq.service", + ap_cidr: str = "192.168.4.1/24", + ) -> None: + self.state = state + self.interface = interface + self.hostapd_unit = hostapd_unit + self.dnsmasq_unit = dnsmasq_unit + self.ap_cidr = ap_cidr + + def _run( + self, + args: List[str], + timeout: int = 12, + check: bool = False, + ) -> subprocess.CompletedProcess: + return subprocess.run( + args, + capture_output=True, + text=True, + timeout=timeout, + check=check, + ) + + def _run_quiet(self, args: List[str], timeout: int = 12) -> bool: + try: + proc = self._run(args, timeout=timeout, check=False) + return proc.returncode == 0 + except Exception: + return False + + def _wpa_status(self) -> Dict[str, str]: + try: + proc = self._run(["wpa_cli", "-i", self.interface, "status"], timeout=8) + if proc.returncode != 0: + return {} + status: Dict[str, str] = {} + for line in proc.stdout.splitlines(): + if "=" not in line: + continue + key, value = line.strip().split("=", 1) + status[key] = value + return status + except Exception: + return {} + + def is_wifi_connected(self) -> bool: + status = self._wpa_status() + if status.get("wpa_state") != "COMPLETED": + return False + if status.get("ip_address"): + return True + return bool(self.get_ipv4_address()) + + def get_ipv4_address(self) -> Optional[str]: + try: + proc = self._run(["ip", "-4", "addr", "show", "dev", self.interface], timeout=5) + if proc.returncode != 0: + return None + match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)/", proc.stdout) + return match.group(1) if match else None + except Exception: + return None + + def has_default_route(self) -> bool: + try: + proc = self._run(["ip", "route", "show", "default"], timeout=5) + if proc.returncode != 0: + return False + return bool(proc.stdout.strip()) + except Exception: + return False + + def has_internet(self) -> bool: + if not self.is_wifi_connected(): + return False + if not self.has_default_route(): + return False + try: + with socket.create_connection(("1.1.1.1", 53), timeout=2): + pass + except OSError: + return False + try: + socket.gethostbyname("pool.ntp.org") + except OSError: + return False + return True + + def _service_action(self, action: str, unit: str) -> bool: + return self._run_quiet(["systemctl", action, unit], timeout=20) + + def _stop_wpa_services(self) -> None: + self._service_action("stop", f"wpa_supplicant@{self.interface}.service") + self._service_action("stop", "wpa_supplicant.service") + + def _start_wpa_services(self) -> None: + if not self._service_action("start", f"wpa_supplicant@{self.interface}.service"): + self._service_action("start", "wpa_supplicant.service") + + def start_ap(self) -> bool: + with self.state.lock: + if self.state.ap_mode: + return True + + self._run_quiet(["rfkill", "unblock", "wlan"], timeout=6) + self._service_action("stop", "dhcpcd.service") + self._stop_wpa_services() + self._run_quiet(["ip", "link", "set", self.interface, "down"], timeout=6) + self._run_quiet(["ip", "addr", "flush", "dev", self.interface], timeout=6) + self._run_quiet(["ip", "addr", "add", self.ap_cidr, "dev", self.interface], timeout=6) + self._run_quiet(["ip", "link", "set", self.interface, "up"], timeout=6) + + ok_hostapd = self._service_action("start", self.hostapd_unit) + ok_dnsmasq = self._service_action("start", self.dnsmasq_unit) + + if ok_hostapd and ok_dnsmasq: + with self.state.lock: + self.state.ap_mode = True + self.state.update_status("AP mode active", "") + return True + + self.state.update_status("AP start failed", "hostapd/dnsmasq could not be started") + return False + + def stop_ap(self) -> bool: + self._service_action("stop", self.dnsmasq_unit) + self._service_action("stop", self.hostapd_unit) + + self._run_quiet(["ip", "link", "set", self.interface, "down"], timeout=6) + self._run_quiet(["ip", "addr", "flush", "dev", self.interface], timeout=6) + self._run_quiet(["ip", "link", "set", self.interface, "up"], timeout=6) + + self._start_wpa_services() + self._service_action("restart", "dhcpcd.service") + + with self.state.lock: + self.state.ap_mode = False + self.state.update_status("Client mode active", "") + return True + + def _parse_scan_results(self, output: str) -> List[Tuple[str, int]]: + results: Dict[str, int] = {} + for line in output.splitlines()[1:]: + parts = line.split("\t") + if len(parts) < 5: + continue + ssid = parts[4].strip() + if not ssid: + continue + try: + signal = int(parts[2]) + except ValueError: + signal = -100 + if ssid not in results or signal > results[ssid]: + results[ssid] = signal + sorted_items = sorted(results.items(), key=lambda x: x[1], reverse=True) + return sorted_items + + def _scan_with_wpa_cli(self) -> List[str]: + cmd_scan = ["wpa_cli", "-i", self.interface, "scan"] + proc = self._run(cmd_scan, timeout=15) + if proc.returncode != 0 or "OK" not in proc.stdout: + return [] + + for _ in range(8): + time.sleep(1) + proc_results = self._run(["wpa_cli", "-i", self.interface, "scan_results"], timeout=10) + if proc_results.returncode == 0 and len(proc_results.stdout.splitlines()) > 1: + parsed = self._parse_scan_results(proc_results.stdout) + if parsed: + return [ssid for ssid, _signal in parsed] + return [] + + def _scan_with_iw(self) -> List[str]: + try: + proc = self._run(["iw", "dev", self.interface, "scan", "ap-force"], timeout=20) + except Exception: + return [] + + if proc.returncode != 0: + return [] + + ssids: List[str] = [] + seen = set() + for line in proc.stdout.splitlines(): + line = line.strip() + if line.startswith("SSID: "): + ssid = line.replace("SSID: ", "", 1).strip() + if ssid and ssid not in seen: + seen.add(ssid) + ssids.append(ssid) + return ssids + + def scan_networks(self) -> List[str]: + ssids = self._scan_with_wpa_cli() + if not ssids: + ssids = self._scan_with_iw() + + if ssids: + self.state.set_known_ssids(ssids) + self.state.update_status(f"Scan found {len(ssids)} network(s)", "") + return ssids + + snapshot = self.state.snapshot() + cached = snapshot.get("known_ssids", []) + self.state.update_status("Scan failed, returning cached list", "No fresh scan results") + return list(cached) + + def connect_to_wifi(self, ssid: str, password: str, timeout: int = 50) -> Tuple[bool, str]: + if not ssid: + return False, "SSID is required" + + with self.state.lock: + self.state.connecting = True + self.state.status_message = f"Connecting to {ssid}" + self.state.last_error = "" + self.state.ap_grace_until = time.time() + 90 + + try: + self.stop_ap() + self._start_wpa_services() + self._service_action("restart", "dhcpcd.service") + + add_proc = self._run(["wpa_cli", "-i", self.interface, "add_network"], timeout=10) + if add_proc.returncode != 0: + return False, "add_network failed" + + network_id = add_proc.stdout.strip().splitlines()[-1].strip() + if not network_id.isdigit(): + return False, f"invalid network id: {network_id}" + + commands = [ + ["wpa_cli", "-i", self.interface, "set_network", network_id, "ssid", f'"{ssid}"'], + ] + + if password: + commands.extend( + [ + ["wpa_cli", "-i", self.interface, "set_network", network_id, "psk", f'"{password}"'], + ["wpa_cli", "-i", self.interface, "set_network", network_id, "key_mgmt", "WPA-PSK"], + ] + ) + else: + commands.append(["wpa_cli", "-i", self.interface, "set_network", network_id, "key_mgmt", "NONE"]) + + commands.extend( + [ + ["wpa_cli", "-i", self.interface, "set_network", network_id, "scan_ssid", "1"], + ["wpa_cli", "-i", self.interface, "enable_network", network_id], + ["wpa_cli", "-i", self.interface, "select_network", network_id], + ["wpa_cli", "-i", self.interface, "save_config"], + ["wpa_cli", "-i", self.interface, "reconfigure"], + ] + ) + + for cmd in commands: + proc = self._run(cmd, timeout=10) + if proc.returncode != 0 or "FAIL" in proc.stdout: + joined = " ".join(shlex.quote(c) for c in cmd) + return False, f"Command failed: {joined}" + + deadline = time.time() + timeout + while time.time() < deadline: + if self.is_wifi_connected(): + self.state.update_status(f"Connected to {ssid}", "") + return True, "Connected" + time.sleep(2) + + return False, "Timeout waiting for Wi-Fi association" + except Exception as exc: + return False, f"Connect error: {exc}" + finally: + with self.state.lock: + self.state.connecting = False + + def refresh_state(self) -> None: + wifi = self.is_wifi_connected() + internet = self.has_internet() if wifi else False + with self.state.lock: + self.state.wifi_connected = wifi + self.state.internet_available = internet diff --git a/src/rtc_sync.py b/src/rtc_sync.py new file mode 100644 index 0000000..0319506 --- /dev/null +++ b/src/rtc_sync.py @@ -0,0 +1,68 @@ +import os +import subprocess +from datetime import datetime, timezone +from typing import Tuple + +import ntplib + +from app_state import AppState + + +class RTCAndNTPManager: + def __init__(self, state: AppState, rtc_device: str = "/dev/rtc0", ntp_server: str = "pool.ntp.org") -> None: + self.state = state + self.rtc_device = rtc_device + self.ntp_server = ntp_server + + def _run(self, args, timeout: int = 12) -> subprocess.CompletedProcess: + return subprocess.run(args, capture_output=True, text=True, timeout=timeout, check=False) + + def rtc_available(self) -> bool: + return os.path.exists(self.rtc_device) + + def sync_from_rtc(self) -> Tuple[bool, str]: + if not self.rtc_available(): + return False, f"RTC not found at {self.rtc_device}" + + proc = self._run(["hwclock", "-s", "--utc"], timeout=10) + if proc.returncode == 0: + self.state.update_status("System time loaded from RTC", "") + return True, "RTC -> system time ok" + return False, (proc.stderr or proc.stdout or "hwclock -s failed").strip() + + def sync_ntp_to_system(self, timeout: int = 6) -> Tuple[bool, str]: + try: + client = ntplib.NTPClient() + response = client.request(self.ntp_server, version=3, timeout=timeout) + ts = float(response.tx_time) + dt_utc = datetime.fromtimestamp(ts, tz=timezone.utc) + iso_utc = dt_utc.strftime("%Y-%m-%d %H:%M:%S") + + proc = self._run(["date", "-u", "-s", iso_utc], timeout=10) + if proc.returncode != 0: + return False, (proc.stderr or proc.stdout or "date -s failed").strip() + + self.state.update_status("System time synced via NTP", "") + return True, f"NTP sync ok ({iso_utc} UTC)" + except Exception as exc: + return False, f"NTP sync failed: {exc}" + + def write_system_time_to_rtc(self) -> Tuple[bool, str]: + if not self.rtc_available(): + return False, f"RTC not found at {self.rtc_device}" + + proc = self._run(["hwclock", "-w", "--utc"], timeout=10) + if proc.returncode == 0: + self.state.update_status("RTC updated from system time", "") + return True, "system -> RTC ok" + return False, (proc.stderr or proc.stdout or "hwclock -w failed").strip() + + def sync_ntp_and_rtc(self) -> Tuple[bool, str]: + ok_ntp, msg_ntp = self.sync_ntp_to_system() + if not ok_ntp: + return False, msg_ntp + + ok_rtc, msg_rtc = self.write_system_time_to_rtc() + if not ok_rtc: + return False, f"NTP ok, RTC write failed: {msg_rtc}" + return True, "NTP + RTC sync successful" diff --git a/src/serial_bridge.py b/src/serial_bridge.py new file mode 100644 index 0000000..b0b13c8 --- /dev/null +++ b/src/serial_bridge.py @@ -0,0 +1,186 @@ +import glob +import os +import queue +import threading +import time +from datetime import datetime, timedelta +from typing import List, Optional + +import serial + + +class SerialBroadcaster: + def __init__(self) -> None: + self._subscribers: List[queue.Queue] = [] + self._lock = threading.Lock() + + def subscribe(self) -> queue.Queue: + q: queue.Queue = queue.Queue(maxsize=500) + with self._lock: + self._subscribers.append(q) + return q + + def unsubscribe(self, q: queue.Queue) -> None: + with self._lock: + if q in self._subscribers: + self._subscribers.remove(q) + + def publish(self, line: str) -> None: + with self._lock: + subscribers = list(self._subscribers) + + for q in subscribers: + try: + q.put_nowait(line) + except queue.Full: + try: + q.get_nowait() + except queue.Empty: + pass + try: + q.put_nowait(line) + except queue.Full: + pass + + +class SerialBridge(threading.Thread): + def __init__( + self, + broadcaster: SerialBroadcaster, + baudrate: int = 115200, + log_dir: str = "/home/pi", + log_prefix: str = "xxx", + ) -> None: + super().__init__(daemon=True) + self.broadcaster = broadcaster + self.baudrate = baudrate + self.log_dir = log_dir + self.log_prefix = log_prefix + self.current_log_link = os.path.join(self.log_dir, f"{self.log_prefix}.log") + self._stop_event = threading.Event() + self._serial: Optional[serial.Serial] = None + self._log_file = None + self._active_log_path: Optional[str] = None + self._next_rollover_epoch: float = 0.0 + + def stop(self) -> None: + self._stop_event.set() + + def _detect_device(self) -> Optional[str]: + patterns = ["/dev/ttyUSB*", "/dev/ttyACM*", "/dev/serial/by-id/*"] + candidates: List[str] = [] + for pattern in patterns: + candidates.extend(glob.glob(pattern)) + + if not candidates: + return None + + candidates = sorted(set(candidates)) + return candidates[0] + + def _open_serial(self, device: str) -> bool: + try: + self._serial = serial.Serial(device, self.baudrate, timeout=1) + self.broadcaster.publish(f"[bridge] connected: {device} @ {self.baudrate}") + return True + except Exception as exc: + self.broadcaster.publish(f"[bridge] open failed ({device}): {exc}") + self._serial = None + return False + + def _close_serial(self) -> None: + if self._serial is not None: + try: + self._serial.close() + except Exception: + pass + self._serial = None + + def _current_time(self) -> datetime: + return datetime.now() + + def _next_midnight_epoch(self, now: datetime) -> float: + next_midnight = (now + timedelta(days=1)).replace(hour=0, minute=0, second=0, microsecond=0) + return next_midnight.timestamp() + + def _build_log_filename(self, now: datetime) -> str: + stamp = now.strftime("%Y-%m-%d_%H-%M-%S") + return f"{self.log_prefix}_{stamp}.log" + + def _update_current_symlink(self, active_path: str) -> None: + try: + if os.path.islink(self.current_log_link): + os.unlink(self.current_log_link) + elif os.path.exists(self.current_log_link): + backup_path = f"{self.current_log_link}.legacy" + if not os.path.exists(backup_path): + os.replace(self.current_log_link, backup_path) + else: + os.unlink(self.current_log_link) + os.symlink(os.path.basename(active_path), self.current_log_link) + except OSError: + pass + + def _open_log(self) -> None: + now = self._current_time() + os.makedirs(self.log_dir, exist_ok=True) + filename = self._build_log_filename(now) + active_path = os.path.join(self.log_dir, filename) + + self._log_file = open(active_path, "a", buffering=1, encoding="utf-8", errors="replace") + self._active_log_path = active_path + self._next_rollover_epoch = self._next_midnight_epoch(now) + self._update_current_symlink(active_path) + + def _close_log(self) -> None: + if self._log_file: + try: + self._log_file.close() + except Exception: + pass + self._log_file = None + self._active_log_path = None + + def _rotate_log_if_needed(self) -> None: + if self._log_file is None: + self._open_log() + return + if time.time() < self._next_rollover_epoch: + return + self._close_log() + self._open_log() + + def _write_line(self, line: str) -> None: + if self._log_file is None: + self._open_log() + self._rotate_log_if_needed() + ts = self._current_time().strftime("%Y-%m-%dT%H:%M:%S") + self._log_file.write(f"{ts} {line}\n") + + def run(self) -> None: + self._open_log() + try: + while not self._stop_event.is_set(): + if self._serial is None: + device = self._detect_device() + if not device: + time.sleep(2) + continue + if not self._open_serial(device): + time.sleep(2) + continue + + try: + raw = self._serial.readline() + if not raw: + continue + text = raw.decode("utf-8", errors="replace").rstrip("\r\n") + self._write_line(text) + self.broadcaster.publish(text) + except Exception as exc: + self.broadcaster.publish(f"[bridge] disconnected: {exc}") + self._close_serial() + time.sleep(2) + finally: + self._close_serial() + self._close_log() diff --git a/src/webapp.py b/src/webapp.py new file mode 100644 index 0000000..c8f1dde --- /dev/null +++ b/src/webapp.py @@ -0,0 +1,85 @@ +import json +import queue +from typing import Any, Dict + +from flask import Flask, Response, jsonify, render_template, request, stream_with_context +from waitress import serve + +from app_state import AppState +from network_manager import NetworkManager +from serial_bridge import SerialBroadcaster + + +class WebPortal: + def __init__( + self, + state: AppState, + network_manager: NetworkManager, + broadcaster: SerialBroadcaster, + template_folder: str = "../templates", + static_folder: str = "../static", + ) -> None: + self.state = state + self.network_manager = network_manager + self.broadcaster = broadcaster + self.app = Flask(__name__, template_folder=template_folder, static_folder=static_folder) + self._register_routes() + + def _register_routes(self) -> None: + @self.app.route("/") + def index() -> str: + return render_template("index.html") + + @self.app.route("/serial") + def serial_page() -> str: + return render_template("serial.html") + + @self.app.route("/api/status", methods=["GET"]) + def status() -> Response: + self.network_manager.refresh_state() + return jsonify(self.state.snapshot()) + + @self.app.route("/api/scan", methods=["POST", "GET"]) + def scan() -> Response: + ssids = self.network_manager.scan_networks() + return jsonify({"ok": True, "ssids": ssids}) + + @self.app.route("/api/connect", methods=["POST"]) + def connect() -> Response: + payload: Dict[str, Any] = request.get_json(silent=True) or {} + ssid = (payload.get("ssid") or "").strip() + password = payload.get("password") or "" + + ok, message = self.network_manager.connect_to_wifi(ssid, password) + if not ok: + self.state.update_status("Connect failed", message) + try: + self.network_manager.start_ap() + except Exception: + pass + return jsonify({"ok": False, "message": message}), 400 + + self.network_manager.refresh_state() + return jsonify({"ok": True, "message": message}) + + @self.app.route("/events/serial") + def serial_events() -> Response: + @stream_with_context + def generate(): + q = self.broadcaster.subscribe() + try: + yield "retry: 2000\n\n" + while True: + try: + line = q.get(timeout=15) + data = json.dumps({"line": line}) + yield f"data: {data}\n\n" + except queue.Empty: + yield ": keepalive\n\n" + finally: + self.broadcaster.unsubscribe(q) + + return Response(generate(), mimetype="text/event-stream") + + def run(self, host: str = "0.0.0.0", port: int = 80) -> None: + serve(self.app, host=host, port=port, threads=6) diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..e727810 --- /dev/null +++ b/static/style.css @@ -0,0 +1,79 @@ +:root { + --bg: #f3f5f7; + --card: #ffffff; + --text: #1d2733; + --accent: #0d7c66; + --error: #b42318; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: "DejaVu Sans", "Noto Sans", sans-serif; + color: var(--text); + background: linear-gradient(180deg, #f3f5f7 0%, #e8eef5 100%); +} + +.container { + max-width: 860px; + margin: 0 auto; + padding: 24px; +} + +.card { + background: var(--card); + border-radius: 10px; + padding: 16px; + margin-bottom: 16px; + border: 1px solid #d0d7de; +} + +label { + display: block; + margin-top: 10px; + margin-bottom: 4px; +} + +input, +select, +button { + width: 100%; + padding: 10px; + margin-bottom: 10px; + border-radius: 8px; + border: 1px solid #c5ced8; + font-size: 15px; +} + +button { + background: var(--accent); + color: #fff; + border: none; + cursor: pointer; +} + +button:hover { + filter: brightness(0.95); +} + +.error { + color: var(--error); + font-size: 14px; + min-height: 20px; +} + +.terminal { + height: 70vh; + overflow-y: auto; + margin: 0; + background: #101418; + color: #c9f5d9; + border-radius: 10px; + padding: 12px; + border: 1px solid #2a3745; + font-family: "DejaVu Sans Mono", "Noto Sans Mono", monospace; + white-space: pre-wrap; +} diff --git a/systemd/serial-bridge.service b/systemd/serial-bridge.service new file mode 100644 index 0000000..62cccbb --- /dev/null +++ b/systemd/serial-bridge.service @@ -0,0 +1,17 @@ +[Unit] +Description=Serial Bridge + WiFi Captive Portal + RTC/NTP +After=network.target wpa_supplicant.service +Wants=network.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/opt/serial-bridge/src +Environment=PYTHONUNBUFFERED=1 +ExecStart=/opt/serial-bridge/.venv/bin/python /opt/serial-bridge/src/main.py +Restart=always +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/serial-dnsmasq.service b/systemd/serial-dnsmasq.service new file mode 100644 index 0000000..e31b391 --- /dev/null +++ b/systemd/serial-dnsmasq.service @@ -0,0 +1,12 @@ +[Unit] +Description=Dedicated dnsmasq for serial fallback AP +After=network.target + +[Service] +Type=simple +ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/etc/serial-bridge/dnsmasq.conf +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/serial-hostapd.service b/systemd/serial-hostapd.service new file mode 100644 index 0000000..ef42839 --- /dev/null +++ b/systemd/serial-hostapd.service @@ -0,0 +1,13 @@ +[Unit] +Description=Dedicated hostapd for serial fallback AP +After=network.target + +[Service] +Type=simple +ExecStartPre=/usr/sbin/rfkill unblock wlan +ExecStart=/usr/sbin/hostapd /etc/serial-bridge/hostapd.conf +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7ec8e96 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,119 @@ + + + + + + Serial Portal + + + +
+

WiFi Setup

+

Fallback-AP: serial / Passwort: serialserial

+ +
+

Netzwerkstatus

+
Lade Status...
+
+
+ +
+

Mit WLAN verbinden

+ + + + + + + + +
+
+ +
+

Serial Monitor

+
Zur Live-Serial-Seite +
+
+ + + + diff --git a/templates/serial.html b/templates/serial.html new file mode 100644 index 0000000..adcfe51 --- /dev/null +++ b/templates/serial.html @@ -0,0 +1,45 @@ + + + + + + Serial Stream + + + +
+

ESP32 Serial Live

+

Zurück zum WLAN-Portal

+

+  
+ + + +