Phase 3 Complete by Claude Code
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -4,6 +4,7 @@ from config import settings
|
||||
from shared.firebase import init_firebase, firebase_initialized
|
||||
from auth.router import router as auth_router
|
||||
from melodies.router import router as melodies_router
|
||||
from devices.router import router as devices_router
|
||||
from settings.router import router as settings_router
|
||||
|
||||
app = FastAPI(
|
||||
@@ -23,6 +24,7 @@ app.add_middleware(
|
||||
|
||||
app.include_router(auth_router)
|
||||
app.include_router(melodies_router)
|
||||
app.include_router(devices_router)
|
||||
app.include_router(settings_router)
|
||||
|
||||
|
||||
|
||||
@@ -1 +1,52 @@
|
||||
# TODO: Mosquitto password file management
|
||||
import subprocess
|
||||
import os
|
||||
from config import settings
|
||||
|
||||
|
||||
def register_device_password(serial_number: str, password: str) -> bool:
|
||||
"""Register a device in the Mosquitto password file.
|
||||
|
||||
Uses mosquitto_passwd to add/update the device credentials.
|
||||
The serial number is used as the MQTT username.
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
passwd_file = settings.mosquitto_password_file
|
||||
|
||||
# Ensure the password file exists
|
||||
if not os.path.exists(passwd_file):
|
||||
# Create the file if it doesn't exist
|
||||
os.makedirs(os.path.dirname(passwd_file), exist_ok=True)
|
||||
open(passwd_file, "a").close()
|
||||
|
||||
try:
|
||||
# Use mosquitto_passwd with -b flag (batch mode) to set password
|
||||
result = subprocess.run(
|
||||
["mosquitto_passwd", "-b", passwd_file, serial_number, password],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
print(f"[WARNING] Mosquitto password registration failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def remove_device_password(serial_number: str) -> bool:
|
||||
"""Remove a device from the Mosquitto password file."""
|
||||
passwd_file = settings.mosquitto_password_file
|
||||
|
||||
if not os.path.exists(passwd_file):
|
||||
return True
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["mosquitto_passwd", "-D", passwd_file, serial_number],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError) as e:
|
||||
print(f"[WARNING] Mosquitto password removal failed: {e}")
|
||||
return False
|
||||
|
||||
Reference in New Issue
Block a user