Skip to content

Commit 81f723a

Browse files
committed
Added Multiple Rooms
1 parent 92d81e0 commit 81f723a

8 files changed

Lines changed: 658 additions & 34 deletions

File tree

app/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from fastapi.templating import Jinja2Templates
99

1010
from app.config import PROJECT_ROOT, settings
11-
from app.routers import ha_proxy, layout, moon, weather
11+
from app.routers import ha_proxy, layout, moon, rooms, weather
1212

1313
logging.basicConfig(
1414
level=logging.DEBUG if settings.debug else logging.INFO,
@@ -29,6 +29,7 @@
2929
# ---------- Routers ----------
3030
app.include_router(ha_proxy.router)
3131
app.include_router(layout.router)
32+
app.include_router(rooms.router)
3233
app.include_router(weather.router)
3334
app.include_router(moon.router)
3435

app/models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ def _inject_tile_type(cls, data):
145145
return data
146146

147147

148+
class Room(BaseModel):
149+
"""A named layout context (room)."""
150+
151+
id: str = Field(description="Unique room identifier (URL-safe slug)")
152+
name: str = Field(description="Display name for the room")
153+
154+
148155
class ServiceCall(BaseModel):
149156
"""Payload for calling a Home Assistant service."""
150157

app/routers/layout.py

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,40 @@
11
"""Layout persistence routes.
22
3-
Tile layout is stored as a JSON file on disk (no database needed).
4-
The file path is configured via settings.layout_file.
3+
Each room has its own layout file:
4+
- "default" room → settings.layout_file (e.g. data/layout.json)
5+
- other rooms → data/layout_{room_id}.json
56
"""
67

78
import contextlib
89
import json
910
import logging
1011
import os
12+
import re
1113
import tempfile
14+
from pathlib import Path
1215

13-
from fastapi import APIRouter, HTTPException, status
16+
from fastapi import APIRouter, HTTPException, Query, status
1417

1518
from app.config import settings
1619
from app.models import Layout
1720

1821
logger = logging.getLogger(__name__)
1922
router = APIRouter(prefix="/api/layout", tags=["layout"])
2023

21-
DEFAULT_LAYOUT = Layout(
22-
columns=12,
23-
tiles=[],
24-
)
24+
ROOM_ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
2525

26+
DEFAULT_LAYOUT = Layout(columns=12, tiles=[])
2627

27-
def _read_layout() -> Layout:
28-
"""Read the layout from disk, returning the default if the file doesn't exist."""
29-
path = settings.layout_file
28+
29+
def _layout_path(room_id: str) -> Path:
30+
base = settings.layout_file
31+
if room_id == "default":
32+
return base
33+
return base.parent / f"layout_{room_id}.json"
34+
35+
36+
def _read_layout(room_id: str) -> Layout:
37+
path = _layout_path(room_id)
3038
if not path.exists():
3139
return DEFAULT_LAYOUT
3240
try:
@@ -37,9 +45,8 @@ def _read_layout() -> Layout:
3745
return DEFAULT_LAYOUT
3846

3947

40-
def _write_layout(layout: Layout) -> None:
41-
"""Persist the layout to disk atomically via write-to-temp + rename."""
42-
path = settings.layout_file
48+
def _write_layout(layout: Layout, room_id: str) -> None:
49+
path = _layout_path(room_id)
4350
path.parent.mkdir(parents=True, exist_ok=True)
4451
fd, tmp_path = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
4552
try:
@@ -53,20 +60,32 @@ def _write_layout(layout: Layout) -> None:
5360

5461

5562
@router.get("")
56-
async def get_layout() -> Layout:
57-
"""Return the current dashboard layout."""
58-
return _read_layout()
63+
async def get_layout(room_id: str = Query(default="default")) -> Layout:
64+
"""Return the layout for the given room."""
65+
if not ROOM_ID_RE.match(room_id):
66+
raise HTTPException(
67+
status_code=status.HTTP_400_BAD_REQUEST,
68+
detail="Invalid room_id",
69+
)
70+
return _read_layout(room_id)
5971

6072

6173
@router.put("")
62-
async def save_layout(layout: Layout) -> Layout:
63-
"""Replace the entire dashboard layout."""
64-
# Validate tile IDs are unique
74+
async def save_layout(
75+
layout: Layout,
76+
room_id: str = Query(default="default"),
77+
) -> Layout:
78+
"""Replace the layout for the given room."""
79+
if not ROOM_ID_RE.match(room_id):
80+
raise HTTPException(
81+
status_code=status.HTTP_400_BAD_REQUEST,
82+
detail="Invalid room_id",
83+
)
6584
ids = [t.id for t in layout.tiles]
6685
if len(ids) != len(set(ids)):
6786
raise HTTPException(
6887
status_code=status.HTTP_400_BAD_REQUEST,
6988
detail="Duplicate tile IDs in layout",
7089
)
71-
_write_layout(layout)
90+
_write_layout(layout, room_id)
7291
return layout

app/routers/rooms.py

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
"""Rooms routes — named layout contexts.
2+
3+
Each room has its own saved layout file. Rooms are stored as a JSON
4+
list at ``data/rooms.json`` (same directory as the layout file).
5+
"""
6+
7+
import contextlib
8+
import json
9+
import logging
10+
import os
11+
import re
12+
import secrets
13+
import tempfile
14+
from typing import List
15+
16+
from fastapi import APIRouter, HTTPException, status
17+
from pydantic import BaseModel
18+
19+
from app.config import settings
20+
from app.models import Room
21+
22+
logger = logging.getLogger(__name__)
23+
router = APIRouter(prefix="/api/rooms", tags=["rooms"])
24+
25+
ROOM_ID_RE = re.compile(r"^[a-z0-9_]{1,40}$")
26+
27+
28+
class RoomCreate(BaseModel):
29+
name: str
30+
31+
32+
def _rooms_path():
33+
return settings.layout_file.parent / "rooms.json"
34+
35+
36+
def _read_rooms() -> List[Room]:
37+
path = _rooms_path()
38+
if not path.exists():
39+
return [Room(id="default", name="Default")]
40+
try:
41+
raw = path.read_text(encoding="utf-8")
42+
data = json.loads(raw)
43+
return [Room(**r) for r in data]
44+
except (json.JSONDecodeError, ValueError, TypeError):
45+
logger.warning("Corrupt rooms file at %s — returning default", path)
46+
return [Room(id="default", name="Default")]
47+
48+
49+
def _write_rooms(rooms: List[Room]) -> None:
50+
path = _rooms_path()
51+
path.parent.mkdir(parents=True, exist_ok=True)
52+
data = [r.model_dump() for r in rooms]
53+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
54+
try:
55+
with os.fdopen(fd, "w", encoding="utf-8") as f:
56+
json.dump(data, f, indent=2)
57+
os.replace(tmp, str(path))
58+
except BaseException:
59+
with contextlib.suppress(OSError):
60+
os.unlink(tmp)
61+
raise
62+
63+
64+
@router.get("", response_model=List[Room])
65+
async def list_rooms() -> List[Room]:
66+
"""Return all rooms."""
67+
return _read_rooms()
68+
69+
70+
@router.post("", response_model=Room, status_code=status.HTTP_201_CREATED)
71+
async def create_room(body: RoomCreate) -> Room:
72+
"""Create a new room."""
73+
name = body.name.strip()
74+
if not name:
75+
raise HTTPException(
76+
status_code=status.HTTP_400_BAD_REQUEST,
77+
detail="Room name cannot be empty",
78+
)
79+
slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_") or "room"
80+
slug = slug[:20]
81+
room_id = f"{slug}_{secrets.token_hex(3)}"
82+
rooms = _read_rooms()
83+
new_room = Room(id=room_id, name=name)
84+
rooms.append(new_room)
85+
_write_rooms(rooms)
86+
return new_room
87+
88+
89+
@router.delete("/{room_id}", status_code=status.HTTP_204_NO_CONTENT)
90+
async def delete_room(room_id: str) -> None:
91+
"""Delete a room and its layout file. Cannot delete the last room."""
92+
if not ROOM_ID_RE.match(room_id):
93+
raise HTTPException(
94+
status_code=status.HTTP_400_BAD_REQUEST,
95+
detail="Invalid room ID",
96+
)
97+
rooms = _read_rooms()
98+
if len(rooms) <= 1:
99+
raise HTTPException(
100+
status_code=status.HTTP_400_BAD_REQUEST,
101+
detail="Cannot delete the last room",
102+
)
103+
new_rooms = [r for r in rooms if r.id != room_id]
104+
if len(new_rooms) == len(rooms):
105+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
106+
_write_rooms(new_rooms)
107+
# Delete the layout file for this room (ignore if absent)
108+
layout_file = settings.layout_file.parent / f"layout_{room_id}.json"
109+
with contextlib.suppress(OSError):
110+
layout_file.unlink()

0 commit comments

Comments
 (0)