diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..41c8deb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +.venv +.env +.env.local +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.pytest_cache +.coverage +htmlcov +.tox +.mypy_cache +.ruff_cache +db.sqlite3 +*.log +.DS_Store +.vscode +.idea +.git +.gitignore +README.md +docs/ +frontend/ +services/ +infrastructure/ +thesis/ +scripts/ +demo.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e322a6f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,49 @@ +# Django Backend Dockerfile +FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 + +# Copy dependency files +COPY pyproject.toml uv.lock ./ + +# Install dependencies +RUN uv sync --frozen --no-dev --no-install-project + +# Copy application code +COPY iotDashboard/ ./iotDashboard/ +COPY manage.py ./ +COPY create_user.py ./ + +# Sync the project +RUN uv sync --frozen --no-dev + + +# Stage 2: Runtime +FROM python:3.13-alpine + +WORKDIR /app + +# Install runtime dependencies +RUN apk add --no-cache postgresql-client + +# Copy virtual environment and application +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/iotDashboard/ /app/iotDashboard/ +COPY --from=builder /app/manage.py /app/ +COPY --from=builder /app/create_user.py /app/ + +# Create non-root user +RUN adduser -D -u 1000 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +ENV PATH="/app/.venv/bin:$PATH" +ENV PYTHONUNBUFFERED=1 + +EXPOSE 3000 + +# Run Django with uvicorn for ASGI +CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"] diff --git a/create_user.py b/create_user.py new file mode 100755 index 0000000..ff9f439 --- /dev/null +++ b/create_user.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Create initial admin user for IoT Dashboard. +Run this script once after running the database migration. + +Usage: + python create_user.py + +Or with custom credentials: + python create_user.py --username admin --password yourpassword --email admin@example.com +""" + +import os +import sys +import secrets +import argparse +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# Add project root to path +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# Configure Django settings before importing make_password (but don't call setup) +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings') + +# Import Django settings and configure password hasher manually +from django.conf import settings +if not settings.configured: + from iotDashboard.settings import * + settings.configure( + PASSWORD_HASHERS=[ + 'django.contrib.auth.hashers.PBKDF2PasswordHasher', + 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', + 'django.contrib.auth.hashers.Argon2PasswordHasher', + 'django.contrib.auth.hashers.BCryptSHA256PasswordHasher', + ], + SECRET_KEY=SECRET_KEY, + ) + +from django.contrib.auth.hashers import make_password + +from db_migrations.models import User + +def create_user(username: str, password: str, email: str): + """Create a new user in the database.""" + + # Get database URL from environment + database_url = os.getenv("DATABASE_URL") + if not database_url: + print("Error: DATABASE_URL environment variable not set") + print("Example: export DATABASE_URL='postgresql://user:pass@localhost:5432/iot_data'") + sys.exit(1) + + # Create database connection + engine = create_engine(database_url) + Session = sessionmaker(bind=engine) + db = Session() + + try: + # Check if user already exists + existing_user = db.query(User).filter(User.username == username).first() + if existing_user: + print(f"Error: User '{username}' already exists") + sys.exit(1) + + # Create new user + user = User( + id=secrets.token_urlsafe(8), + username=username, + email=email, + password_hash=make_password(password), + is_active=True + ) + + db.add(user) + db.commit() + + print("✓ User created successfully!") + print(f" Username: {username}") + print(f" Email: {email}") + print(f" ID: {user.id}") + print("\nYou can now log in at http://localhost:5173/login") + + except Exception as e: + db.rollback() + print(f"Error creating user: {e}") + sys.exit(1) + finally: + db.close() + +def main(): + parser = argparse.ArgumentParser(description='Create IoT Dashboard user') + parser.add_argument('--username', default='admin', help='Username (default: admin)') + parser.add_argument('--password', default='admin123', help='Password (default: admin123)') + parser.add_argument('--email', default='admin@example.com', help='Email (default: admin@example.com)') + + args = parser.parse_args() + + print("Creating IoT Dashboard user...") + print(f"Username: {args.username}") + print(f"Email: {args.email}") + print() + + create_user(args.username, args.password, args.email) + +if __name__ == "__main__": + main() diff --git a/db_migrations/.dockerignore b/db_migrations/.dockerignore new file mode 100644 index 0000000..c273d88 --- /dev/null +++ b/db_migrations/.dockerignore @@ -0,0 +1,7 @@ +.env* +.md +__pycache__/ +*.pyc +*.pyo +*.pyd +*.log \ No newline at end of file diff --git a/db_migrations/Dockerfile b/db_migrations/Dockerfile new file mode 100644 index 0000000..4b777c8 --- /dev/null +++ b/db_migrations/Dockerfile @@ -0,0 +1,12 @@ +FROM ghcr.io/astral-sh/uv:python3.13-alpine + +WORKDIR /app + +COPY pyproject.toml ./ + +RUN uv sync + +COPY . . + +CMD ["uv", "run", "alembic", "upgrade", "head"] + \ No newline at end of file diff --git a/db_migrations/alembic/versions/20251215_2124_7c71d43d53e3_add_users_table.py b/db_migrations/alembic/versions/20251215_2124_7c71d43d53e3_add_users_table.py new file mode 100644 index 0000000..b9fbdb4 --- /dev/null +++ b/db_migrations/alembic/versions/20251215_2124_7c71d43d53e3_add_users_table.py @@ -0,0 +1,83 @@ +"""add users table + +Revision ID: 7c71d43d53e3 +Revises: 4b84a36e13f5 +Create Date: 2025-12-15 21:24:36.718471+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '7c71d43d53e3' +down_revision: Union[str, Sequence[str], None] = '4b84a36e13f5' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('id', sa.Text(), nullable=False), + sa.Column('username', sa.Text(), nullable=False), + sa.Column('email', sa.Text(), nullable=False), + sa.Column('password_hash', sa.Text(), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email'), + sa.UniqueConstraint('username') + ) + op.create_index('idx_users_email', 'users', ['email'], unique=False) + op.create_index('idx_users_username', 'users', ['username'], unique=False) + op.drop_index(op.f('dashboard_l_is_defa_033b71_idx'), table_name='dashboard_layouts') + op.drop_index(op.f('dashboard_l_name_c36020_idx'), table_name='dashboard_layouts') + op.drop_index(op.f('dashboard_layouts_name_349f3640_like'), table_name='dashboard_layouts', postgresql_ops={'name': 'varchar_pattern_ops'}) + op.drop_table('dashboard_layouts') + op.drop_table('django_migrations') + op.drop_table('iotDashboard_device') + op.drop_constraint(op.f('telemetry_device_id_fkey'), 'telemetry', type_='foreignkey') + op.create_foreign_key(None, 'telemetry', 'devices', ['device_id'], ['id']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'telemetry', type_='foreignkey') + op.create_foreign_key(op.f('telemetry_device_id_fkey'), 'telemetry', 'devices', ['device_id'], ['id'], ondelete='CASCADE') + op.create_table('iotDashboard_device', + sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=50), autoincrement=False, nullable=False), + sa.Column('ip', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('protocol', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('iotDashboard_device_pkey')) + ) + op.create_table('django_migrations', + sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False), + sa.Column('app', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('applied', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('django_migrations_pkey')) + ) + op.create_table('dashboard_layouts', + sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False), + sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False), + sa.Column('is_default', sa.BOOLEAN(), autoincrement=False, nullable=False), + sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('dashboard_layouts_pkey')), + sa.UniqueConstraint('name', name=op.f('dashboard_layouts_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + op.create_index(op.f('dashboard_layouts_name_349f3640_like'), 'dashboard_layouts', ['name'], unique=False, postgresql_ops={'name': 'varchar_pattern_ops'}) + op.create_index(op.f('dashboard_l_name_c36020_idx'), 'dashboard_layouts', ['name'], unique=False) + op.create_index(op.f('dashboard_l_is_defa_033b71_idx'), 'dashboard_layouts', ['is_default'], unique=False) + op.drop_index('idx_users_username', table_name='users') + op.drop_index('idx_users_email', table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/db_migrations/alembic/versions/20251215_2156_1dfb0bb45f93_add_cascade_delete_to_telemetry_foreign_.py b/db_migrations/alembic/versions/20251215_2156_1dfb0bb45f93_add_cascade_delete_to_telemetry_foreign_.py new file mode 100644 index 0000000..7b4326c --- /dev/null +++ b/db_migrations/alembic/versions/20251215_2156_1dfb0bb45f93_add_cascade_delete_to_telemetry_foreign_.py @@ -0,0 +1,45 @@ +"""add cascade delete to telemetry foreign key + +Revision ID: 1dfb0bb45f93 +Revises: 7c71d43d53e3 +Create Date: 2025-12-15 21:56:13.260281+00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision: str = '1dfb0bb45f93' +down_revision: Union[str, Sequence[str], None] = '7c71d43d53e3' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema - only update telemetry foreign key.""" + # Drop old foreign key constraint + op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey') + + # Add new foreign key constraint with CASCADE delete + op.create_foreign_key( + 'telemetry_device_id_fkey', + 'telemetry', 'devices', + ['device_id'], ['id'], + ondelete='CASCADE' + ) + + +def downgrade() -> None: + """Downgrade schema - revert foreign key change.""" + # Drop CASCADE foreign key + op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey') + + # Add back original foreign key without CASCADE + op.create_foreign_key( + 'telemetry_device_id_fkey', + 'telemetry', 'devices', + ['device_id'], ['id'] + ) diff --git a/db_migrations/models.py b/db_migrations/models.py index 3ce8c56..5eafd6a 100644 --- a/db_migrations/models.py +++ b/db_migrations/models.py @@ -109,7 +109,7 @@ class Telemetry(Base): __tablename__ = "telemetry" time = Column(DateTime(timezone=True), primary_key=True, nullable=False) - device_id = Column(Text, ForeignKey("devices.id"), primary_key=True, nullable=False) + device_id = Column(Text, ForeignKey("devices.id", ondelete="CASCADE"), primary_key=True, nullable=False) metric = Column(Text, primary_key=True, nullable=False) value = Column(Float, nullable=False) unit = Column(Text) @@ -118,3 +118,24 @@ class Telemetry(Base): def __repr__(self): return f"" + + +class User(Base): + """Dashboard users for authentication.""" + + __tablename__ = "users" + + id = Column(Text, primary_key=True) + username = Column(Text, unique=True, nullable=False) + email = Column(Text, unique=True, nullable=False) + password_hash = Column(Text, nullable=False) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + + __table_args__ = ( + Index("idx_users_username", "username"), + Index("idx_users_email", "email"), + ) + + def __repr__(self): + return f"" diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..6271c98 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,13 @@ +node_modules +dist +.env +.env.local +.env.*.local +npm-debug.log* +.DS_Store +.vscode +.idea +*.swp +*.swo +*.log +coverage diff --git a/frontend/.env.example b/frontend/.env.sample similarity index 100% rename from frontend/.env.example rename to frontend/.env.sample diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a1ed40b --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +COPY package*.json ./ + +RUN npm ci + +COPY . . + +RUN npm run build + + +FROM nginx:alpine + +COPY --from=builder /app/dist /usr/share/nginx/html + +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md index d2e7761..93976fd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,73 +1,111 @@ -# React + TypeScript + Vite +# IoT Dashboard Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +React-based dashboard for visualizing IoT telemetry data with customizable widgets. -Currently, two official plugins are available: +## Technology Stack -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +| Technology | Purpose | +|------------|---------| +| React 19 | UI framework | +| Vite | Build tool and dev server | +| TypeScript | Type safety | +| DaisyUI | Component library | +| Tailwind CSS | Styling | +| React Query | Data fetching and caching | +| Recharts | Data visualization | +| React Grid Layout | Drag-and-drop widget layout | -## React Compiler +## Features -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +- Customizable widget-based dashboard +- Drag-and-drop layout editing +- Multiple widget types (weather, charts, calendar, AI briefings) +- Responsive design +- Dark/light theme support -## Expanding the ESLint configuration +## Widget Types -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: +| Widget | Description | +|--------|-------------| +| WeatherWidget | Current weather and forecast | +| AirQualityWidget | PM2.5, PM10 levels from pulse.eco | +| ComfortIndexWidget | Indoor comfort based on temperature/humidity | +| RunSuitabilityWidget | Outdoor running conditions analysis | +| CalendarWidget | iCal calendar integration | +| DailyBriefingWidget | AI-generated daily summary | +| HealthStatsWidget | Health metrics from wearables | +| TelemetryChartWidget | Time-series data visualization | -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... +## Project Structure - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +``` +frontend/ +├── src/ +│ ├── api/ # API client functions +│ ├── components/ # React components +│ │ ├── widgets/ # Widget components +│ │ └── ... +│ ├── hooks/ # Custom React hooks +│ ├── types/ # TypeScript type definitions +│ ├── utils/ # Utility functions +│ ├── App.tsx # Main application component +│ └── main.tsx # Entry point +├── public/ # Static assets +├── package.json # Dependencies +└── vite.config.ts # Vite configuration ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +## Running -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +cd frontend +npm install +npm run dev +``` + +Development server runs at http://localhost:5173 + +## Configuration + +The frontend connects to the Django API. Configure the API URL in `vite.config.ts`: + +```typescript +proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + }, +} +``` + +## Building for Production + +```bash +npm run build +``` + +Output is in the `dist/` directory. + +## Key Components + +| Component | Purpose | +|-----------|---------| +| Dashboard.tsx | Main dashboard with widget grid | +| WidgetWrapper.tsx | Generic widget container | +| EditWidgetModal.tsx | Widget configuration modal | +| AddWidgetMenu.tsx | Widget type selection | + +## API Integration + +All API calls are in `src/api/index.ts`. Uses React Query for: +- Automatic caching +- Background refetching +- Loading/error states + +Example: +```typescript +const { data, isLoading } = useQuery({ + queryKey: ['weather', city], + queryFn: () => fetchWeather(city), +}); ``` diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..0ec0f59 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,36 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json; + + # SPA routing - serve index.html for all routes + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to Django backend + location /api/ { + proxy_pass http://django:3000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6051b75..c7d1327 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -24,7 +24,8 @@ "react-hook-form": "^7.66.1", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.9.6", - "recharts": "^3.4.1" + "recharts": "^3.4.1", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.36.0", @@ -4957,6 +4958,16 @@ "node": ">=8" } }, + "node_modules/tailwind-merge": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", + "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/tailwindcss": { "version": "4.1.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", diff --git a/frontend/package.json b/frontend/package.json index d1498f6..ebf75b1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,7 +26,8 @@ "react-hook-form": "^7.66.1", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.9.6", - "recharts": "^3.4.1" + "recharts": "^3.4.1", + "tailwind-merge": "^3.4.0" }, "devDependencies": { "@eslint/js": "^9.36.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e4825ae..f92559b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,16 +1,25 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom' +import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom' import { Toaster } from 'react-hot-toast' import { WellnessStateProvider } from './hooks/useWellnessState' +import { AuthProvider, useAuth } from './contexts/AuthContext' import Dashboard from './pages/Dashboard' import DeviceList from './pages/DeviceList' import DeviceDetail from './pages/DeviceDetail' import AddDevice from './pages/AddDevice' +import Login from './pages/Login' import './App.css' const queryClient = new QueryClient() +function ProtectedRoute({ children }: { children: React.ReactNode }) { + const { isAuthenticated } = useAuth() + return isAuthenticated ? <>{children} : +} + function AppLayout({ children }: { children: React.ReactNode }) { + const { logout } = useAuth() + return (
@@ -27,6 +36,11 @@ function AppLayout({ children }: { children: React.ReactNode }) {
IoT Dashboard
+
+ +
{/* Page content */} @@ -38,7 +52,7 @@ function AppLayout({ children }: { children: React.ReactNode }) { {/* Sidebar */}
-
@@ -82,17 +105,21 @@ function AppLayout({ children }: { children: React.ReactNode }) { function App() { return ( - - - - - } /> - } /> - } /> - } /> - - - + + + + + + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) } diff --git a/frontend/src/components/AddWidgetModal.tsx b/frontend/src/components/AddWidgetModal.tsx index 68c9fbd..50c3fac 100644 --- a/frontend/src/components/AddWidgetModal.tsx +++ b/frontend/src/components/AddWidgetModal.tsx @@ -113,7 +113,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa defaultTitle = `Run Suitability - ${city}` } else if (widgetType === 'health-stats') { const selectedDevice = devices.find(d => d.id === selectedDevices[0]) - defaultTitle = `Health StaTts - ${selectedDevice?.name || 'Device'}` + defaultTitle = `Health Stats - ${selectedDevice?.name || 'Device'}` } else if (widgetType === 'calendar') { defaultTitle = 'Calendar Agenda' } else if (widgetType === 'daily-briefing') { diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..f6309e1 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,50 @@ +import { createContext, useContext, useState, useEffect, type ReactNode } from 'react' +import { apiClient } from '../lib/api-client' + +interface AuthContextType { + isAuthenticated: boolean + login: (username: string, password: string) => Promise + logout: () => void +} + +const AuthContext = createContext(undefined) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [token, setToken] = useState( + () => localStorage.getItem('access_token') + ) + + useEffect(() => { + if (token) { + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}` + } else { + delete apiClient.defaults.headers.common['Authorization'] + } + }, [token]) + + const login = async (username: string, password: string) => { + const response = await apiClient.post('/auth/login/', { username, password }) + const { access, refresh } = response.data + localStorage.setItem('access_token', access) + localStorage.setItem('refresh_token', refresh) + setToken(access) + } + + const logout = () => { + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + setToken(null) + } + + return ( + + {children} + + ) +} + +export const useAuth = () => { + const context = useContext(AuthContext) + if (!context) throw new Error('useAuth must be used within AuthProvider') + return context +} diff --git a/frontend/src/hooks/useGridstack.ts b/frontend/src/hooks/useGridstack.ts index 617eee7..90f7c16 100644 --- a/frontend/src/hooks/useGridstack.ts +++ b/frontend/src/hooks/useGridstack.ts @@ -4,7 +4,7 @@ import 'gridstack/dist/gridstack.min.css' // Define the widget type based on gridstack.js structure export type GridStackWidget = { - id?: string | number + id?: string x?: number y?: number w?: number @@ -74,7 +74,7 @@ export function useGridstack(options: UseGridstackOptions = {}) { // Handle layout change if (onLayoutChange) { - grid.on('change', (event, items) => { + grid.on('change', () => { const serialized = grid.save(false) as GridStackWidget[] onLayoutChange(serialized) }) diff --git a/frontend/src/hooks/useWellnessState.tsx b/frontend/src/hooks/useWellnessState.tsx index 60404d7..c54d2b5 100644 --- a/frontend/src/hooks/useWellnessState.tsx +++ b/frontend/src/hooks/useWellnessState.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, ReactNode } from 'react' +import { createContext, useContext, useState, type ReactNode } from 'react' interface WellnessState { healthDeviceId: string | null diff --git a/frontend/src/lib/api-client.ts b/frontend/src/lib/api-client.ts index b5a729c..578f991 100644 --- a/frontend/src/lib/api-client.ts +++ b/frontend/src/lib/api-client.ts @@ -10,11 +10,47 @@ export const apiClient = axios.create({ }, }); -// Add response interceptor for error handling +// Add token from localStorage on initialization +const token = localStorage.getItem('access_token'); +if (token) { + apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`; +} + +// Add response interceptor for token refresh on 401 apiClient.interceptors.response.use( (response) => response, - (error) => { - // Basic error handling - can be extended if needed + async (error) => { + const originalRequest = error.config; + + // Handle 401 errors with token refresh + if (error.response?.status === 401 && !originalRequest._retry) { + originalRequest._retry = true; + + try { + const refreshToken = localStorage.getItem('refresh_token'); + if (!refreshToken) { + throw new Error('No refresh token'); + } + + const response = await axios.post(`${API_BASE_URL}/auth/refresh/`, { + refresh: refreshToken + }); + + const { access } = response.data; + localStorage.setItem('access_token', access); + apiClient.defaults.headers.common['Authorization'] = `Bearer ${access}`; + originalRequest.headers['Authorization'] = `Bearer ${access}`; + + return apiClient(originalRequest); + } catch (refreshError) { + // Refresh failed - clear tokens and redirect to login + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + window.location.href = '/login'; + return Promise.reject(refreshError); + } + } + return Promise.reject(error); } ); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx new file mode 100644 index 0000000..bfcaa45 --- /dev/null +++ b/frontend/src/pages/Login.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useAuth } from '../contexts/AuthContext' + +export default function Login() { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const { login } = useAuth() + const navigate = useNavigate() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError('') + setLoading(true) + try { + await login(username, password) + navigate('/dashboard') + } catch (err: any) { + setError(err.response?.data?.detail || 'Invalid credentials') + } finally { + setLoading(false) + } + } + + return ( +
+
+
+

IoT Dashboard Login

+ {error &&
{error}
} +
+
+ + setUsername(e.target.value)} + required + autoFocus + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+
+
+
+ ) +} diff --git a/iotDashboard/api/views.py b/iotDashboard/api/views.py index 16e9156..d8fad9b 100644 --- a/iotDashboard/api/views.py +++ b/iotDashboard/api/views.py @@ -20,7 +20,6 @@ from iotDashboard import weather_client from iotDashboard.comfort_index import ( ComfortMetrics, ComfortIndexCalculator, - calculate_comfort_index_from_telemetry, ) from iotDashboard.run_suitability import ( RunSuitabilityCalculator, @@ -89,11 +88,18 @@ class DeviceViewSet(viewsets.ModelViewSet): ) def destroy(self, request, pk=None): - """Delete a device.""" + """Delete a device via device_manager microservice.""" try: device = self.get_object() device_name = device.name + device_id = device.id + + # Call device_manager to delete device and handle certificates + device_manager.delete_device(device_id) + + # Delete from Django database (CASCADE will handle related records) device.delete() + return Response( {'message': f"Device '{device_name}' deleted successfully"}, status=status.HTTP_204_NO_CONTENT @@ -103,6 +109,11 @@ class DeviceViewSet(viewsets.ModelViewSet): {'error': 'Device not found'}, status=status.HTTP_404_NOT_FOUND ) + except DeviceManagerAPIError as e: + return Response( + {'error': e.message, 'details': e.details}, + status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR + ) @action(detail=True, methods=['post']) def revoke(self, request, pk=None): @@ -298,7 +309,6 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet): queryset = Telemetry.objects.all() serializer_class = TelemetrySerializer - # permission_classes = [IsAuthenticated] def get_queryset(self): """Filter telemetry by query parameters.""" @@ -664,9 +674,9 @@ class WellnessViewSet(viewsets.ViewSet): ) try: - # Get health device + # Validate health device exists try: - health_device = Device.objects.get(id=health_device_id) + Device.objects.get(id=health_device_id) except Device.DoesNotExist: return Response( {'error': f'Health device {health_device_id} not found'}, @@ -811,9 +821,9 @@ class WellnessViewSet(viewsets.ViewSet): ) try: - # Get health device + # Validate health device exists try: - health_device = Device.objects.get(id=health_device_id) + Device.objects.get(id=health_device_id) except Device.DoesNotExist: return Response( {'error': f'Health device {health_device_id} not found'}, @@ -973,7 +983,7 @@ class WellnessViewSet(viewsets.ViewSet): outdoor_data['pm25'] = aq.get('measurements', {}).get('pm25', {}).get('average') outdoor_data['pm10'] = aq.get('measurements', {}).get('pm10', {}).get('average') outdoor_data['air_quality_status'] = aq.get('status') - except Exception as e: + except Exception: pass # Air quality optional # Gather health data if device specified @@ -1013,7 +1023,7 @@ class WellnessViewSet(viewsets.ViewSet): current_time, calendar_range_hours ) - except Exception as e: + except Exception: pass # Calendar optional # Call GPT service @@ -1095,7 +1105,7 @@ class WellnessViewSet(viewsets.ViewSet): continue if dt.replace(tzinfo=None) > end_time.replace(tzinfo=None): continue - except: + except ValueError: event['start'] = 'TBD' # Extract location diff --git a/iotDashboard/auth_backend.py b/iotDashboard/auth_backend.py new file mode 100644 index 0000000..a3b03e0 --- /dev/null +++ b/iotDashboard/auth_backend.py @@ -0,0 +1,48 @@ +""" +Custom authentication backend for IoT Dashboard. +Uses the custom users table instead of Django's auth_user table. +""" + +from django.contrib.auth.hashers import check_password +from django.contrib.auth.backends import BaseBackend +from iotDashboard.models import User + + +class CustomUserBackend(BaseBackend): + """ + Custom authentication backend that uses our users table. + """ + + def authenticate(self, request, username=None, password=None, **kwargs): + """ + Authenticate user against our custom users table. + """ + if username is None or password is None: + return None + + try: + user = User.objects.get(username=username, is_active=True) + except User.DoesNotExist: + return None + + # Verify password + if check_password(password, user.password_hash): + # Create a minimal user-like object that JWT can use + # We need to add required attributes for JWT + user.is_authenticated = True + user.pk = user.id # JWT expects pk attribute + return user + + return None + + def get_user(self, user_id): + """ + Get user by ID for session/JWT validation. + """ + try: + user = User.objects.get(pk=user_id, is_active=True) + user.is_authenticated = True + user.pk = user.id + return user + except User.DoesNotExist: + return None diff --git a/iotDashboard/device_manager_client.py b/iotDashboard/device_manager_client.py index 8a23941..d403e0b 100644 --- a/iotDashboard/device_manager_client.py +++ b/iotDashboard/device_manager_client.py @@ -151,6 +151,11 @@ class DeviceManagerClient: webhook_secret=data.get("webhook_secret"), ) + def delete_device(self, device_id: str) -> Dict[str, Any]: + """Delete a device and its associated certificates.""" + response = self._request("POST", f"/devices/{device_id}/delete") + return response.json() + def get_ca_certificate(self) -> str: response = self._request("GET", "/ca_certificate") return response.text @@ -213,3 +218,7 @@ def revoke_certificate(device_id: str) -> Dict[str, Any]: def renew_certificate(device_id: str) -> Dict[str, Any]: return default_client.renew_certificate(device_id) + + +def delete_device(device_id: str) -> Dict[str, Any]: + return default_client.delete_device(device_id) diff --git a/iotDashboard/jwt_serializers.py b/iotDashboard/jwt_serializers.py new file mode 100644 index 0000000..ab40eef --- /dev/null +++ b/iotDashboard/jwt_serializers.py @@ -0,0 +1,23 @@ +""" +Custom JWT serializers for IoT Dashboard. +Handles string-based user IDs instead of integer IDs. +""" + +from rest_framework_simplejwt.serializers import TokenObtainPairSerializer +from rest_framework_simplejwt.tokens import RefreshToken + + +class CustomTokenObtainPairSerializer(TokenObtainPairSerializer): + """ + Custom token serializer that handles string user IDs. + """ + + @classmethod + def get_token(cls, user): + token = RefreshToken.for_user(user) + + # Add custom claims + token['username'] = user.username + token['email'] = user.email + + return token diff --git a/iotDashboard/models.py b/iotDashboard/models.py index c3bd9d0..a66d468 100644 --- a/iotDashboard/models.py +++ b/iotDashboard/models.py @@ -175,3 +175,21 @@ class Telemetry(models.Model): def __str__(self): return f"{self.device.name} - {self.metric}: {self.value} at {self.time}" + +class User(models.Model): + """Dashboard users for authentication (mirrors SQLAlchemy model).""" + + id = models.CharField(max_length=255, primary_key=True) + username = models.CharField(max_length=255, unique=True) + email = models.CharField(max_length=255, unique=True) + password_hash = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + managed = False + db_table = "users" + + def __str__(self): + return self.username + diff --git a/iotDashboard/settings.py b/iotDashboard/settings.py index f4be685..b665490 100644 --- a/iotDashboard/settings.py +++ b/iotDashboard/settings.py @@ -50,6 +50,7 @@ INSTALLED_APPS = [ "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", + "rest_framework_simplejwt", "corsheaders", "iotDashboard", ] @@ -153,13 +154,23 @@ REST_FRAMEWORK = { 'rest_framework.parsers.JSONParser', ], 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework.authentication.SessionAuthentication', # For admin only ], 'DEFAULT_PERMISSION_CLASSES': [ - 'rest_framework.permissions.AllowAny', # Single-user system, no auth required + 'rest_framework.permissions.AllowAny', # Keep APIs open, auth only in frontend ], } +# Simple JWT Settings +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=8), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=30), + 'ROTATE_REFRESH_TOKENS': True, + 'AUTH_HEADER_TYPES': ('Bearer',), +} + # CORS Settings (for React frontend) CORS_ALLOWED_ORIGINS = [ "http://localhost:5173", diff --git a/iotDashboard/urls.py b/iotDashboard/urls.py index fef87c2..96d863e 100644 --- a/iotDashboard/urls.py +++ b/iotDashboard/urls.py @@ -18,10 +18,15 @@ Including another URLconf from django.contrib import admin from django.urls import path, include from iotDashboard import views +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView urlpatterns = [ path("admin/", admin.site.urls), + # JWT Authentication + path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + # REST API path("api/", include("iotDashboard.api.urls")), diff --git a/iotDashboard/views.py b/iotDashboard/views.py index bd6d37c..675b64d 100644 --- a/iotDashboard/views.py +++ b/iotDashboard/views.py @@ -180,14 +180,25 @@ def view_device(request, device_id): def delete_device(request, device_id): - """Delete a device.""" + """Delete a device via device_manager microservice.""" try: device = Device.objects.get(id=device_id) if request.method == "POST": device_name = device.name - device.delete() - messages.success(request, f"Device '{device_name}' deleted successfully") + + try: + # Call device_manager to delete device and handle certificates + device_manager.delete_device(device_id) + + # Delete from Django database (CASCADE will handle related records) + device.delete() + + messages.success(request, f"Device '{device_name}' deleted successfully") + except DeviceManagerAPIError as e: + messages.error(request, f"Failed to delete device: {e.message}") + return redirect("device_list") + return redirect("device_list") return render(request, "device_confirm_delete.html", {"device": device}) diff --git a/services/device_manager/.dockerignore b/services/device_manager/.dockerignore index 01415b9..1653cf4 100644 --- a/services/device_manager/.dockerignore +++ b/services/device_manager/.dockerignore @@ -6,4 +6,5 @@ __pycache__/ *.pyd *.crl *.crt -*.pem \ No newline at end of file +*.pem +*.sh diff --git a/services/device_manager/gen_ca.sh b/services/device_manager/gen_ca.sh new file mode 100644 index 0000000..7e200dc --- /dev/null +++ b/services/device_manager/gen_ca.sh @@ -0,0 +1,34 @@ +#!/bin/bash +set -e + +CERT_DIR="certs" +SERVER_IP="${1:-localhost}" + +mkdir -p "$CERT_DIR" + +openssl genrsa -out "$CERT_DIR/ca.key" 4096 +openssl req -new -x509 -days 3650 -key "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.crt" \ + -subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA" + +openssl genrsa -out "$CERT_DIR/server.key" 4096 +openssl req -new -key "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" \ + -subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=MQTT/CN=$SERVER_IP" + +cat > "$CERT_DIR/server.ext" << EOF +subjectAltName = @alt_names +[alt_names] +IP.1 = $SERVER_IP +DNS.1 = localhost +EOF + +openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \ + -CAcreateserial -out "$CERT_DIR/server.crt" -days 365 -sha256 -extfile "$CERT_DIR/server.ext" + +rm "$CERT_DIR/server.csr" "$CERT_DIR/server.ext" "$CERT_DIR/ca.srl" + +chmod 600 "$CERT_DIR/ca.key" "$CERT_DIR/server.key" +chmod 644 "$CERT_DIR/ca.crt" "$CERT_DIR/server.crt" + +echo "Certificates created:" +echo " CA: $CERT_DIR/ca.crt" +echo " Server: $CERT_DIR/server.crt (valid for $SERVER_IP)" diff --git a/services/device_manager/generate_ca.sh b/services/device_manager/generate_ca.sh index 3d7b97c..670118d 100755 --- a/services/device_manager/generate_ca.sh +++ b/services/device_manager/generate_ca.sh @@ -1,34 +1,39 @@ #!/bin/bash -# Script to generate a Certificate Authority (CA) for IoT devices -CERT_DIR="certs" -CA_KEY="$CERT_DIR/ca.key" -CA_CERT="$CERT_DIR/ca.crt" +set -e + +CERT_DIR="certs" +DOMAINS="${@:-localhost}" -# Create certs directory mkdir -p "$CERT_DIR" -echo "Generating CA Certificate Authority..." +echo "Generating CA..." +openssl genrsa -out "$CERT_DIR/ca.key" 4096 +openssl req -new -x509 -days 3650 -key "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.crt" \ + -subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA" -# Generate CA private key (4096-bit RSA) -openssl genrsa -out "$CA_KEY" 4096 -echo "Generated CA private key: $CA_KEY" +echo "Generating server certificate..." +openssl genrsa -out "$CERT_DIR/server.key" 4096 +openssl req -new -key "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" \ + -subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=MQTT/CN=${1:-localhost}" -# Generate CA certificate (valid for 10 years) -openssl req -new -x509 -days 3650 -key "$CA_KEY" -out "$CA_CERT" \ - -subj "/C=US/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA" -echo "Generated CA certificate: $CA_CERT" +echo "subjectAltName = @alt_names" > "$CERT_DIR/server.ext" +echo "[alt_names]" >> "$CERT_DIR/server.ext" -# Set secure permissions -chmod 600 "$CA_KEY" -chmod 644 "$CA_CERT" +INDEX=1 +for DOMAIN in $DOMAINS; do + if [[ $DOMAIN =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "IP.$INDEX = $DOMAIN" >> "$CERT_DIR/server.ext" + else + echo "DNS.$INDEX = $DOMAIN" >> "$CERT_DIR/server.ext" + fi + INDEX=$((INDEX + 1)) +done -echo "" -echo "CA Certificate Authority created successfully!" -echo "" -echo "CA Certificate Details:" -openssl x509 -in "$CA_CERT" -noout -text | grep -A 2 "Subject:" -echo "" -echo "Valid from:" -openssl x509 -in "$CA_CERT" -noout -startdate -echo "Valid until:" -openssl x509 -in "$CA_CERT" -noout -enddate +openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \ + -CAcreateserial -out "$CERT_DIR/server.crt" -days 365 -sha256 -extfile "$CERT_DIR/server.ext" + +rm "$CERT_DIR/server.csr" "$CERT_DIR/server.ext" "$CERT_DIR/ca.srl" +chmod 600 "$CERT_DIR/ca.key" "$CERT_DIR/server.key" +chmod 644 "$CERT_DIR/ca.crt" "$CERT_DIR/server.crt" + +echo "Done! Server cert valid for: $DOMAINS" diff --git a/services/gpt_service/gpt_service.py b/services/gpt_service/gpt_service.py index 272f5a8..76753c0 100644 --- a/services/gpt_service/gpt_service.py +++ b/services/gpt_service/gpt_service.py @@ -446,7 +446,6 @@ Keep all text concise: summary under 50 words, each item under 20 words.""", context = "\n\n".join(context_sections) - # Build briefing-specific prompts prompts = { "schedule": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals). Generate a Schedule Briefing focused on calendar and activity optimization. diff --git a/services/gpt_service/main.py b/services/gpt_service/main.py index 320fe56..b473026 100644 --- a/services/gpt_service/main.py +++ b/services/gpt_service/main.py @@ -15,12 +15,10 @@ gpt_service = None async def lifespan(app: FastAPI): """Lifespan event handler for startup and shutdown.""" global gpt_service - # Startup logger.info("Initializing GPT Service...") gpt_service = GPTService() logger.info("GPT Service initialized successfully") yield - # Shutdown (cleanup if needed) logger.info("Shutting down GPT Service...") @@ -96,10 +94,8 @@ async def analyze_telemetry(request: AnalyzeRequest): logger.info(f"Analyzing {len(request.telemetry_data)} telemetry points with prompt_type={request.prompt_type}") - # Convert Pydantic models to dicts for GPTService telemetry_dicts = [point.model_dump() for point in request.telemetry_data] - # Call GPT service analysis analysis_result = await gpt_service.analyze( telemetry_data=telemetry_dicts, device_info=request.device_info, @@ -133,7 +129,6 @@ async def generate_daily_briefing(request: DailyBriefingRequest): logger.info(f"Generating {request.briefing_type} briefing") - # Convert calendar events to dicts calendar_events = None if request.calendar_events: calendar_events = [event.model_dump() for event in request.calendar_events] diff --git a/services/mqtt_ingestion/README.md b/services/mqtt_ingestion/README.md index c149913..79b972c 100644 --- a/services/mqtt_ingestion/README.md +++ b/services/mqtt_ingestion/README.md @@ -1,3 +1,98 @@ -# MQTT Ingestion service +# MQTT Ingestion Service -This service ingests all the data from the MQTT topics and sends the data to Redis \ No newline at end of file +Subscribes to MQTT topics and writes telemetry data to Redis streams for downstream processing. + +## Purpose + +This service acts as the bridge between MQTT devices and the data pipeline. It: +- Connects to Mosquitto MQTT broker +- Subscribes to device topics using wildcard pattern +- Parses incoming messages +- Writes structured data to Redis stream + +## Architecture + +``` +MQTT Broker (port 8883) + | + v ++-------------------+ +| mqtt_ingestion | +| - MQTT subscriber | +| - Topic parser | +| - Redis writer | ++-------------------+ + | + v +Redis Stream: mqtt:ingestion +``` + +## Topic Format + +Devices publish to: `devices/{device_id}/{metric}` + +Examples: +- `devices/a1b2c3d4/temperature` - Temperature reading +- `devices/a1b2c3d4/humidity` - Humidity reading +- `devices/a1b2c3d4/heart_rate` - Health metric + +The service subscribes to `devices/#` to receive all device messages. + +## Redis Stream Format + +Each message written to `mqtt:ingestion` contains: + +| Field | Type | Description | +|-------|------|-------------| +| device_id | string | 8-character device identifier | +| metric | string | Metric name (temperature, humidity, etc.) | +| value | string | Metric value (stored as string) | +| timestamp | string | ISO 8601 timestamp | + +Example: +```json +{ + "device_id": "a1b2c3d4", + "metric": "temperature", + "value": "23.5", + "timestamp": "2025-01-15T10:30:00.000Z" +} +``` + +## Configuration + +Environment variables (`.env` file): + +| Variable | Description | Default | +|----------|-------------|---------| +| MQTT_HOST | MQTT broker hostname | localhost | +| MQTT_PORT | MQTT broker port | 8883 | +| REDIS_HOST | Redis hostname | localhost | +| REDIS_PORT | Redis port | 6379 | + +## Running + +```bash +cd services/mqtt_ingestion +uv sync +uv run main.py +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `main.py` | Entry point, service initialization | +| `src/mqtt_client.py` | MQTT connection and subscription logic | +| `src/redis_writer.py` | Redis stream writing | + +## Error Handling + +- Invalid topics (not matching `devices/{id}/{metric}`) are logged and dropped +- Connection failures trigger automatic reconnection +- Redis write failures are logged (messages may be lost) + +## Integration Points + +- **Upstream**: Mosquitto MQTT broker with mTLS +- **Downstream**: Redis stream consumed by db_write service \ No newline at end of file