WiFi Setup
+Fallback-AP: serial / Passwort: serialserial
+ +diff --git a/README.md b/README.md index d2a8dc2..081a004 100644 Binary files a/README.md and b/README.md differ diff --git a/config/dnsmasq.conf b/config/dnsmasq.conf new file mode 100644 index 0000000..96be861 --- /dev/null +++ b/config/dnsmasq.conf @@ -0,0 +1,9 @@ +interface=wlan0 +bind-interfaces +dhcp-authoritative +dhcp-range=192.168.4.10,192.168.4.200,255.255.255.0,12h +dhcp-option=3,192.168.4.1 +dhcp-option=6,192.168.4.1 +address=/#/192.168.4.1 +log-queries +log-dhcp diff --git a/config/hostapd.conf b/config/hostapd.conf new file mode 100644 index 0000000..f6bc942 --- /dev/null +++ b/config/hostapd.conf @@ -0,0 +1,16 @@ +interface=wlan0 +driver=nl80211 +ssid=serial +hw_mode=g +channel=6 +ieee80211n=1 +wmm_enabled=0 +macaddr_acl=0 +auth_algs=1 +ignore_broadcast_ssid=0 +wpa=2 +wpa_passphrase=serialserial +wpa_key_mgmt=WPA-PSK +wpa_pairwise=CCMP +rsn_pairwise=CCMP +country_code=US diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..a11696c --- /dev/null +++ b/install.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "Bitte als root ausfuehren: sudo ./install.sh" + exit 1 +fi + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_ROOT="/opt/serial-bridge" +CONFIG_ROOT="/etc/serial-bridge" + +apt-get update +apt-get install -y \ + python3 \ + python3-venv \ + python3-pip \ + hostapd \ + dnsmasq \ + iw \ + wpasupplicant \ + iproute2 \ + rfkill \ + i2c-tools + +install -d -m 755 "${APP_ROOT}" "${APP_ROOT}/src" "${APP_ROOT}/templates" "${APP_ROOT}/static" "${CONFIG_ROOT}" + +cp -a "${PROJECT_ROOT}/src/." "${APP_ROOT}/src/" +cp -a "${PROJECT_ROOT}/templates/." "${APP_ROOT}/templates/" +cp -a "${PROJECT_ROOT}/static/." "${APP_ROOT}/static/" +cp "${PROJECT_ROOT}/requirements.txt" "${APP_ROOT}/requirements.txt" + +python3 -m venv "${APP_ROOT}/.venv" +"${APP_ROOT}/.venv/bin/pip" install --upgrade pip +"${APP_ROOT}/.venv/bin/pip" install -r "${APP_ROOT}/requirements.txt" + +install -m 644 "${PROJECT_ROOT}/config/hostapd.conf" "${CONFIG_ROOT}/hostapd.conf" +install -m 644 "${PROJECT_ROOT}/config/dnsmasq.conf" "${CONFIG_ROOT}/dnsmasq.conf" + +install -m 644 "${PROJECT_ROOT}/systemd/serial-hostapd.service" "/etc/systemd/system/serial-hostapd.service" +install -m 644 "${PROJECT_ROOT}/systemd/serial-dnsmasq.service" "/etc/systemd/system/serial-dnsmasq.service" +install -m 644 "${PROJECT_ROOT}/systemd/serial-bridge.service" "/etc/systemd/system/serial-bridge.service" + +WPA_CONF="/etc/wpa_supplicant/wpa_supplicant.conf" +touch "${WPA_CONF}" +grep -q '^ctrl_interface=' "${WPA_CONF}" || echo 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev' >> "${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 0000000..4f2df53 Binary files /dev/null and b/src/__pycache__/app_state.cpython-312.pyc differ diff --git a/src/__pycache__/main.cpython-312.pyc b/src/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000..336dbaa Binary files /dev/null and b/src/__pycache__/main.cpython-312.pyc differ diff --git a/src/__pycache__/network_manager.cpython-312.pyc b/src/__pycache__/network_manager.cpython-312.pyc new file mode 100644 index 0000000..5e45350 Binary files /dev/null and b/src/__pycache__/network_manager.cpython-312.pyc differ diff --git a/src/__pycache__/rtc_sync.cpython-312.pyc b/src/__pycache__/rtc_sync.cpython-312.pyc new file mode 100644 index 0000000..90a446e Binary files /dev/null and b/src/__pycache__/rtc_sync.cpython-312.pyc differ diff --git a/src/__pycache__/serial_bridge.cpython-312.pyc b/src/__pycache__/serial_bridge.cpython-312.pyc new file mode 100644 index 0000000..a7ec64c Binary files /dev/null and b/src/__pycache__/serial_bridge.cpython-312.pyc differ diff --git a/src/__pycache__/webapp.cpython-312.pyc b/src/__pycache__/webapp.cpython-312.pyc new file mode 100644 index 0000000..5a05f4e Binary files /dev/null and b/src/__pycache__/webapp.cpython-312.pyc differ 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 @@ + + +
+ + +Fallback-AP: serial / Passwort: serialserial
+ +