Add Pi Zero headless serial bridge with AP portal and daily RTC-based logs
This commit is contained in:
9
config/dnsmasq.conf
Normal file
9
config/dnsmasq.conf
Normal file
@@ -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
|
||||||
16
config/hostapd.conf
Normal file
16
config/hostapd.conf
Normal file
@@ -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
|
||||||
59
install.sh
Normal file
59
install.sh
Normal file
@@ -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"
|
||||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Flask==3.1.0
|
||||||
|
pyserial==3.5
|
||||||
|
ntplib==0.4.0
|
||||||
|
waitress==3.0.2
|
||||||
BIN
src/__pycache__/app_state.cpython-312.pyc
Normal file
BIN
src/__pycache__/app_state.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/main.cpython-312.pyc
Normal file
BIN
src/__pycache__/main.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/network_manager.cpython-312.pyc
Normal file
BIN
src/__pycache__/network_manager.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/rtc_sync.cpython-312.pyc
Normal file
BIN
src/__pycache__/rtc_sync.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/serial_bridge.cpython-312.pyc
Normal file
BIN
src/__pycache__/serial_bridge.cpython-312.pyc
Normal file
Binary file not shown.
BIN
src/__pycache__/webapp.cpython-312.pyc
Normal file
BIN
src/__pycache__/webapp.cpython-312.pyc
Normal file
Binary file not shown.
37
src/app_state.py
Normal file
37
src/app_state.py
Normal file
@@ -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),
|
||||||
|
}
|
||||||
127
src/main.py
Normal file
127
src/main.py
Normal file
@@ -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()
|
||||||
297
src/network_manager.py
Normal file
297
src/network_manager.py
Normal file
@@ -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
|
||||||
68
src/rtc_sync.py
Normal file
68
src/rtc_sync.py
Normal file
@@ -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"
|
||||||
186
src/serial_bridge.py
Normal file
186
src/serial_bridge.py
Normal file
@@ -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()
|
||||||
85
src/webapp.py
Normal file
85
src/webapp.py
Normal file
@@ -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)
|
||||||
79
static/style.css
Normal file
79
static/style.css
Normal file
@@ -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;
|
||||||
|
}
|
||||||
17
systemd/serial-bridge.service
Normal file
17
systemd/serial-bridge.service
Normal file
@@ -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
|
||||||
12
systemd/serial-dnsmasq.service
Normal file
12
systemd/serial-dnsmasq.service
Normal file
@@ -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
|
||||||
13
systemd/serial-hostapd.service
Normal file
13
systemd/serial-hostapd.service
Normal file
@@ -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
|
||||||
119
templates/index.html
Normal file
119
templates/index.html
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Serial Portal</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<h1>WiFi Setup</h1>
|
||||||
|
<p>Fallback-AP: <strong>serial</strong> / Passwort: <strong>serialserial</strong></p>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Netzwerkstatus</h2>
|
||||||
|
<div id="status">Lade Status...</div>
|
||||||
|
<div id="error" class="error"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Mit WLAN verbinden</h2>
|
||||||
|
<button id="scanBtn">Scan</button>
|
||||||
|
<label for="ssidSelect">SSID</label>
|
||||||
|
<select id="ssidSelect">
|
||||||
|
<option value="">Bitte zuerst scannen</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label for="pw">Passwort</label>
|
||||||
|
<input id="pw" type="password" autocomplete="off" placeholder="WLAN Passwort">
|
||||||
|
|
||||||
|
<button id="connectBtn">Verbinden</button>
|
||||||
|
<div id="connectMsg"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>Serial Monitor</h2>
|
||||||
|
<a href="/serial">Zur Live-Serial-Seite</a>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function refreshStatus() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/status');
|
||||||
|
const data = await resp.json();
|
||||||
|
document.getElementById('status').textContent =
|
||||||
|
`AP: ${data.ap_mode ? 'an' : 'aus'} | WLAN: ${data.wifi_connected ? 'verbunden' : 'getrennt'} | Internet: ${data.internet_available ? 'ok' : 'nein'} | Status: ${data.status_message}`;
|
||||||
|
document.getElementById('error').textContent = data.last_error || '';
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('status').textContent = 'Status nicht erreichbar';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scan() {
|
||||||
|
const msg = document.getElementById('connectMsg');
|
||||||
|
msg.textContent = 'Scanne WLANs...';
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/scan', {method: 'POST'});
|
||||||
|
const data = await resp.json();
|
||||||
|
const select = document.getElementById('ssidSelect');
|
||||||
|
select.innerHTML = '';
|
||||||
|
if (data.ssids && data.ssids.length) {
|
||||||
|
data.ssids.forEach(ssid => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = ssid;
|
||||||
|
opt.textContent = ssid;
|
||||||
|
select.appendChild(opt);
|
||||||
|
});
|
||||||
|
msg.textContent = `${data.ssids.length} Netzwerke gefunden.`;
|
||||||
|
} else {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = '';
|
||||||
|
opt.textContent = 'Keine Netzwerke gefunden';
|
||||||
|
select.appendChild(opt);
|
||||||
|
msg.textContent = 'Keine Netzwerke gefunden.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = 'Scan fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
refreshStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectWifi() {
|
||||||
|
const ssid = document.getElementById('ssidSelect').value;
|
||||||
|
const password = document.getElementById('pw').value;
|
||||||
|
const msg = document.getElementById('connectMsg');
|
||||||
|
|
||||||
|
if (!ssid) {
|
||||||
|
msg.textContent = 'Bitte SSID wählen.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.textContent = `Verbinde mit ${ssid}...`;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/connect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ssid, password})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (resp.ok && data.ok) {
|
||||||
|
msg.textContent = 'Verbindung erfolgreich. AP wird beendet.';
|
||||||
|
} else {
|
||||||
|
msg.textContent = `Fehler: ${data.message || 'Connect fehlgeschlagen'}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
msg.textContent = 'Verbindungsversuch fehlgeschlagen.';
|
||||||
|
}
|
||||||
|
setTimeout(refreshStatus, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('scanBtn').addEventListener('click', scan);
|
||||||
|
document.getElementById('connectBtn').addEventListener('click', connectWifi);
|
||||||
|
|
||||||
|
refreshStatus();
|
||||||
|
setInterval(refreshStatus, 5000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
45
templates/serial.html
Normal file
45
templates/serial.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Serial Stream</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="container">
|
||||||
|
<h1>ESP32 Serial Live</h1>
|
||||||
|
<p><a href="/">Zurück zum WLAN-Portal</a></p>
|
||||||
|
<pre id="terminal" class="terminal"></pre>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const term = document.getElementById('terminal');
|
||||||
|
const maxLines = 500;
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
function appendLine(text) {
|
||||||
|
lines.push(text);
|
||||||
|
if (lines.length > maxLines) {
|
||||||
|
lines.shift();
|
||||||
|
}
|
||||||
|
term.textContent = lines.join('\n');
|
||||||
|
term.scrollTop = term.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = new EventSource('/events/serial');
|
||||||
|
events.onmessage = (evt) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(evt.data);
|
||||||
|
appendLine(payload.line || '');
|
||||||
|
} catch (e) {
|
||||||
|
appendLine(evt.data || '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
events.onerror = () => {
|
||||||
|
appendLine('[sse] reconnecting...');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user