diff --git a/backend/devices/models.py b/backend/devices/models.py
index f7edf46..4e5634f 100644
--- a/backend/devices/models.py
+++ b/backend/devices/models.py
@@ -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
diff --git a/backend/devices/router.py b/backend/devices/router.py
index 31bc220..2886fbb 100644
--- a/backend/devices/router.py
+++ b/backend/devices/router.py
@@ -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)
diff --git a/backend/devices/service.py b/backend/devices/service.py
index 461b7c0..f8de946 100644
--- a/backend/devices/service.py
+++ b/backend/devices/service.py
@@ -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()
diff --git a/backend/main.py b/backend/main.py
index 625da28..1cb2650 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -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)
diff --git a/backend/mqtt/mosquitto.py b/backend/mqtt/mosquitto.py
index d7d018e..3a9e9f7 100644
--- a/backend/mqtt/mosquitto.py
+++ b/backend/mqtt/mosquitto.py
@@ -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
diff --git a/bellsystemsblack-300x138_lightTheme.png b/bellsystemsblack-300x138_lightTheme.png
new file mode 100644
index 0000000..378e9f6
Binary files /dev/null and b/bellsystemsblack-300x138_lightTheme.png differ
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 03c4edb..b5533d5 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -6,6 +6,9 @@ import MelodyList from "./melodies/MelodyList";
import MelodyDetail from "./melodies/MelodyDetail";
import MelodyForm from "./melodies/MelodyForm";
import MelodySettings from "./melodies/MelodySettings";
+import DeviceList from "./devices/DeviceList";
+import DeviceDetail from "./devices/DeviceDetail";
+import DeviceForm from "./devices/DeviceForm";
function ProtectedRoute({ children }) {
const { user, loading } = useAuth();
@@ -55,8 +58,11 @@ export default function App() {
| Status | +Name | +Serial Number | +Location | +Tier | +Bells | +Users | + {canEdit && ( ++ )} + |
|---|---|---|---|---|---|---|---|
| + + | ++ {device.device_name || "Unnamed Device"} + | ++ {device.device_id || "-"} + | ++ {device.device_location || "-"} + | ++ + {device.device_subscription?.subscrTier || "basic"} + + | ++ {device.device_attributes?.totalBells ?? 0} + | ++ {device.user_list?.length ?? 0} + | + {canEdit && ( +
+ e.stopPropagation()}
+ >
+
+
+
+ |
+ )}
+