Phase 3 Complete by Claude Code

This commit is contained in:
2026-02-17 14:05:39 +02:00
parent 115c3773ef
commit 337712ffac
11 changed files with 1818 additions and 13 deletions

View File

@@ -1 +1,153 @@
# TODO: Device Pydantic schemas
from pydantic import BaseModel, Field
from typing import List, Optional
from enum import Enum
# --- Enums ---
class HelperMelodyTypes(str, Enum):
orthodox = "orthodox"
catholic = "catholic"
all = "all"
class HelperRingAlerts(str, Enum):
disabled = "disabled"
single = "single"
multi = "multi"
class DeviceTiers(str, Enum):
basic = "basic"
small = "small"
mini = "mini"
premium = "premium"
vip = "vip"
custom = "custom"
# --- Nested Structs ---
class DeviceNetworkSettings(BaseModel):
hostname: str = ""
useStaticIP: bool = False
ipAddress: List[str] = []
gateway: List[str] = []
subnet: List[str] = []
dns1: List[str] = []
dns2: List[str] = []
class DeviceClockSettings(BaseModel):
clockOutputs: List[int] = []
clockTimings: List[int] = []
ringAlertsMasterOn: bool = False
ringAlerts: HelperRingAlerts = HelperRingAlerts.disabled
ringIntervals: int = 0
hourAlertsBell: int = 0
halfhourAlertsBell: int = 0
quarterAlertsBell: int = 0
isDaySilenceOn: bool = False
isNightSilenceOn: bool = False
daySilenceFrom: str = ""
daySilenceTo: str = ""
nightSilenceFrom: str = ""
nightSilenceTo: str = ""
backlightTurnOnTime: str = ""
backlightTurnOffTime: str = ""
isBacklightAutomationOn: bool = False
backlightOutput: int = 0
class DeviceAttributes(BaseModel):
hasAssistant: bool = False
hasClock: bool = False
hasBells: bool = False
totalBells: int = 0
bellOutputs: List[int] = []
hammerTimings: List[int] = []
bellGuardOn: bool = False
bellGuardSafetyOn: bool = False
warningsOn: bool = False
towerClockTime: str = ""
clockSettings: DeviceClockSettings = DeviceClockSettings()
deviceLocale: HelperMelodyTypes = HelperMelodyTypes.all
networkSettings: DeviceNetworkSettings = DeviceNetworkSettings()
serialLogLevel: int = 0
sdLogLevel: int = 0
class DeviceSubInformation(BaseModel):
subscrTier: DeviceTiers = DeviceTiers.basic
subscrStart: str = ""
subscrDuration: int = 0
maxUsers: int = 0
maxOutputs: int = 0
class DeviceStatistics(BaseModel):
totalPlaybacks: int = 0
totalHammerStrikes: int = 0
perBellStrikes: List[int] = []
totalWarningsGiven: int = 0
warrantyActive: bool = False
warrantyStart: str = ""
warrantyPeriod: int = 0
maintainedOn: str = ""
maintainancePeriod: int = 0
class MelodyMainItem(BaseModel):
"""Mirrors the Melody schema used in the melodies collection."""
information: dict = {}
default_settings: dict = {}
type: str = ""
url: str = ""
uid: str = ""
pid: str = ""
# --- Request / Response schemas ---
class DeviceCreate(BaseModel):
device_name: str = ""
device_photo: str = ""
device_location: str = ""
is_Online: bool = False
device_attributes: DeviceAttributes = DeviceAttributes()
device_subscription: DeviceSubInformation = DeviceSubInformation()
device_stats: DeviceStatistics = DeviceStatistics()
events_on: bool = False
device_location_coordinates: str = ""
device_melodies_all: List[MelodyMainItem] = []
device_melodies_favorites: List[str] = []
user_list: List[str] = []
websocket_url: str = ""
churchAssistantURL: str = ""
class DeviceUpdate(BaseModel):
device_name: Optional[str] = None
device_photo: Optional[str] = None
device_location: Optional[str] = None
is_Online: Optional[bool] = None
device_attributes: Optional[DeviceAttributes] = None
device_subscription: Optional[DeviceSubInformation] = None
device_stats: Optional[DeviceStatistics] = None
events_on: Optional[bool] = None
device_location_coordinates: Optional[str] = None
device_melodies_all: Optional[List[MelodyMainItem]] = None
device_melodies_favorites: Optional[List[str]] = None
user_list: Optional[List[str]] = None
websocket_url: Optional[str] = None
churchAssistantURL: Optional[str] = None
class DeviceInDB(DeviceCreate):
id: str
device_id: str = ""
class DeviceListResponse(BaseModel):
devices: List[DeviceInDB]
total: int

View File

@@ -1 +1,58 @@
# TODO: CRUD endpoints for devices
from fastapi import APIRouter, Depends, Query
from typing import Optional
from auth.models import TokenPayload
from auth.dependencies import require_device_access, require_viewer
from devices.models import (
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
)
from devices import service
router = APIRouter(prefix="/api/devices", tags=["devices"])
@router.get("", response_model=DeviceListResponse)
async def list_devices(
search: Optional[str] = Query(None),
online: Optional[bool] = Query(None),
tier: Optional[str] = Query(None),
_user: TokenPayload = Depends(require_viewer),
):
devices = service.list_devices(
search=search,
online_only=online,
subscription_tier=tier,
)
return DeviceListResponse(devices=devices, total=len(devices))
@router.get("/{device_id}", response_model=DeviceInDB)
async def get_device(
device_id: str,
_user: TokenPayload = Depends(require_viewer),
):
return service.get_device(device_id)
@router.post("", response_model=DeviceInDB, status_code=201)
async def create_device(
body: DeviceCreate,
_user: TokenPayload = Depends(require_device_access),
):
return service.create_device(body)
@router.put("/{device_id}", response_model=DeviceInDB)
async def update_device(
device_id: str,
body: DeviceUpdate,
_user: TokenPayload = Depends(require_device_access),
):
return service.update_device(device_id, body)
@router.delete("/{device_id}", status_code=204)
async def delete_device(
device_id: str,
_user: TokenPayload = Depends(require_device_access),
):
service.delete_device(device_id)

View File

@@ -1 +1,143 @@
# TODO: Device Firestore operations
import secrets
import string
from shared.firebase import get_db
from shared.exceptions import NotFoundError
from devices.models import DeviceCreate, DeviceUpdate, DeviceInDB
from mqtt.mosquitto import register_device_password
COLLECTION = "devices"
# Serial number format: BS-XXXX-XXXX (uppercase alphanumeric)
SN_CHARS = string.ascii_uppercase + string.digits
SN_SEGMENT_LEN = 4
def _generate_serial_number() -> str:
"""Generate a unique serial number in the format BS-XXXX-XXXX."""
seg1 = "".join(secrets.choice(SN_CHARS) for _ in range(SN_SEGMENT_LEN))
seg2 = "".join(secrets.choice(SN_CHARS) for _ in range(SN_SEGMENT_LEN))
return f"BS-{seg1}-{seg2}"
def _ensure_unique_serial(db) -> str:
"""Generate a serial number and verify it doesn't already exist in Firestore."""
existing_sns = set()
for doc in db.collection(COLLECTION).select(["device_id"]).stream():
data = doc.to_dict()
if data.get("device_id"):
existing_sns.add(data["device_id"])
for _ in range(100): # safety limit
sn = _generate_serial_number()
if sn not in existing_sns:
return sn
raise RuntimeError("Could not generate a unique serial number after 100 attempts")
def _doc_to_device(doc) -> DeviceInDB:
"""Convert a Firestore document snapshot to a DeviceInDB model."""
data = doc.to_dict()
return DeviceInDB(id=doc.id, **data)
def list_devices(
search: str | None = None,
online_only: bool | None = None,
subscription_tier: str | None = None,
) -> list[DeviceInDB]:
"""List devices with optional filters."""
db = get_db()
ref = db.collection(COLLECTION)
query = ref
if subscription_tier:
query = query.where("device_subscription.subscrTier", "==", subscription_tier)
docs = query.stream()
results = []
for doc in docs:
device = _doc_to_device(doc)
# Client-side filters
if online_only is not None and device.is_Online != online_only:
continue
if search:
search_lower = search.lower()
name_match = search_lower in (device.device_name or "").lower()
location_match = search_lower in (device.device_location or "").lower()
sn_match = search_lower in (device.device_id or "").lower()
if not (name_match or location_match or sn_match):
continue
results.append(device)
return results
def get_device(device_doc_id: str) -> DeviceInDB:
"""Get a single device by Firestore document ID."""
db = get_db()
doc = db.collection(COLLECTION).document(device_doc_id).get()
if not doc.exists:
raise NotFoundError("Device")
return _doc_to_device(doc)
def create_device(data: DeviceCreate) -> DeviceInDB:
"""Create a new device document in Firestore with auto-generated serial number."""
db = get_db()
# Generate unique serial number
serial_number = _ensure_unique_serial(db)
# Generate MQTT password and register with Mosquitto
mqtt_password = secrets.token_urlsafe(24)
register_device_password(serial_number, mqtt_password)
doc_data = data.model_dump()
doc_data["device_id"] = serial_number
_, doc_ref = db.collection(COLLECTION).add(doc_data)
return DeviceInDB(id=doc_ref.id, **doc_data)
def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
"""Update an existing device document. Only provided fields are updated."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(device_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Device")
update_data = data.model_dump(exclude_none=True)
# For nested structs, merge with existing data rather than replacing
existing = doc.to_dict()
nested_keys = (
"device_attributes", "device_subscription", "device_stats",
)
for key in nested_keys:
if key in update_data and key in existing:
merged = {**existing[key], **update_data[key]}
update_data[key] = merged
doc_ref.update(update_data)
updated_doc = doc_ref.get()
return _doc_to_device(updated_doc)
def delete_device(device_doc_id: str) -> None:
"""Delete a device document from Firestore."""
db = get_db()
doc_ref = db.collection(COLLECTION).document(device_doc_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Device")
doc_ref.delete()