diff --git a/python/api/dependencies.py b/python/api/dependencies.py new file mode 100644 index 0000000..459a4e1 --- /dev/null +++ b/python/api/dependencies.py @@ -0,0 +1,16 @@ +"""FastAPI dependencies.""" + +from collections.abc import Iterator +from typing import Annotated + +from fastapi import Depends, Request +from sqlalchemy.orm import Session + + +def get_db(request: Request) -> Iterator[Session]: + """Get database session from app state.""" + with Session(request.app.state.engine) as session: + yield session + + +DbSession = Annotated[Session, Depends(get_db)] diff --git a/python/api/main.py b/python/api/main.py new file mode 100644 index 0000000..5090ee6 --- /dev/null +++ b/python/api/main.py @@ -0,0 +1,113 @@ +"""FastAPI interface for Contact database.""" + +import shutil +import subprocess +import tempfile +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from os import environ +from pathlib import Path +from typing import Annotated + +import typer +import uvicorn +from fastapi import FastAPI + +from python.api.routers import contact_router, create_frontend_router +from python.orm.base import get_postgres_engine + + +def create_app(frontend_dir: Path | None = None) -> FastAPI: + """Create and configure the FastAPI application.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """Manage application lifespan.""" + app.state.engine = get_postgres_engine() + yield + app.state.engine.dispose() + + app = FastAPI(title="Contact Database API", lifespan=lifespan) + + app.include_router(contact_router) + + if frontend_dir: + print(f"Serving frontend from {frontend_dir}") + frontend_router = create_frontend_router(frontend_dir) + app.include_router(frontend_router) + + return app + + +cli = typer.Typer() + + +def build_frontend(source_dir: Path | None, cache_dir: Path | None = None) -> Path | None: + """Run npm build and copy output to a temp directory. + + Works even if source_dir is read-only by copying to a temp directory first. + + Args: + source_dir: Frontend source directory. + cache_dir: Optional npm cache directory for faster repeated builds. + + Returns: + Path to frontend build directory, or None if no source_dir provided. + """ + if not source_dir: + return None + + if not source_dir.exists(): + error = f"Error: Frontend directory {source_dir} does not exist" + raise FileExistsError(error) + + print(f"Building frontend from {source_dir}...") + + # Copy source to a writable temp directory + build_dir = Path(tempfile.mkdtemp(prefix="contact_frontend_build_")) + shutil.copytree(source_dir, build_dir, dirs_exist_ok=True) + + env = dict(environ) + if cache_dir: + cache_dir.mkdir(parents=True, exist_ok=True) + env["npm_config_cache"] = str(cache_dir) + + subprocess.run(["npm", "install"], cwd=build_dir, env=env, check=True) + subprocess.run(["npm", "run", "build"], cwd=build_dir, env=env, check=True) + + dist_dir = build_dir / "dist" + if not dist_dir.exists(): + error = f"Build output not found at {dist_dir}" + raise FileNotFoundError(error) + + output_dir = Path(tempfile.mkdtemp(prefix="contact_frontend_")) + shutil.copytree(dist_dir, output_dir, dirs_exist_ok=True) + print(f"Frontend built and copied to {output_dir}") + + shutil.rmtree(build_dir) + + return output_dir + + +@cli.command() +def serve( + frontend_dir: Annotated[ + Path | None, + typer.Option( + "--frontend-dir", + "-f", + help="Frontend source directory. If provided, runs npm build and serves from temp dir.", + ), + ] = None, + host: Annotated[str, typer.Option("--host", "-h", help="Host to bind to")] = "0.0.0.0", + port: Annotated[int, typer.Option("--port", "-p", help="Port to bind to")] = 8000, +) -> None: + """Start the Contact API server.""" + serve_dir = build_frontend(frontend_dir) + + app = create_app(frontend_dir=serve_dir) + uvicorn.run(app, host=host, port=port) + + +if __name__ == "__main__": + cli() diff --git a/python/api/routers/__init__.py b/python/api/routers/__init__.py new file mode 100644 index 0000000..2e59a7e --- /dev/null +++ b/python/api/routers/__init__.py @@ -0,0 +1,6 @@ +"""API routers.""" + +from python.api.routers.contact import router as contact_router +from python.api.routers.frontend import create_frontend_router + +__all__ = ["contact_router", "create_frontend_router"] diff --git a/python/api/contact_api.py b/python/api/routers/contact.py similarity index 76% rename from python/api/contact_api.py rename to python/api/routers/contact.py index fe2696d..64e6eda 100644 --- a/python/api/contact_api.py +++ b/python/api/routers/contact.py @@ -1,23 +1,13 @@ -"""FastAPI interface for Contact database.""" +"""Contact API router.""" -from collections.abc import AsyncIterator, Iterator -from contextlib import asynccontextmanager -from pathlib import Path -from typing import Annotated - -from fastapi import Depends, FastAPI, HTTPException -from fastapi.responses import FileResponse -from fastapi.staticfiles import StaticFiles +from fastapi import APIRouter, HTTPException from pydantic import BaseModel from sqlalchemy import select -from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session, selectinload +from sqlalchemy.orm import selectinload -from python.orm.base import get_postgres_engine +from python.api.dependencies import DbSession from python.orm.contact import Contact, ContactRelationship, Need, RelationshipType -FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend" / "dist" - class NeedBase(BaseModel): """Base schema for Need.""" @@ -162,55 +152,10 @@ class ContactListResponse(ContactBase): model_config = {"from_attributes": True} -class DatabaseSession: - """Database session manager.""" - - def __init__(self) -> None: - """Initialize with no engine.""" - self._engine: Engine | None = None - - @property - def engine(self) -> Engine: - """Get or create the database engine.""" - if self._engine is None: - self._engine = get_postgres_engine() - return self._engine - - def get_session(self) -> Iterator[Session]: - """Yield a database session.""" - with Session(self.engine) as session: - yield session - - def dispose(self) -> None: - """Dispose of the engine.""" - if self._engine is not None: - self._engine.dispose() - self._engine = None +router = APIRouter(prefix="/api", tags=["contacts"]) -db_manager = DatabaseSession() - - -def get_db() -> Iterator[Session]: - """Get database session dependency.""" - yield from db_manager.get_session() - - -DbSession = Annotated[Session, Depends(get_db)] - - -@asynccontextmanager -async def lifespan(_: FastAPI) -> AsyncIterator[None]: - """Manage application lifespan.""" - yield - db_manager.dispose() - - -app = FastAPI(title="Contact Database API", lifespan=lifespan) - - -# API routes -@app.post("/api/needs", response_model=NeedResponse) +@router.post("/needs", response_model=NeedResponse) def create_need(need: NeedCreate, db: DbSession) -> Need: """Create a new need.""" db_need = Need(name=need.name, description=need.description) @@ -220,13 +165,13 @@ def create_need(need: NeedCreate, db: DbSession) -> Need: return db_need -@app.get("/api/needs", response_model=list[NeedResponse]) +@router.get("/needs", response_model=list[NeedResponse]) def list_needs(db: DbSession) -> list[Need]: """List all needs.""" return list(db.scalars(select(Need)).all()) -@app.get("/api/needs/{need_id}", response_model=NeedResponse) +@router.get("/needs/{need_id}", response_model=NeedResponse) def get_need(need_id: int, db: DbSession) -> Need: """Get a need by ID.""" need = db.get(Need, need_id) @@ -235,7 +180,7 @@ def get_need(need_id: int, db: DbSession) -> Need: return need -@app.delete("/api/needs/{need_id}") +@router.delete("/needs/{need_id}") def delete_need(need_id: int, db: DbSession) -> dict[str, bool]: """Delete a need by ID.""" need = db.get(Need, need_id) @@ -246,7 +191,7 @@ def delete_need(need_id: int, db: DbSession) -> dict[str, bool]: return {"deleted": True} -@app.post("/api/contacts", response_model=ContactResponse) +@router.post("/contacts", response_model=ContactResponse) def create_contact(contact: ContactCreate, db: DbSession) -> Contact: """Create a new contact.""" need_ids = contact.need_ids @@ -263,7 +208,7 @@ def create_contact(contact: ContactCreate, db: DbSession) -> Contact: return db_contact -@app.get("/api/contacts", response_model=list[ContactListResponse]) +@router.get("/contacts", response_model=list[ContactListResponse]) def list_contacts( db: DbSession, skip: int = 0, @@ -273,7 +218,7 @@ def list_contacts( return list(db.scalars(select(Contact).offset(skip).limit(limit)).all()) -@app.get("/api/contacts/{contact_id}", response_model=ContactResponse) +@router.get("/contacts/{contact_id}", response_model=ContactResponse) def get_contact(contact_id: int, db: DbSession) -> Contact: """Get a contact by ID with all relationships.""" contact = db.scalar( @@ -290,7 +235,7 @@ def get_contact(contact_id: int, db: DbSession) -> Contact: return contact -@app.patch("/api/contacts/{contact_id}", response_model=ContactResponse) +@router.patch("/contacts/{contact_id}", response_model=ContactResponse) def update_contact( contact_id: int, contact: ContactUpdate, @@ -316,7 +261,7 @@ def update_contact( return db_contact -@app.delete("/api/contacts/{contact_id}") +@router.delete("/contacts/{contact_id}") def delete_contact(contact_id: int, db: DbSession) -> dict[str, bool]: """Delete a contact by ID.""" contact = db.get(Contact, contact_id) @@ -327,7 +272,7 @@ def delete_contact(contact_id: int, db: DbSession) -> dict[str, bool]: return {"deleted": True} -@app.post("/api/contacts/{contact_id}/needs/{need_id}") +@router.post("/contacts/{contact_id}/needs/{need_id}") def add_need_to_contact( contact_id: int, need_id: int, @@ -349,7 +294,7 @@ def add_need_to_contact( return {"added": True} -@app.delete("/api/contacts/{contact_id}/needs/{need_id}") +@router.delete("/contacts/{contact_id}/needs/{need_id}") def remove_need_from_contact( contact_id: int, need_id: int, @@ -371,8 +316,8 @@ def remove_need_from_contact( return {"removed": True} -@app.post( - "/api/contacts/{contact_id}/relationships", +@router.post( + "/contacts/{contact_id}/relationships", response_model=ContactRelationshipResponse, ) def add_contact_relationship( @@ -409,8 +354,8 @@ def add_contact_relationship( return db_relationship -@app.get( - "/api/contacts/{contact_id}/relationships", +@router.get( + "/contacts/{contact_id}/relationships", response_model=list[ContactRelationshipResponse], ) def get_contact_relationships( @@ -422,21 +367,15 @@ def get_contact_relationships( if not contact: raise HTTPException(status_code=404, detail="Contact not found") - outgoing = list( - db.scalars( - select(ContactRelationship).where(ContactRelationship.contact_id == contact_id) - ).all() - ) + outgoing = list(db.scalars(select(ContactRelationship).where(ContactRelationship.contact_id == contact_id)).all()) incoming = list( - db.scalars( - select(ContactRelationship).where(ContactRelationship.related_contact_id == contact_id) - ).all() + db.scalars(select(ContactRelationship).where(ContactRelationship.related_contact_id == contact_id)).all() ) return outgoing + incoming -@app.patch( - "/api/contacts/{contact_id}/relationships/{related_contact_id}", +@router.patch( + "/contacts/{contact_id}/relationships/{related_contact_id}", response_model=ContactRelationshipResponse, ) def update_contact_relationship( @@ -465,7 +404,7 @@ def update_contact_relationship( return relationship -@app.delete("/api/contacts/{contact_id}/relationships/{related_contact_id}") +@router.delete("/contacts/{contact_id}/relationships/{related_contact_id}") def remove_contact_relationship( contact_id: int, related_contact_id: int, @@ -486,7 +425,7 @@ def remove_contact_relationship( return {"deleted": True} -@app.get("/api/relationship-types") +@router.get("/relationship-types") def list_relationship_types() -> list[RelationshipTypeInfo]: """List all available relationship types with their default weights.""" return [ @@ -499,16 +438,13 @@ def list_relationship_types() -> list[RelationshipTypeInfo]: ] -@app.get("/api/graph") +@router.get("/graph") def get_relationship_graph(db: DbSession) -> GraphData: """Get all contacts and relationships as graph data for visualization.""" contacts = list(db.scalars(select(Contact)).all()) relationships = list(db.scalars(select(ContactRelationship)).all()) - nodes = [ - GraphNode(id=c.id, name=c.name, current_job=c.current_job) - for c in contacts - ] + nodes = [GraphNode(id=c.id, name=c.name, current_job=c.current_job) for c in contacts] edges = [ GraphEdge( @@ -521,16 +457,3 @@ def get_relationship_graph(db: DbSession) -> GraphData: ] return GraphData(nodes=nodes, edges=edges) - - -# Serve React frontend -if FRONTEND_DIR.exists(): - app.mount("/assets", StaticFiles(directory=FRONTEND_DIR / "assets"), name="assets") - - @app.get("/{full_path:path}") - async def serve_spa(full_path: str) -> FileResponse: - """Serve React SPA for all non-API routes.""" - file_path = FRONTEND_DIR / full_path - if file_path.is_file(): - return FileResponse(file_path) - return FileResponse(FRONTEND_DIR / "index.html") diff --git a/python/api/routers/frontend.py b/python/api/routers/frontend.py new file mode 100644 index 0000000..150bca0 --- /dev/null +++ b/python/api/routers/frontend.py @@ -0,0 +1,24 @@ +"""Frontend SPA router.""" + +from pathlib import Path + +from fastapi import APIRouter +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + + +def create_frontend_router(frontend_dir: Path) -> APIRouter: + """Create a router for serving the frontend SPA.""" + router = APIRouter(tags=["frontend"]) + + router.mount("/assets", StaticFiles(directory=frontend_dir / "assets"), name="assets") + + @router.get("/{full_path:path}") + async def serve_spa(full_path: str) -> FileResponse: + """Serve React SPA for all non-API routes.""" + file_path = frontend_dir / full_path + if file_path.is_file(): + return FileResponse(file_path) + return FileResponse(frontend_dir / "index.html") + + return router diff --git a/python/orm/base.py b/python/orm/base.py index 935bdf5..ec8f1e6 100644 --- a/python/orm/base.py +++ b/python/orm/base.py @@ -61,7 +61,6 @@ def get_connection_info() -> tuple[str, str, str, str, str | None]: f"{host=}\n" f"{port=}\n" f"{username=}\n" - f"password{'***' if password else None}\n" ) raise ValueError(error) return cast("tuple[str, str, str, str, str | None]", (database, host, port, username, password)) diff --git a/systems/brain/services/home_assistant/gps_location.yaml b/systems/brain/services/home_assistant/gps_location.yaml new file mode 100644 index 0000000..19d88c5 --- /dev/null +++ b/systems/brain/services/home_assistant/gps_location.yaml @@ -0,0 +1,107 @@ +automation: + - id: update_home_location_from_gps + alias: Update Home Location from GPS + description: Updates the Home zone location based on GPS coordinates from Modbus + trigger: + - platform: state + entity_id: + - sensor.gps_latitude + - sensor.gps_longitude + condition: + - condition: template + value_template: >- + {% set lat = states('sensor.gps_latitude')|float(0) %} + {% set lon = states('sensor.gps_longitude')|float(0) %} + {% set fix = states('sensor.gps_fix')|int(0) %} + {{ lat != 0 and lon != 0 and fix > 0 }} + action: + - service: homeassistant.set_location + data: + latitude: "{{ states('sensor.gps_latitude') }}" + longitude: "{{ states('sensor.gps_longitude') }}" + + - id: update_home_location_on_startup + alias: Update Home Location on Startup + description: Sets home location from last known GPS coordinates on HA restart + trigger: + - platform: homeassistant + event: start + condition: + - condition: template + value_template: >- + {% set lat = states('sensor.gps_latitude')|float(0) %} + {% set lon = states('sensor.gps_longitude')|float(0) %} + {{ lat != 0 and lon != 0 }} + action: + - delay: + seconds: 10 + - service: homeassistant.set_location + data: + latitude: "{{ states('sensor.gps_latitude') }}" + longitude: "{{ states('sensor.gps_longitude') }}" + + - id: refresh_weather_hourly + alias: Refresh Weather Hourly + description: Forces weather to refresh hourly with current GPS location + trigger: + - platform: time_pattern + hours: "/1" + condition: + - condition: state + entity_id: binary_sensor.gps_fix_available + state: "on" + action: + - service: homeassistant.update_entity + target: + entity_id: weather.home + +template: + - sensor: + - name: GPS Location + unique_id: gps_location + state: >- + {% set lat = states('sensor.gps_latitude')|float(0) %} + {% set lon = states('sensor.gps_longitude')|float(0) %} + {% if lat != 0 and lon != 0 %} + {{ lat }}, {{ lon }} + {% else %} + unavailable + {% endif %} + attributes: + latitude: "{{ states('sensor.gps_latitude') }}" + longitude: "{{ states('sensor.gps_longitude') }}" + speed: "{{ states('sensor.gps_speed') }}" + course: "{{ states('sensor.gps_course') }}" + altitude: "{{ states('sensor.gps_altitude') }}" + satellites: "{{ states('sensor.gps_satellites') }}" + fix: "{{ states('sensor.gps_fix') }}" + last_updated: "{{ now().isoformat() }}" + + # Weather sensors based on current GPS location + - name: Current Weather Temperature + unique_id: current_weather_temperature + unit_of_measurement: "°F" + device_class: temperature + state: "{{ state_attr('weather.home', 'temperature') }}" + + - name: Current Weather Humidity + unique_id: current_weather_humidity + unit_of_measurement: "%" + device_class: humidity + state: "{{ state_attr('weather.home', 'humidity') }}" + + - name: Current Weather Condition + unique_id: current_weather_condition + state: "{{ states('weather.home') }}" + + - name: Current Weather Wind Speed + unique_id: current_weather_wind_speed + unit_of_measurement: "mph" + device_class: wind_speed + state: "{{ state_attr('weather.home', 'wind_speed') }}" + + - binary_sensor: + - name: GPS Fix Available + unique_id: gps_fix_available + device_class: connectivity + state: "{{ states('sensor.gps_fix')|int(0) > 0 }}" diff --git a/systems/jeeves/services/contact_api.nix b/systems/jeeves/services/contact_api.nix new file mode 100644 index 0000000..df9c5e3 --- /dev/null +++ b/systems/jeeves/services/contact_api.nix @@ -0,0 +1,45 @@ +{ + pkgs, + inputs, + ... +}: +{ + networking.firewall.allowedTCPPorts = [ + 8069 + ]; + systemd.services.contact-api = { + description = "Contact Database API with Frontend"; + after = [ + "postgresql.service" + "network.target" + ]; + requires = [ "postgresql.service" ]; + wantedBy = [ "multi-user.target" ]; + + environment = { + PYTHONPATH = "${inputs.self}"; + POSTGRES_DB = "richie"; + POSTGRES_HOST = "/run/postgresql"; + POSTGRES_USER = "richie"; + FRONTEND_DIR = "/home/richie/dotfiles/frontend/dist/"; + }; + + serviceConfig = { + Type = "simple"; + ExecStart = "${pkgs.my_python}/bin/fastapi run ${inputs.self}/python/api/contact_api.py --port 8069"; + Restart = "on-failure"; + RestartSec = "5s"; + StandardOutput = "journal"; + StandardError = "journal"; + # Security hardening + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = "read-only"; + PrivateTmp = true; + ReadOnlyPaths = [ + "${inputs.self}" + "/home/richie/dotfiles/frontend/dist/" + ]; + }; + }; +}