Initial commit: Location Management Flask app

This commit is contained in:
ske087
2026-02-26 19:24:17 +02:00
commit 7a22575dab
52 changed files with 3481 additions and 0 deletions

1
app/drivers/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Drivers package — auto-discovered plugins."""

76
app/drivers/base.py Normal file
View File

@@ -0,0 +1,76 @@
"""Base class every board driver must implement.
To create a new board driver:
1. Make a subfolder inside ``app/drivers/`` (e.g. ``my_custom_board/``)
2. Add a ``driver.py`` that subclasses ``BoardDriver``
3. Set the class-level metadata attributes
4. The registry will auto-discover and register it at startup — no other
changes needed anywhere in the application.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.models.board import Board
class BoardDriver(ABC):
"""Abstract interface all board drivers must satisfy."""
# ── Metadata (override in subclass) ──────────────────────────────────────
#: Unique identifier stored in Board.board_type column
DRIVER_ID: str = ""
#: Human-readable name shown in the UI
DISPLAY_NAME: str = ""
#: Short description shown on the Add-board form
DESCRIPTION: str = ""
#: Default I/O counts pre-filled when this type is selected
DEFAULT_NUM_RELAYS: int = 4
DEFAULT_NUM_INPUTS: int = 4
#: Firmware download link shown in the UI (optional)
FIRMWARE_URL: str = ""
# ── Core API ──────────────────────────────────────────────────────────────
@abstractmethod
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
"""Return current relay state or None on comm error."""
@abstractmethod
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
"""Set relay ON/OFF. Return True on success."""
@abstractmethod
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
"""Toggle relay. Return new state or None on error."""
@abstractmethod
def poll(self, board: "Board") -> dict:
"""Fetch all I/O states.
Must return::
{
"relay_states": {"relay_1": True, ...},
"input_states": {"input_1": False, ...},
"is_online": True,
"firmware_version": "1.0.0", # optional
}
"""
@abstractmethod
def register_webhook(self, board: "Board", callback_url: str) -> bool:
"""Tell the board where to POST input events. Return True on success."""
# ── Optional hooks ────────────────────────────────────────────────────────
def on_board_added(self, board: "Board", server_base_url: str) -> None:
"""Called once right after a board is created in the DB."""
self.register_webhook(board, f"{server_base_url}/api/webhook/{board.id}")
def on_board_deleted(self, board: "Board") -> None:
"""Called just before a board is deleted from the DB."""
def __repr__(self) -> str:
return f"<Driver {self.DRIVER_ID}>"

View File

@@ -0,0 +1 @@
"""Generic ESP32 driver package."""

View File

@@ -0,0 +1,90 @@
"""Generic ESP32 board driver (custom firmware).
This is a template driver for custom ESP32 firmware.
Copy this folder, rename it, and implement the HTTP endpoints
to match your own firmware's API.
Expected firmware endpoints (same shape as Olimex by default):
POST /relay/on?relay=<n>
POST /relay/off?relay=<n>
POST /relay/toggle?relay=<n>
GET /relay/status?relay=<n> → {"state": <bool>}
GET /input/status?input=<n> → {"state": <bool>}
POST /register?callback_url=<url>
"""
from __future__ import annotations
import logging
import requests
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
_TIMEOUT = 3
def _get(url):
try:
r = requests.get(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("GET %s%s", url, exc)
return None
def _post(url):
try:
r = requests.post(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("POST %s%s", url, exc)
return None
class GenericESP32Driver(BoardDriver):
"""Generic ESP32 driver — uses the same REST conventions as the Olimex board.
Customise the endpoint paths below to match your firmware."""
DRIVER_ID = "generic_esp32"
DISPLAY_NAME = "Generic ESP32"
DESCRIPTION = "Custom ESP32 firmware · same REST API shape as Olimex"
DEFAULT_NUM_RELAYS = 4
DEFAULT_NUM_INPUTS = 4
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
action = "on" if state else "off"
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def poll(self, board: "Board") -> dict:
relay_states, input_states, online = {}, {}, False
for n in range(1, board.num_relays + 1):
data = _get(f"{board.base_url}/relay/status?relay={n}")
if data is not None:
relay_states[f"relay_{n}"] = bool(data.get("state", False))
online = True
for n in range(1, board.num_inputs + 1):
data = _get(f"{board.base_url}/input/status?input={n}")
if data is not None:
input_states[f"input_{n}"] = bool(data.get("state", False))
online = True
return {"relay_states": relay_states, "input_states": input_states, "is_online": online}
def register_webhook(self, board: "Board", callback_url: str) -> bool:
return _post(f"{board.base_url}/register?callback_url={callback_url}") is not None

View File

@@ -0,0 +1 @@
"""Generic ESP8266 driver package."""

View File

@@ -0,0 +1,80 @@
"""Generic ESP8266 board driver (custom firmware).
Lighter board — defaults to 2 relays and 2 inputs.
Adjust DEFAULT_NUM_RELAYS / DEFAULT_NUM_INPUTS to match your hardware.
"""
from __future__ import annotations
import logging
import requests
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
_TIMEOUT = 3
def _get(url):
try:
r = requests.get(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("GET %s%s", url, exc)
return None
def _post(url):
try:
r = requests.post(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("POST %s%s", url, exc)
return None
class GenericESP8266Driver(BoardDriver):
"""Generic ESP8266 driver — lighter board variant."""
DRIVER_ID = "generic_esp8266"
DISPLAY_NAME = "Generic ESP8266"
DESCRIPTION = "Custom ESP8266 firmware · 2 relays · 2 inputs (configurable)"
DEFAULT_NUM_RELAYS = 2
DEFAULT_NUM_INPUTS = 2
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
action = "on" if state else "off"
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def poll(self, board: "Board") -> dict:
relay_states, input_states, online = {}, {}, False
for n in range(1, board.num_relays + 1):
data = _get(f"{board.base_url}/relay/status?relay={n}")
if data is not None:
relay_states[f"relay_{n}"] = bool(data.get("state", False))
online = True
for n in range(1, board.num_inputs + 1):
data = _get(f"{board.base_url}/input/status?input={n}")
if data is not None:
input_states[f"input_{n}"] = bool(data.get("state", False))
online = True
return {"relay_states": relay_states, "input_states": input_states, "is_online": online}
def register_webhook(self, board: "Board", callback_url: str) -> bool:
return _post(f"{board.base_url}/register?callback_url={callback_url}") is not None

View File

@@ -0,0 +1 @@
"""Olimex ESP32-C6-EVB driver package."""

View File

@@ -0,0 +1,117 @@
"""Olimex ESP32-C6-EVB board driver.
Hardware
--------
- 4 relays (outputs)
- 4 digital inputs
- HTTP REST API served directly by the board on port 80
API endpoints
-------------
POST /relay/on?relay=<n> → {"state": true}
POST /relay/off?relay=<n> → {"state": false}
POST /relay/toggle?relay=<n> → {"state": <new>}
GET /relay/status?relay=<n> → {"state": <bool>}
GET /input/status?input=<n> → {"state": <bool>}
POST /register?callback_url=<url> → {"status": "ok"}
Webhook (board → server)
------------------------
The board POSTs to the registered callback_url whenever an input changes:
POST <callback_url>
{"input": <n>, "state": <bool>}
"""
from __future__ import annotations
import logging
import requests
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
from app.models.board import Board
logger = logging.getLogger(__name__)
_TIMEOUT = 3
def _get(url: str) -> dict | None:
try:
r = requests.get(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("GET %s%s", url, exc)
return None
def _post(url: str) -> dict | None:
try:
r = requests.post(url, timeout=_TIMEOUT)
r.raise_for_status()
return r.json()
except Exception as exc:
logger.debug("POST %s%s", url, exc)
return None
class OlimexESP32C6EVBDriver(BoardDriver):
"""Driver for the Olimex ESP32-C6-EVB board."""
DRIVER_ID = "olimex_esp32_c6_evb"
DISPLAY_NAME = "Olimex ESP32-C6-EVB"
DESCRIPTION = "4 relays · 4 digital inputs · HTTP REST + webhook callbacks"
DEFAULT_NUM_RELAYS = 4
DEFAULT_NUM_INPUTS = 4
FIRMWARE_URL = "https://github.com/OLIMEX/ESP32-C6-EVB"
# ── relay control ─────────────────────────────────────────────────────────
def get_relay_status(self, board: "Board", relay_num: int) -> bool | None:
data = _get(f"{board.base_url}/relay/status?relay={relay_num}")
return bool(data["state"]) if data is not None else None
def set_relay(self, board: "Board", relay_num: int, state: bool) -> bool:
action = "on" if state else "off"
return _post(f"{board.base_url}/relay/{action}?relay={relay_num}") is not None
def toggle_relay(self, board: "Board", relay_num: int) -> bool | None:
data = _post(f"{board.base_url}/relay/toggle?relay={relay_num}")
return bool(data["state"]) if data is not None else None
# ── poll ──────────────────────────────────────────────────────────────────
def poll(self, board: "Board") -> dict:
relay_states: dict = {}
input_states: dict = {}
online = False
for n in range(1, board.num_relays + 1):
data = _get(f"{board.base_url}/relay/status?relay={n}")
if data is not None:
relay_states[f"relay_{n}"] = bool(data.get("state", False))
online = True
for n in range(1, board.num_inputs + 1):
data = _get(f"{board.base_url}/input/status?input={n}")
if data is not None:
input_states[f"input_{n}"] = bool(data.get("state", True))
online = True
return {
"relay_states": relay_states,
"input_states": input_states,
"is_online": online,
}
# ── webhook registration ──────────────────────────────────────────────────
def register_webhook(self, board: "Board", callback_url: str) -> bool:
url = f"{board.base_url}/register?callback_url={callback_url}"
ok = _post(url) is not None
if ok:
logger.info("Webhook registered on board '%s'%s", board.name, callback_url)
else:
logger.warning("Webhook registration failed for board '%s'", board.name)
return ok

View File

@@ -0,0 +1,18 @@
{
"driver_id": "olimex_esp32_c6_evb",
"display_name": "Olimex ESP32-C6-EVB",
"description": "Olimex ESP32-C6-EVB board with 4 relays and 4 digital inputs. Communicates via HTTP REST API with webhook callbacks.",
"manufacturer": "Olimex",
"chip": "ESP32-C6 WROOM-1",
"default_num_relays": 4,
"default_num_inputs": 4,
"firmware_url": "https://github.com/OLIMEX/ESP32-C6-EVB",
"api": {
"relay_on": "POST /relay/on?relay={n}",
"relay_off": "POST /relay/off?relay={n}",
"relay_toggle": "POST /relay/toggle?relay={n}",
"relay_status": "GET /relay/status?relay={n}",
"input_status": "GET /input/status?input={n}",
"register": "POST /register?callback_url={url}"
}
}

104
app/drivers/registry.py Normal file
View File

@@ -0,0 +1,104 @@
"""Driver registry — auto-discovers board drivers from sub-folders.
At application startup call ``registry.discover()``. Every sub-folder of
``app/drivers/`` that contains a ``driver.py`` with a ``BoardDriver`` subclass
is registered automatically. No manual imports needed.
Usage
-----
::
from app.drivers.registry import registry
driver = registry.get("olimex_esp32_c6_evb")
driver.set_relay(board, 1, True)
# All registered drivers (for UI drop-downs):
all_drivers = registry.all()
"""
from __future__ import annotations
import importlib
import inspect
import logging
import os
from typing import TYPE_CHECKING
from app.drivers.base import BoardDriver
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
_DRIVERS_DIR = os.path.dirname(__file__)
class DriverRegistry:
def __init__(self):
self._drivers: dict[str, BoardDriver] = {}
# ── discovery ─────────────────────────────────────────────────────────────
def discover(self) -> None:
"""Scan ``app/drivers/`` sub-folders and register found drivers."""
for entry in sorted(os.scandir(_DRIVERS_DIR), key=lambda e: e.name):
if not entry.is_dir() or entry.name.startswith(("_", ".")):
continue
driver_module_path = os.path.join(entry.path, "driver.py")
if not os.path.isfile(driver_module_path):
logger.debug("Skipping %s — no driver.py found", entry.name)
continue
module_name = f"app.drivers.{entry.name}.driver"
try:
mod = importlib.import_module(module_name)
self._register_from_module(mod, entry.name)
except Exception as exc:
logger.error("Failed to load driver '%s': %s", entry.name, exc, exc_info=True)
def _register_from_module(self, mod, folder_name: str) -> None:
for _name, obj in inspect.getmembers(mod, inspect.isclass):
if (
issubclass(obj, BoardDriver)
and obj is not BoardDriver
and obj.DRIVER_ID
):
instance = obj()
self._drivers[obj.DRIVER_ID] = instance
logger.info(
"Board driver registered: '%s' (%s) from folder '%s'",
obj.DRIVER_ID, obj.DISPLAY_NAME, folder_name,
)
return
logger.warning(
"Folder '%s' has driver.py but no BoardDriver subclass with DRIVER_ID found",
folder_name,
)
# ── lookup ────────────────────────────────────────────────────────────────
def get(self, driver_id: str) -> BoardDriver | None:
return self._drivers.get(driver_id)
def get_or_default(self, driver_id: str) -> BoardDriver:
"""Return driver or fall back to the generic_esp32 stub."""
return self._drivers.get(driver_id) or self._drivers.get("generic_esp32") or next(iter(self._drivers.values()))
def all(self) -> list[BoardDriver]:
return list(self._drivers.values())
def choices(self) -> list[tuple[str, str]]:
"""Return list of (DRIVER_ID, DISPLAY_NAME) for form select fields."""
return [(d.DRIVER_ID, d.DISPLAY_NAME) for d in self._drivers.values()]
def is_registered(self, driver_id: str) -> bool:
return driver_id in self._drivers
def __len__(self) -> int:
return len(self._drivers)
def __repr__(self) -> str:
return f"<DriverRegistry drivers={list(self._drivers.keys())}>"
# Singleton — import this everywhere
registry = DriverRegistry()