mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 01:06:24 +00:00
Compare commits
7 Commits
8c699bd121
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1eb102e44 | ||
|
|
8fa4117ce0 | ||
| 4ce795228d | |||
| 6c0dc5f3d8 | |||
| 1a5bef277d | |||
| 3ab81fad8c | |||
| ab72c01999 |
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -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
|
||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
||||
# Django Backend Dockerfile
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
COPY iotDashboard/ ./iotDashboard/
|
||||
COPY manage.py ./
|
||||
COPY create_user.py ./
|
||||
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
|
||||
FROM python:3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
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/
|
||||
|
||||
RUN adduser -D -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"]
|
||||
107
create_user.py
Executable file
107
create_user.py
Executable file
@@ -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 SECRET_KEY
|
||||
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()
|
||||
7
db_migrations/.dockerignore
Normal file
7
db_migrations/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env*
|
||||
.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.log
|
||||
12
db_migrations/Dockerfile
Normal file
12
db_migrations/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN uv sync --no-cache
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uv", "run", "alembic", "upgrade", "head"]
|
||||
|
||||
@@ -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"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"
|
||||
|
||||
|
||||
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"<User(username={self.username}, email={self.email})>"
|
||||
|
||||
@@ -6,5 +6,7 @@ readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.17.0",
|
||||
"dotenv>=0.9.9",
|
||||
"sqlalchemy>=2.0.44",
|
||||
"psycopg2-binary>=2.9.10",
|
||||
]
|
||||
|
||||
13
frontend/.dockerignore
Normal file
13
frontend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
coverage
|
||||
32
frontend/Dockerfile
Normal file
32
frontend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL=/api
|
||||
ARG VITE_DEVICE_MANAGER_URL
|
||||
ARG VITE_MQTT_BROKER
|
||||
ARG VITE_MQTT_PORT=8883
|
||||
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_DEVICE_MANAGER_URL=$VITE_DEVICE_MANAGER_URL
|
||||
ENV VITE_MQTT_BROKER=$VITE_MQTT_BROKER
|
||||
ENV VITE_MQTT_PORT=$VITE_MQTT_PORT
|
||||
|
||||
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;"]
|
||||
@@ -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),
|
||||
});
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>iotDashboard</title>
|
||||
<title>Lyncis</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
||||
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;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
1043
frontend/package-lock.json
generated
1043
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,8 +10,24 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-grid-layout": "^1.3.6",
|
||||
"axios": "^1.13.2",
|
||||
"daisyui": "^5.5.5",
|
||||
"gridstack": "^12.3.3",
|
||||
"ical.js": "^2.2.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1"
|
||||
"react-dom": "^19.1.1",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
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 { useState } from 'react'
|
||||
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}</> : <Navigate to="/login" />
|
||||
}
|
||||
|
||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { logout } = useAuth()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="drawer lg:drawer-open">
|
||||
<div className="drawer lg:drawer-open min-h-screen">
|
||||
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
||||
<div className="drawer-content flex flex-col">
|
||||
<div className="drawer-content flex flex-col min-h-screen bg-base-200">
|
||||
{/* Navbar */}
|
||||
<div className="navbar bg-base-300 lg:hidden">
|
||||
<div className="flex-none">
|
||||
@@ -26,10 +38,15 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
<div className="flex-1">
|
||||
<span className="text-xl font-bold">IoT Dashboard</span>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
<button onClick={logout} className="btn btn-ghost btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 bg-base-200">
|
||||
<main className="flex-1 min-h-full">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
@@ -37,41 +54,73 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
{/* Sidebar */}
|
||||
<div className="drawer-side">
|
||||
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||
<aside className="bg-base-100 w-64 min-h-full">
|
||||
<div className="p-4">
|
||||
<Link to="/" className="flex items-center gap-2 text-2xl font-bold">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<aside className={`bg-base-100 min-h-screen flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-56'}`}>
|
||||
{/* Toggle button (desktop only) */}
|
||||
<div className="hidden lg:flex justify-end p-2">
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="btn btn-ghost btn-xs btn-square"
|
||||
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{sidebarCollapsed ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`${sidebarCollapsed ? 'px-2' : 'p-4'}`}>
|
||||
<Link to="/" className={`flex items-center gap-2 font-bold ${sidebarCollapsed ? 'justify-center' : 'text-2xl'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className={`${sidebarCollapsed ? 'h-6 w-6' : 'h-8 w-8'}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
<span>IoT Dashboard</span>
|
||||
{!sidebarCollapsed && <span>Lyncis</span>}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="menu p-4 space-y-2">
|
||||
<ul className={`menu space-y-2 ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/"
|
||||
className={({ isActive }) => isActive ? 'active' : ''}
|
||||
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||
data-tip="Dashboard"
|
||||
end
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
{!sidebarCollapsed && 'Dashboard'}
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/devices"
|
||||
className={({ isActive }) => isActive ? 'active' : ''}
|
||||
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||
data-tip="Devices"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Devices
|
||||
{!sidebarCollapsed && 'Devices'}
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div className={`mt-auto ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||
<button
|
||||
onClick={logout}
|
||||
className={`btn btn-ghost btn-sm w-full ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||
data-tip="Logout"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
{!sidebarCollapsed && 'Logout'}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,15 +130,21 @@ function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<Toaster position="top-right" />
|
||||
<Routes>
|
||||
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
|
||||
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
|
||||
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
|
||||
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
<AuthProvider>
|
||||
<WellnessStateProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster position="top-right" />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/devices" element={<ProtectedRoute><AppLayout><DeviceList /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/devices/add" element={<ProtectedRoute><AppLayout><AddDevice /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/devices/:id" element={<ProtectedRoute><AppLayout><DeviceDetail /></AppLayout></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</WellnessStateProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,6 +97,7 @@ export const telemetryApi = {
|
||||
analyze: (data: {
|
||||
device_id: string;
|
||||
metric?: string;
|
||||
metrics?: string[]; // Support multiple metrics
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
||||
@@ -146,3 +147,150 @@ export const weatherApi = {
|
||||
sensor_count: number;
|
||||
}>('/weather/air_quality/', { params: { city } }),
|
||||
};
|
||||
|
||||
// Dashboard Layout API
|
||||
export const dashboardLayoutApi = {
|
||||
getAll: () =>
|
||||
apiClient.get<Array<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>>('/dashboard-layouts/'),
|
||||
|
||||
getDefault: () =>
|
||||
apiClient.get<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>('/dashboard-layouts/default/'),
|
||||
|
||||
create: (data: {
|
||||
name: string
|
||||
config: any
|
||||
is_default?: boolean
|
||||
}) =>
|
||||
apiClient.post('/dashboard-layouts/', data),
|
||||
|
||||
update: (id: number, data: {
|
||||
name?: string
|
||||
config?: any
|
||||
is_default?: boolean
|
||||
}) =>
|
||||
apiClient.put(`/dashboard-layouts/${id}/`, data),
|
||||
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/dashboard-layouts/${id}/`),
|
||||
|
||||
setDefault: (id: number) =>
|
||||
apiClient.post(`/dashboard-layouts/${id}/set_default/`),
|
||||
}
|
||||
|
||||
// Wellness API
|
||||
export const wellnessApi = {
|
||||
getRunSuitability: (healthDeviceId: string, city: string, timeOfDay?: string) =>
|
||||
apiClient.get<{
|
||||
status: 'GO' | 'MODERATE' | 'NO';
|
||||
overall_score: number;
|
||||
scores: {
|
||||
weather: number;
|
||||
air_quality: number;
|
||||
health: number;
|
||||
};
|
||||
primary_reason: string;
|
||||
detailed_insights: string[];
|
||||
time_recommendations: string[];
|
||||
suggestions: string[];
|
||||
weather_data: {
|
||||
temperature: number;
|
||||
wind_speed: number;
|
||||
precipitation: number;
|
||||
description: string;
|
||||
};
|
||||
air_quality_data: {
|
||||
pm25: number | null;
|
||||
pm10: number | null;
|
||||
status: string;
|
||||
};
|
||||
health_data: {
|
||||
steps: number;
|
||||
active_calories: number;
|
||||
heart_rate: number | null;
|
||||
};
|
||||
}>('/wellness/run_suitability/', {
|
||||
params: {
|
||||
health_device_id: healthDeviceId,
|
||||
city,
|
||||
...(timeOfDay && { time_of_day: timeOfDay }),
|
||||
},
|
||||
}),
|
||||
|
||||
getHealthInsights: (healthDeviceId: string, city?: string) =>
|
||||
apiClient.get<{
|
||||
health_metrics: {
|
||||
steps: number;
|
||||
active_calories: number;
|
||||
heart_rate: number | null;
|
||||
resting_heart_rate: number | null;
|
||||
};
|
||||
environmental_context: {
|
||||
temperature: number | null;
|
||||
humidity: number | null;
|
||||
pm25: number | null;
|
||||
pm10: number | null;
|
||||
air_quality_status: string | null;
|
||||
weather_description: string | null;
|
||||
};
|
||||
insights: Array<{
|
||||
metric: string;
|
||||
value: number;
|
||||
context: string;
|
||||
correlation: string | null;
|
||||
recommendation: string | null;
|
||||
}>;
|
||||
correlations: string[];
|
||||
recommendations: string[];
|
||||
trend_indicators: string[];
|
||||
}>('/wellness/health_insights/', {
|
||||
params: {
|
||||
health_device_id: healthDeviceId,
|
||||
...(city && { city }),
|
||||
},
|
||||
}),
|
||||
|
||||
getDailyBriefing: (params: {
|
||||
briefing_type: 'schedule' | 'environment' | 'full';
|
||||
city: string;
|
||||
health_device_id?: string;
|
||||
calendar_url?: string;
|
||||
calendar_range_hours?: number;
|
||||
}) =>
|
||||
apiClient.post<{
|
||||
status_emoji: string;
|
||||
status_line: string;
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
briefing_type: string;
|
||||
generated_at: string;
|
||||
context: {
|
||||
indoor: Record<string, number> | null;
|
||||
outdoor: Record<string, number | string | null> | null;
|
||||
health: Record<string, number> | null;
|
||||
calendar_event_count: number;
|
||||
};
|
||||
}>('/wellness/daily_briefing/', params),
|
||||
};
|
||||
|
||||
// Calendar API
|
||||
export const calendarApi = {
|
||||
fetch: (calendarUrl: string, options?: { signal?: AbortSignal }) =>
|
||||
apiClient.get<{ calendar_data: string }>('/calendar/fetch/', {
|
||||
params: { calendar_url: calendarUrl },
|
||||
signal: options?.signal,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -18,8 +18,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||
const [widgetHeight, setWidgetHeight] = useState(3)
|
||||
const [city, setCity] = useState('Skopje')
|
||||
const [calendarUrl, setCalendarUrl] = useState('')
|
||||
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
|
||||
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
|
||||
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
|
||||
|
||||
// Fetch devices
|
||||
const { data: devicesData } = useQuery({
|
||||
@@ -44,6 +48,10 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
const devices = devicesData?.results || []
|
||||
const availableMetrics = deviceMetricsData || []
|
||||
|
||||
// Validation helpers
|
||||
const needsDevice = !['weather', 'air-quality', 'calendar', 'daily-briefing'].includes(widgetType)
|
||||
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widgetType)
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
@@ -54,7 +62,11 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
setSelectedMetrics([])
|
||||
setTimeframeHours(24)
|
||||
setWidgetWidth(1)
|
||||
setWidgetHeight(2)
|
||||
setWidgetHeight(3)
|
||||
setCalendarUrl('')
|
||||
setShowCalendarUrl(false)
|
||||
setCalendarRangeHours(72)
|
||||
setBriefingType('full')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
@@ -64,12 +76,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
}, [selectedDevices])
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Weather and air-quality widgets don't need device/metric validation
|
||||
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
|
||||
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
||||
alert('Please select at least one device and one metric')
|
||||
return
|
||||
}
|
||||
// Validation
|
||||
if (needsDevice && selectedDevices.length === 0) {
|
||||
alert('Please select a device')
|
||||
return
|
||||
}
|
||||
|
||||
if (needsMetrics && selectedMetrics.length === 0) {
|
||||
alert('Please select at least one metric')
|
||||
return
|
||||
}
|
||||
|
||||
const wellnessWidgets = ['run-suitability', 'health-stats']
|
||||
if (wellnessWidgets.includes(widgetType) && !city) {
|
||||
alert('Please enter a city for wellness widgets')
|
||||
return
|
||||
}
|
||||
|
||||
if (widgetType === 'calendar' && !calendarUrl.trim()) {
|
||||
alert('Please provide an iCal URL')
|
||||
return
|
||||
}
|
||||
|
||||
if (widgetType === 'daily-briefing' && !city) {
|
||||
alert('Please enter a city for daily briefing')
|
||||
return
|
||||
}
|
||||
|
||||
// Create title
|
||||
@@ -78,6 +109,16 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
defaultTitle = `Weather - ${city}`
|
||||
} else if (widgetType === 'air-quality') {
|
||||
defaultTitle = `Air Quality - ${city}`
|
||||
} else if (widgetType === 'run-suitability') {
|
||||
defaultTitle = `Run Suitability - ${city}`
|
||||
} else if (widgetType === 'health-stats') {
|
||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||
defaultTitle = `Health Stats - ${selectedDevice?.name || 'Device'}`
|
||||
} else if (widgetType === 'calendar') {
|
||||
defaultTitle = 'Calendar Agenda'
|
||||
} else if (widgetType === 'daily-briefing') {
|
||||
const briefingLabels = { schedule: 'Schedule', environment: 'Environment', full: 'Full' }
|
||||
defaultTitle = `${briefingLabels[briefingType]} Briefing`
|
||||
} else {
|
||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
||||
@@ -87,8 +128,8 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
id: `widget-${Date.now()}`,
|
||||
type: widgetType,
|
||||
title: title || defaultTitle,
|
||||
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
|
||||
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
|
||||
deviceIds: needsDevice ? selectedDevices : [],
|
||||
metricIds: needsMetrics ? selectedMetrics : [],
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
@@ -96,13 +137,24 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
showLegend: true,
|
||||
showGrid: true,
|
||||
height: widgetType === 'line-chart' ? 300 : undefined,
|
||||
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
|
||||
city: (widgetType === 'weather' || widgetType === 'air-quality' || wellnessWidgets.includes(widgetType) || widgetType === 'daily-briefing') ? city : undefined,
|
||||
},
|
||||
calendar: widgetType === 'calendar' ? {
|
||||
icalUrl: calendarUrl.trim(),
|
||||
timeRangeHours: calendarRangeHours,
|
||||
} : undefined,
|
||||
briefing: widgetType === 'daily-briefing' ? {
|
||||
briefingType,
|
||||
city,
|
||||
healthDeviceId: selectedDevices[0],
|
||||
calendarUrl: calendarUrl.trim() || undefined,
|
||||
calendarRangeHours: calendarRangeHours,
|
||||
} : undefined,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
h: ['run-suitability', 'health-stats', 'daily-briefing'].includes(widgetType) ? 2 : widgetHeight,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -249,6 +301,58 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
<div className="text-xs opacity-70">Multi-factor</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'run-suitability' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('run-suitability')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Run Suitability</div>
|
||||
<div className="text-xs opacity-70">Go / No Go</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'health-stats' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('health-stats')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Health Stats</div>
|
||||
<div className="text-xs opacity-70">Steps & HR</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'calendar' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('calendar')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10m-12 8h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Calendar</div>
|
||||
<div className="text-xs opacity-70">Agenda (iCal)</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'daily-briefing' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('daily-briefing')}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Daily Briefing</div>
|
||||
<div className="text-xs opacity-70">AI Summary</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +364,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
// Skip data source step for weather and air quality widgets
|
||||
if (widgetType === 'weather' || widgetType === 'air-quality') {
|
||||
if (widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') {
|
||||
setStep(3)
|
||||
} else {
|
||||
setStep(2)
|
||||
@@ -274,8 +378,29 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
)}
|
||||
|
||||
{/* Step 2: Data Source */}
|
||||
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
|
||||
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && widgetType !== 'calendar' && widgetType !== 'daily-briefing' && (
|
||||
<div className="space-y-4">
|
||||
{/* City input for wellness widgets */}
|
||||
{['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City (for weather/air quality)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">
|
||||
Used for weather and air quality context
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Device</span>
|
||||
@@ -305,48 +430,53 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||
</label>
|
||||
{selectedDevices.length === 0 ? (
|
||||
<div className="border rounded-lg p-4 text-center text-base-content/60">
|
||||
Please select a device first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(['stat', 'gauge'].includes(widgetType)) && (
|
||||
<div className="alert alert-info mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">This widget type supports only one metric</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
No metrics found for this device
|
||||
</div>
|
||||
) : (
|
||||
availableMetrics.map((metric) => (
|
||||
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
|
||||
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
|
||||
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onChange={() => toggleMetric(metric)}
|
||||
/>
|
||||
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
|
||||
|
||||
{/* Metric Selection - Skip for widgets with hardcoded metrics */}
|
||||
{!['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||
</label>
|
||||
{selectedDevices.length === 0 ? (
|
||||
<div className="border rounded-lg p-4 text-center text-base-content/60">
|
||||
Please select a device first
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(['stat', 'gauge'].includes(widgetType)) && (
|
||||
<div className="alert alert-info mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">This widget type supports only one metric</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
No metrics found for this device
|
||||
</div>
|
||||
) : (
|
||||
availableMetrics.map((metric) => (
|
||||
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
|
||||
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
|
||||
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onChange={() => toggleMetric(metric)}
|
||||
/>
|
||||
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(1)}>
|
||||
@@ -354,8 +484,12 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
|
||||
onClick={() => setStep(3)}
|
||||
disabled={selectedDevices.length === 0 || selectedMetrics.length === 0}
|
||||
disabled={
|
||||
(needsDevice && selectedDevices.length === 0) ||
|
||||
(needsMetrics && selectedMetrics.length === 0)
|
||||
}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
@@ -402,6 +536,194 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : widgetType === 'calendar' ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">iCal URL</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Link is saved with the widget configuration</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Agenda Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Calendar Agenda"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : widgetType === 'daily-briefing' ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Briefing Type</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('schedule')}
|
||||
>
|
||||
📅 Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('environment')}
|
||||
>
|
||||
🌡️ Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('full')}
|
||||
>
|
||||
✨ Full
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">
|
||||
{briefingType === 'schedule' && 'Focus on your calendar and daily activity'}
|
||||
{briefingType === 'environment' && 'Focus on indoor/outdoor conditions for productivity'}
|
||||
{briefingType === 'full' && 'Complete briefing with all insights combined'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City (for weather/air quality)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(briefingType === 'schedule' || briefingType === 'full') && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar URL (Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Optional: Include calendar events in your briefing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{calendarUrl && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Health Device (Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDevices[0] || ''}
|
||||
onChange={(e) => setSelectedDevices(e.target.value ? [e.target.value] : [])}
|
||||
>
|
||||
<option value="">No health device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Optional: Include step count and activity data</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder={`${briefingType.charAt(0).toUpperCase() + briefingType.slice(1)} Briefing`}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// Original configuration for sensor-based widgets
|
||||
<>
|
||||
@@ -478,14 +800,23 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">Widget Summary</div>
|
||||
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
|
||||
<div>Device: {devices.find(d => d.id === selectedDevices[0])?.name}</div>
|
||||
<div>Metrics: {selectedMetrics.join(', ')}</div>
|
||||
<div>Device: {needsDevice ? (devices.find(d => d.id === selectedDevices[0])?.name || 'Not selected') : 'Not required'}</div>
|
||||
<div>Metrics: {needsMetrics ? (selectedMetrics.join(', ') || 'Not selected') : 'Not required'}</div>
|
||||
<div>Size: {widgetWidth} × {widgetHeight}</div>
|
||||
{widgetType === 'calendar' && (
|
||||
<div>Range: Next {calendarRangeHours}h</div>
|
||||
)}
|
||||
{widgetType === 'daily-briefing' && (
|
||||
<div>Briefing: {briefingType.charAt(0).toUpperCase() + briefingType.slice(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button className="btn btn-ghost" onClick={() => setStep(2)}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setStep((widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') ? 1 : 2)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||
|
||||
@@ -86,6 +86,87 @@ export default function CredentialsViewer({ credentials, deviceId }: Credentials
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MQTT Connection Information */}
|
||||
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-2">Connection Configuration</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">MQTT Broker:</span>
|
||||
<code className="bg-base-100 px-2 py-1 rounded text-xs">{mqttBroker}</code>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Port (TLS):</span>
|
||||
<code className="bg-base-100 px-2 py-1 rounded text-xs">{mqttPort}</code>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium">Device ID:</span>
|
||||
<code className="bg-base-100 px-2 py-1 rounded text-xs">{resolvedDeviceId}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Publishing Guide */}
|
||||
<div className="rounded-lg bg-primary/10 border border-primary/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-2">How to Publish Data</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div>
|
||||
<span className="font-medium block mb-1">Topic Format:</span>
|
||||
<code className="block bg-base-100 px-3 py-2 rounded text-xs break-all">
|
||||
devices/{resolvedDeviceId}/{'<metric>'}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium block mb-1">Payload:</span>
|
||||
<p className="opacity-80 text-xs mb-1">Send numeric value as plain text (no JSON)</p>
|
||||
<code className="block bg-base-100 px-3 py-2 rounded text-xs">23.5</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium block mb-1">Supported Metrics:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{['temperature', 'humidity', 'co2', 'pm25', 'pm10', 'pressure', 'light', 'noise'].map(metric => (
|
||||
<span key={metric} className="badge badge-sm badge-outline">{metric}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divider my-2"></div>
|
||||
|
||||
<div>
|
||||
<span className="font-medium block mb-1">Example (mosquitto_pub):</span>
|
||||
<code className="block bg-base-100 px-3 py-2 rounded text-xs overflow-x-auto">
|
||||
mosquitto_pub --cafile ca.crt --cert {resolvedDeviceId}.crt --key {resolvedDeviceId}.key \<br/>
|
||||
-h {mqttBroker} -p {mqttPort} \<br/>
|
||||
-t "devices/{resolvedDeviceId}/temperature" -m "23.5"
|
||||
</code>
|
||||
<button
|
||||
className="btn btn-xs btn-outline mt-2"
|
||||
onClick={() => copyToClipboard(
|
||||
`mosquitto_pub --cafile ca.crt --cert ${resolvedDeviceId}.crt --key ${resolvedDeviceId}.key -h ${mqttBroker} -p ${mqttPort} -t "devices/${resolvedDeviceId}/temperature" -m "23.5"`,
|
||||
'Example command'
|
||||
)}
|
||||
>
|
||||
Copy Command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(credentials.certificate_id || expiresAt) && (
|
||||
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -12,44 +12,140 @@ interface EditWidgetModalProps {
|
||||
|
||||
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [selectedDeviceId, setSelectedDeviceId] = useState('')
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||
const [calendarUrl, setCalendarUrl] = useState('')
|
||||
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
|
||||
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
|
||||
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
|
||||
const [city, setCity] = useState('Skopje')
|
||||
|
||||
// Fetch metrics for the widget's device
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', widget?.deviceIds[0]],
|
||||
// Fetch all devices
|
||||
const { data: devicesData } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: async () => {
|
||||
if (!widget?.deviceIds[0]) return []
|
||||
const response = await devicesApi.getMetrics(widget.deviceIds[0])
|
||||
return response.data.metrics
|
||||
const response = await devicesApi.getAll()
|
||||
return response.data.results
|
||||
},
|
||||
enabled: !!widget?.deviceIds[0] && isOpen,
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
const availableMetrics = deviceMetricsData || []
|
||||
// Fetch metrics for the selected device
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', selectedDeviceId],
|
||||
queryFn: async () => {
|
||||
if (!selectedDeviceId) return []
|
||||
const response = await devicesApi.getMetrics(selectedDeviceId)
|
||||
return response.data.metrics
|
||||
},
|
||||
enabled: !!selectedDeviceId && isOpen,
|
||||
})
|
||||
|
||||
const availableMetrics = Array.isArray(deviceMetricsData) ? deviceMetricsData : []
|
||||
const devices = Array.isArray(devicesData) ? devicesData : []
|
||||
|
||||
// Load widget data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && widget) {
|
||||
setTitle(widget.title || '')
|
||||
setSelectedDeviceId(widget.deviceIds[0] || widget.briefing?.healthDeviceId || '')
|
||||
setSelectedMetrics(widget.metricIds || [])
|
||||
setTimeframeHours(widget.timeframe?.hours || 24)
|
||||
setWidgetWidth(widget.position?.w || 1)
|
||||
setWidgetHeight(widget.position?.h || 2)
|
||||
setCalendarUrl(widget.calendar?.icalUrl || widget.briefing?.calendarUrl || '')
|
||||
setCalendarRangeHours(widget.calendar?.timeRangeHours || widget.briefing?.calendarRangeHours || 72)
|
||||
setShowCalendarUrl(false)
|
||||
setBriefingType((widget.briefing?.briefingType as 'schedule' | 'environment' | 'full') || 'full')
|
||||
setCity(widget.visualization?.city || widget.briefing?.city || 'Skopje')
|
||||
}
|
||||
}, [isOpen, widget])
|
||||
|
||||
// Reset metrics when device changes
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && widget && selectedDeviceId !== widget.deviceIds[0]) {
|
||||
setSelectedMetrics([])
|
||||
}
|
||||
}, [selectedDeviceId, widget])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!widget || selectedMetrics.length === 0) {
|
||||
if (!widget) return
|
||||
|
||||
const needsDevice = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
|
||||
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
|
||||
|
||||
if (widget.type === 'calendar') {
|
||||
if (!calendarUrl.trim()) {
|
||||
alert('Please provide an iCal URL')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
calendar: {
|
||||
icalUrl: calendarUrl.trim(),
|
||||
timeRangeHours: calendarRangeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (widget.type === 'daily-briefing') {
|
||||
if (!city.trim()) {
|
||||
alert('Please enter a city')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
visualization: {
|
||||
...widget.visualization,
|
||||
city,
|
||||
},
|
||||
briefing: {
|
||||
briefingType,
|
||||
city,
|
||||
healthDeviceId: selectedDeviceId || undefined,
|
||||
calendarUrl: calendarUrl.trim() || undefined,
|
||||
calendarRangeHours: calendarRangeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (needsDevice && (!selectedDeviceId)) {
|
||||
alert('Please select a device')
|
||||
return
|
||||
}
|
||||
|
||||
if (needsMetrics && selectedMetrics.length === 0) {
|
||||
alert('Please select at least one metric')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
metricIds: selectedMetrics,
|
||||
deviceIds: needsDevice ? [selectedDeviceId] : [],
|
||||
metricIds: needsMetrics ? selectedMetrics : [],
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
@@ -102,6 +198,27 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
<div className="badge badge-lg badge-primary">{widget.type}</div>
|
||||
</div>
|
||||
|
||||
{/* Device Selection */}
|
||||
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Device</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
@@ -117,58 +234,216 @@ export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: Edi
|
||||
</div>
|
||||
|
||||
{/* Metrics */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||
</label>
|
||||
{(['stat', 'gauge'].includes(widget.type)) && (
|
||||
<div className="alert alert-info mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">This widget type supports only one metric</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Loading metrics...
|
||||
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||
</label>
|
||||
{(['stat', 'gauge'].includes(widget.type)) && (
|
||||
<div className="alert alert-info mb-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<span className="text-sm">This widget type supports only one metric</span>
|
||||
</div>
|
||||
) : (
|
||||
availableMetrics.map((metric) => (
|
||||
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
|
||||
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
|
||||
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onChange={() => toggleMetric(metric)}
|
||||
/>
|
||||
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||
{!selectedDeviceId ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Please select a device first
|
||||
</div>
|
||||
) : availableMetrics.length === 0 ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Loading metrics...
|
||||
</div>
|
||||
) : (
|
||||
availableMetrics.map((metric) => (
|
||||
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||
<input
|
||||
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
|
||||
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
|
||||
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||
checked={selectedMetrics.includes(metric)}
|
||||
onChange={() => toggleMetric(metric)}
|
||||
/>
|
||||
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={timeframeHours}
|
||||
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={168}>Last 7 days</option>
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
{!['calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Time Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={timeframeHours}
|
||||
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={1}>Last 1 hour</option>
|
||||
<option value={6}>Last 6 hours</option>
|
||||
<option value={24}>Last 24 hours</option>
|
||||
<option value={168}>Last 7 days</option>
|
||||
<option value={720}>Last 30 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{widget.type === 'calendar' && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">iCal URL</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Agenda Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{widget.type === 'daily-briefing' && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Briefing Type</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('schedule')}
|
||||
>
|
||||
📅 Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('environment')}
|
||||
>
|
||||
🌡️ Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('full')}
|
||||
>
|
||||
✨ Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(briefingType === 'schedule' || briefingType === 'full') && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar URL (Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calendarUrl && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Health Device (Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDeviceId || ''}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">No health device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Size */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
@@ -7,13 +7,45 @@ interface AiInsightWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface TrendSummary {
|
||||
status: 'excellent' | 'good' | 'fair' | 'poor'
|
||||
summary: string
|
||||
trends: Array<{
|
||||
metric: string
|
||||
direction: 'improving' | 'stable' | 'degrading'
|
||||
description: string
|
||||
}>
|
||||
comfort_score: {
|
||||
rating: number
|
||||
description: string
|
||||
}
|
||||
patterns: string[]
|
||||
recommendations: string[]
|
||||
forecast: string
|
||||
}
|
||||
|
||||
interface AnomalyDetection {
|
||||
status: 'normal' | 'warning' | 'critical'
|
||||
summary: string
|
||||
anomalies: Array<{
|
||||
metric: string
|
||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||
description: string
|
||||
value: string
|
||||
expected: string
|
||||
}>
|
||||
impacts: string[]
|
||||
actions: string[]
|
||||
root_causes: string[]
|
||||
}
|
||||
|
||||
export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
const metrics = metricIds // Use ALL selected metrics
|
||||
|
||||
const {
|
||||
data: analysis,
|
||||
@@ -21,11 +53,11 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
||||
queryKey: ['ai-insight', deviceId, metrics, promptType, timeframe],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.analyze({
|
||||
device_id: deviceId,
|
||||
metric,
|
||||
metrics: metrics, // Send all metrics
|
||||
hours: timeframe.hours || 240,
|
||||
limit: 200,
|
||||
prompt_type: promptType,
|
||||
@@ -40,6 +72,52 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
refetch()
|
||||
}
|
||||
|
||||
// Parse JSON analysis if it's a string
|
||||
const parsedAnalysis = analysis?.analysis ? (() => {
|
||||
try {
|
||||
return typeof analysis.analysis === 'string'
|
||||
? JSON.parse(analysis.analysis)
|
||||
: analysis.analysis
|
||||
} catch {
|
||||
return null // If parsing fails, return null to show raw text
|
||||
}
|
||||
})() : null
|
||||
|
||||
const isTrendSummary = promptType === 'trend_summary' && parsedAnalysis
|
||||
const isAnomalyDetection = promptType === 'anomaly_detection' && parsedAnalysis
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent': return 'badge-success'
|
||||
case 'good': return 'badge-info'
|
||||
case 'fair': return 'badge-warning'
|
||||
case 'poor': return 'badge-error'
|
||||
case 'normal': return 'badge-success'
|
||||
case 'warning': return 'badge-warning'
|
||||
case 'critical': return 'badge-error'
|
||||
default: return 'badge-ghost'
|
||||
}
|
||||
}
|
||||
|
||||
const getDirectionIcon = (direction: string) => {
|
||||
switch (direction) {
|
||||
case 'improving': return '↑'
|
||||
case 'degrading': return '↓'
|
||||
case 'stable': return '→'
|
||||
default: return '•'
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'badge-error'
|
||||
case 'high': return 'badge-warning'
|
||||
case 'medium': return 'badge-warning badge-outline'
|
||||
case 'low': return 'badge-info'
|
||||
default: return 'badge-ghost'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
@@ -99,20 +177,239 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
|
||||
{analysis && showAnalysis && !isLoading && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="badge badge-primary badge-lg">
|
||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points analyzed
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider my-2"></div>
|
||||
<div className="prose max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
|
||||
{analysis.analysis}
|
||||
</div>
|
||||
</div>
|
||||
{/* Structured Display for Trend Summary */}
|
||||
{isTrendSummary && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comfort Score */}
|
||||
{parsedAnalysis.comfort_score && (
|
||||
<div className="card bg-base-200">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="radial-progress text-primary" style={{ "--value": parsedAnalysis.comfort_score.rating } as React.CSSProperties}>
|
||||
{parsedAnalysis.comfort_score.rating}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Comfort Score</div>
|
||||
<div className="text-sm text-base-content/70">{parsedAnalysis.comfort_score.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trends */}
|
||||
{parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Trends</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{getDirectionIcon(trend.direction)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">{trend.metric}</div>
|
||||
<div className="text-xs text-base-content/70">{trend.description}</div>
|
||||
</div>
|
||||
<div className="badge badge-sm">{trend.direction}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patterns */}
|
||||
{parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Patterns Detected</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.patterns.map((pattern: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">▸</span>
|
||||
<span>{pattern}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommendations</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.recommendations.map((rec: string, i: number) => (
|
||||
<div key={i} className="alert alert-success alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm">{rec}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecast */}
|
||||
{parsedAnalysis.forecast && (
|
||||
<div className="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold text-sm">Forecast</div>
|
||||
<div className="text-xs">{parsedAnalysis.forecast}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Structured Display for Anomaly Detection */}
|
||||
{isAnomalyDetection && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`alert ${parsedAnalysis.status === 'critical' ? 'alert-error' : parsedAnalysis.status === 'warning' ? 'alert-warning' : 'alert-success'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies */}
|
||||
{parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Anomalies Detected</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200 border-l-4 border-error">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{anomaly.metric}</span>
|
||||
<span className={`badge badge-sm ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-base-content/70 mb-1">{anomaly.description}</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-error font-semibold">Current: {anomaly.value}</span>
|
||||
{' • '}
|
||||
<span className="text-base-content/60">Expected: {anomaly.expected}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Impacts */}
|
||||
{parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Potential Impacts</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.impacts.map((impact: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-warning mt-0.5">▸</span>
|
||||
<span>{impact}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommended Actions</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.actions.map((action: string, i: number) => (
|
||||
<div key={i} className="alert alert-warning alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-sm">{action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Causes */}
|
||||
{parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Possible Root Causes</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.root_causes.map((cause: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-info mt-0.5">▸</span>
|
||||
<span>{cause}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback: Raw Text Display */}
|
||||
{!parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="badge badge-primary badge-lg">
|
||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points analyzed
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider my-2"></div>
|
||||
<div className="prose max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
|
||||
{analysis.analysis}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
const response = await weatherApi.getAirQuality(city)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
|
||||
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AxiosError } from 'axios'
|
||||
import ICAL from 'ical.js'
|
||||
import { calendarApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAX_EVENTS = 25
|
||||
|
||||
interface CalendarWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string
|
||||
summary: string
|
||||
start: Date
|
||||
end: Date
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
isAllDay: boolean
|
||||
}
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function formatDayLabel(date: Date) {
|
||||
const today = new Date()
|
||||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffMs = startOfDate.getTime() - startOfToday.getTime()
|
||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (Math.abs(diffDays) <= 1) {
|
||||
return relativeFormatter.format(diffDays, 'day')
|
||||
}
|
||||
|
||||
return dayFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatEventRange(event: AgendaEvent) {
|
||||
const sameDay = event.start.toDateString() === event.end.toDateString()
|
||||
const dayLabel = formatDayLabel(event.start)
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dayLabel} - All day`
|
||||
}
|
||||
|
||||
if (sameDay) {
|
||||
return `${dayLabel} - ${timeFormatter.format(event.start)} to ${timeFormatter.format(event.end)}`
|
||||
}
|
||||
|
||||
return `${dayLabel} ${timeFormatter.format(event.start)} -> ${formatDayLabel(event.end)} ${timeFormatter.format(event.end)}`
|
||||
}
|
||||
|
||||
function createAgendaEvents(
|
||||
component: ICAL.Component,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
): AgendaEvent[] {
|
||||
const event = new ICAL.Event(component)
|
||||
const results: AgendaEvent[] = []
|
||||
const eventTemplate = {
|
||||
summary: event.summary || 'Untitled event',
|
||||
location: event.location || null,
|
||||
description: event.description || null,
|
||||
}
|
||||
|
||||
const addOccurrence = (start: ICAL.Time, end?: ICAL.Time | null) => {
|
||||
const jsStart = start.toJSDate()
|
||||
const jsEnd = (end || start).toJSDate()
|
||||
|
||||
if (jsEnd < windowStart || jsStart > windowEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `${event.uid || event.summary}-${jsStart.toISOString()}`,
|
||||
summary: eventTemplate.summary,
|
||||
location: eventTemplate.location,
|
||||
description: eventTemplate.description,
|
||||
start: jsStart,
|
||||
end: jsEnd,
|
||||
isAllDay: start.isDate,
|
||||
})
|
||||
}
|
||||
|
||||
const overlapWindowStart = new Date(windowStart.getTime() - 24 * 60 * 60 * 1000)
|
||||
const iteratorStart = ICAL.Time.fromJSDate(overlapWindowStart)
|
||||
|
||||
if (event.isRecurring()) {
|
||||
const iterator = event.iterator(iteratorStart)
|
||||
let next = iterator.next()
|
||||
while (next) {
|
||||
const occurrence = event.getOccurrenceDetails(next)
|
||||
addOccurrence(occurrence.startDate, occurrence.endDate)
|
||||
const jsStart = occurrence.startDate.toJSDate()
|
||||
if (jsStart > windowEnd) {
|
||||
break
|
||||
}
|
||||
next = iterator.next()
|
||||
}
|
||||
} else {
|
||||
addOccurrence(event.startDate, event.endDate)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ config }: CalendarWidgetProps) {
|
||||
const calendarConfig = config.calendar
|
||||
const [events, setEvents] = useState<AgendaEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
if (!calendarConfig?.icalUrl) {
|
||||
setEvents([])
|
||||
setError('Calendar URL is missing')
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await calendarApi.fetch(calendarConfig.icalUrl, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const raw = response.data?.calendar_data
|
||||
if (!raw) {
|
||||
throw new Error('Calendar feed was empty')
|
||||
}
|
||||
const parsed = ICAL.parse(raw)
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevents = comp.getAllSubcomponents('vevent') || []
|
||||
|
||||
const now = new Date()
|
||||
const windowStart = new Date(now.getTime() - 30 * 60 * 1000) // keep events that started recently
|
||||
const windowEnd = new Date(now.getTime() + (calendarConfig.timeRangeHours || 72) * 60 * 60 * 1000)
|
||||
|
||||
const agendaEvents = vevents
|
||||
.flatMap((vevent) => createAgendaEvents(vevent, windowStart, windowEnd))
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
.slice(0, MAX_EVENTS)
|
||||
|
||||
setEvents(agendaEvents)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const axiosError = err as AxiosError<{ error?: string; detail?: string }>
|
||||
const message = axiosError?.response?.data?.error
|
||||
|| axiosError?.response?.data?.detail
|
||||
|| axiosError?.message
|
||||
|| 'Failed to load calendar'
|
||||
setError(message)
|
||||
setEvents([])
|
||||
} finally {
|
||||
if (!controller.signal.aborted && requestId === requestIdRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [calendarConfig?.icalUrl, calendarConfig?.timeRangeHours])
|
||||
|
||||
useEffect(() => {
|
||||
if (!calendarConfig?.icalUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchEvents()
|
||||
const interval = setInterval(fetchEvents, REFRESH_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [calendarConfig?.icalUrl, fetchEvents])
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
fetchEvents()
|
||||
}
|
||||
|
||||
if (!calendarConfig) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-4 text-sm">
|
||||
<h2 className="card-title text-sm mb-2">{config.title || 'Calendar'}</h2>
|
||||
<p className="opacity-70">
|
||||
Configure an iCal URL to see your agenda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rangeLabel = `Next ${calendarConfig.timeRangeHours || 72}h`
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-3 h-full flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide opacity-60">Agenda</p>
|
||||
<h2 className="card-title text-sm leading-tight">{config.title || 'Calendar'}</h2>
|
||||
<p className="text-xs opacity-60">{rangeLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
{lastUpdated && (
|
||||
<span className="text-[10px] opacity-60">
|
||||
Updated {timeFormatter.format(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && events.length === 0 && !isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center text-sm opacity-60 text-center">
|
||||
No upcoming events in this window.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && events.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="loading loading-spinner"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ul className="flex-1 overflow-y-auto divide-y divide-base-200">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{event.summary || 'Untitled event'}
|
||||
</p>
|
||||
<p className="text-xs opacity-70">
|
||||
{formatEventRange(event)}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="text-[11px] opacity-70 mt-1 flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 11c1.38 0 2.5-1.12 2.5-2.5S13.38 6 12 6s-2.5 1.12-2.5 2.5S10.62 11 12 11zm0 0c-4 0-5 4-5 4v.5a2.5 2.5 0 002.5 2.5h5a2.5 2.5 0 002.5-2.5V15s-1-4-5-4z"
|
||||
/>
|
||||
</svg>
|
||||
{event.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-right whitespace-nowrap">
|
||||
{event.isAllDay ? 'All day' : timeFormatter.format(event.start)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,61 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { devicesApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
type IconProps = { className?: string }
|
||||
|
||||
const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ThermometerIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
|
||||
<line x1="12" y1="8" x2="12" y2="11" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const DropletIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const AirQualityIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M4 12h9a3 3 0 10-3-3" />
|
||||
<path d="M6 17h8a3 3 0 11-3 3" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const AcousticIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 9v6h3l4 4V5l-4 4H5z" />
|
||||
<path d="M16 9a4 4 0 010 6" />
|
||||
<path d="M18 7a6 6 0 010 10" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const LightIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3a5 5 0 00-3 9v3h6v-3a5 5 0 00-3-9z" />
|
||||
<path d="M10 18h4" />
|
||||
<path d="M10 21h4" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
interface ComfortIndexWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
@@ -16,7 +69,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
const response = await devicesApi.getComfortIndex(deviceId)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
enabled: !!deviceId,
|
||||
})
|
||||
|
||||
@@ -85,7 +138,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌡️</span>
|
||||
<ThermometerIcon className="w-4 h-4" />
|
||||
<span className="truncate">Temperature</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
|
||||
@@ -95,7 +148,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">💧</span>
|
||||
<DropletIcon className="w-4 h-4" />
|
||||
<span className="truncate">Humidity</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
|
||||
@@ -105,7 +158,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🌬️</span>
|
||||
<AirQualityIcon className="w-4 h-4" />
|
||||
<span className="truncate">Air Quality</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
|
||||
@@ -115,7 +168,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">🔊</span>
|
||||
<AcousticIcon className="w-4 h-4" />
|
||||
<span className="truncate">Acoustic</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
|
||||
@@ -125,7 +178,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm">💡</span>
|
||||
<LightIcon className="w-4 h-4" />
|
||||
<span className="truncate">Lighting</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
||||
@@ -137,7 +190,10 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps)
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-auto">
|
||||
<div className="text-xs font-semibold mb-1">💡 Suggestions</div>
|
||||
<div className="flex items-center gap-1 text-xs font-semibold mb-1">
|
||||
<LightIcon className="w-3.5 h-3.5" />
|
||||
<span>Suggestions</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||
{data.suggestions.map((suggestion, i) => (
|
||||
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
||||
|
||||
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { wellnessApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
interface DailyBriefingWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface BriefingData {
|
||||
status_emoji: string
|
||||
status_line: string
|
||||
insights: string[]
|
||||
recommendations: string[]
|
||||
briefing_type: string
|
||||
generated_at: string
|
||||
context: {
|
||||
indoor: Record<string, number> | null
|
||||
outdoor: Record<string, number | string | null> | null
|
||||
health: Record<string, number> | null
|
||||
calendar_event_count: number
|
||||
}
|
||||
}
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BuildingIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ClipboardIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const LightbulbIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const StatusIcon = ({ status }: { status: string }) => {
|
||||
if (status.includes('good') || status.includes('great') || status.includes('optimal')) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
if (status.includes('warning') || status.includes('moderate') || status.includes('attention')) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const BRIEFING_TYPES = [
|
||||
{ value: 'schedule', label: 'Schedule', Icon: CalendarIcon, description: 'Calendar & activity focus' },
|
||||
{ value: 'environment', label: 'Environment', Icon: BuildingIcon, description: 'Workspace conditions' },
|
||||
{ value: 'full', label: 'Full', Icon: ClipboardIcon, description: 'Complete overview' },
|
||||
] as const
|
||||
|
||||
type BriefingType = typeof BRIEFING_TYPES[number]['value']
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export default function DailyBriefingWidget({ config }: DailyBriefingWidgetProps) {
|
||||
const briefingConfig = config.briefing
|
||||
const [briefingType, setBriefingType] = useState<BriefingType>(
|
||||
(briefingConfig?.briefingType as BriefingType) || 'full'
|
||||
)
|
||||
const [briefingData, setBriefingData] = useState<BriefingData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const city = config.visualization?.city || briefingConfig?.city || 'Skopje'
|
||||
const healthDeviceId = config.deviceIds?.[0] || briefingConfig?.healthDeviceId
|
||||
const calendarUrl = briefingConfig?.calendarUrl
|
||||
|
||||
const generateBriefing = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await wellnessApi.getDailyBriefing({
|
||||
briefing_type: briefingType,
|
||||
city,
|
||||
health_device_id: healthDeviceId,
|
||||
calendar_url: calendarUrl,
|
||||
calendar_range_hours: briefingConfig?.calendarRangeHours || 24,
|
||||
})
|
||||
|
||||
setBriefingData(response.data)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate briefing'
|
||||
setError(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [briefingType, city, healthDeviceId, calendarUrl, briefingConfig?.calendarRangeHours])
|
||||
|
||||
const getBriefingTypeInfo = (type: BriefingType) => {
|
||||
return BRIEFING_TYPES.find(t => t.value === type) || BRIEFING_TYPES[2]
|
||||
}
|
||||
|
||||
const CurrentIcon = getBriefingTypeInfo(briefingType).Icon
|
||||
|
||||
// No config state - show setup message
|
||||
if (!city) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="card-title text-sm">{config.title || 'Daily Briefing'}</h2>
|
||||
<p className="text-sm opacity-70">
|
||||
Configure a city to generate briefings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 flex flex-col gap-2 min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide opacity-60 flex items-center gap-1">
|
||||
<CurrentIcon /> {getBriefingTypeInfo(briefingType).label} Briefing
|
||||
</p>
|
||||
<h2 className="card-title text-sm leading-tight">{config.title || 'Daily Briefing'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Briefing Type Selector */}
|
||||
<div className="flex gap-1">
|
||||
{BRIEFING_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
className={`btn btn-xs flex-1 gap-1 ${
|
||||
briefingType === type.value ? 'btn-primary' : 'btn-outline'
|
||||
}`}
|
||||
onClick={() => setBriefingType(type.value)}
|
||||
title={type.description}
|
||||
>
|
||||
<type.Icon /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generate Button or Content */}
|
||||
{!briefingData && !isLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm opacity-60 text-center">
|
||||
Get AI-powered insights for your day
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={generateBriefing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Generate Briefing
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
<p className="text-sm opacity-60">Analyzing your environment...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={generateBriefing}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Briefing Content */}
|
||||
{briefingData && !isLoading && (
|
||||
<div className="flex-1 flex flex-col gap-2 overflow-y-auto">
|
||||
{/* Status Line */}
|
||||
<div className="bg-base-200 rounded-lg p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={briefingData.status_line.toLowerCase()} />
|
||||
<p className="text-sm font-medium">{briefingData.status_line}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{briefingData.insights.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{briefingData.insights.map((insight, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
<span className="opacity-90">{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{briefingData.recommendations.length > 0 && (
|
||||
<div className="bg-primary/10 rounded-lg p-2 mt-auto">
|
||||
<div className="flex items-start gap-2">
|
||||
<LightbulbIcon />
|
||||
<div className="space-y-1">
|
||||
{briefingData.recommendations.map((rec, i) => (
|
||||
<p key={i} className="text-sm opacity-90">{rec}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with timestamp and refresh */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-base-200 mt-auto">
|
||||
<span className="text-[10px] opacity-50">
|
||||
Generated {timeFormatter.format(new Date(briefingData.generated_at))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost"
|
||||
onClick={generateBriefing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { wellnessApi } from '../../api'
|
||||
import { useWellnessState } from '../../hooks/useWellnessState'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
interface HealthStatsWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) {
|
||||
const { healthDeviceId, city } = useWellnessState()
|
||||
|
||||
// Use device from config or shared state
|
||||
const deviceId = config.deviceIds[0] || healthDeviceId
|
||||
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['health-insights', deviceId, widgetCity],
|
||||
queryFn: async () => {
|
||||
if (!deviceId) {
|
||||
throw new Error('Device ID is required')
|
||||
}
|
||||
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
enabled: !!deviceId,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'Failed to load health data'}
|
||||
</span>
|
||||
</div>
|
||||
{!deviceId && (
|
||||
<p className="text-xs text-base-content/60 mt-2">Select a health device</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const StepIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FireIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HeartIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Steps',
|
||||
value: data.health_metrics.steps,
|
||||
unit: '',
|
||||
icon: <StepIcon />,
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
label: 'Calories',
|
||||
value: data.health_metrics.active_calories,
|
||||
unit: 'kcal',
|
||||
icon: <FireIcon />,
|
||||
color: 'text-secondary'
|
||||
},
|
||||
{
|
||||
label: 'Heart Rate',
|
||||
value: data.health_metrics.heart_rate,
|
||||
unit: 'bpm',
|
||||
icon: <HeartIcon />,
|
||||
color: 'text-error'
|
||||
},
|
||||
]
|
||||
|
||||
// Find insights for each stat
|
||||
const getInsightForMetric = (metric: string) => {
|
||||
return data.insights.find(i => i.metric === metric)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-3">{config.title}</h2>
|
||||
|
||||
{/* Health Stats */}
|
||||
<div className="space-y-3">
|
||||
{stats.map((stat, i) => {
|
||||
const insight = getInsightForMetric(stat.label)
|
||||
const hasValue = stat.value !== null && stat.value !== undefined
|
||||
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={stat.color}>{stat.icon}</span>
|
||||
<span className="text-sm opacity-70">{stat.label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl font-bold ${stat.color}`}>
|
||||
{hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'}
|
||||
</span>
|
||||
{hasValue && <span className="text-xs opacity-50">{stat.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context/Correlation */}
|
||||
{insight && (
|
||||
<div className="text-xs space-y-0.5 ml-7">
|
||||
{insight.context && (
|
||||
<div className="opacity-70">{insight.context}</div>
|
||||
)}
|
||||
{insight.correlation && (
|
||||
<div className="text-warning opacity-80 flex items-center gap-1">
|
||||
<WarningIcon />
|
||||
{insight.correlation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{data.recommendations.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.recommendations[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useMemo, memo } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
@@ -17,15 +17,39 @@ interface LineChartWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
// Optimized date formatter - cache formatters to avoid recreating
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
// Helper to format date efficiently
|
||||
function formatTime(date: Date): string {
|
||||
return timeFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date): string {
|
||||
return dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
// Helper component to fetch data for a single metric
|
||||
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
|
||||
// Fetch data for each metric (React allows hooks in arrays when count is stable)
|
||||
// Only fetch data for metrics that exist (up to 5)
|
||||
const metric1 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[0] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500, // Limit data points for performance
|
||||
})
|
||||
|
||||
const metric2 = useTelemetrySeries({
|
||||
@@ -34,6 +58,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 1,
|
||||
})
|
||||
|
||||
const metric3 = useTelemetrySeries({
|
||||
@@ -42,6 +68,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 2,
|
||||
})
|
||||
|
||||
const metric4 = useTelemetrySeries({
|
||||
@@ -50,6 +78,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 3,
|
||||
})
|
||||
|
||||
const metric5 = useTelemetrySeries({
|
||||
@@ -58,6 +88,8 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 4,
|
||||
})
|
||||
|
||||
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
|
||||
@@ -65,7 +97,7 @@ function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: Wi
|
||||
return { queries, metricIds }
|
||||
}
|
||||
|
||||
export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, visualization } = config
|
||||
const deviceId = deviceIds[0]
|
||||
|
||||
@@ -74,64 +106,83 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
const isLoading = queries.some((q) => q.isLoading)
|
||||
const error = queries.find((q) => q.error)?.error
|
||||
|
||||
// Combine data from all metrics into a single chart dataset
|
||||
// Combine data from all metrics into a single chart dataset (optimized)
|
||||
const chartData = useMemo(() => {
|
||||
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
||||
|
||||
// Create a map of time -> { time, fullDateTime, metric1, metric2, ... }
|
||||
const timeMap = new Map<string, Record<string, number | string>>()
|
||||
// Limit total data points for performance (max 300 points)
|
||||
const MAX_POINTS = 300
|
||||
const totalPoints = queries.reduce((sum, q) => sum + (q.data?.length || 0), 0)
|
||||
const shouldDownsample = totalPoints > MAX_POINTS
|
||||
const step = shouldDownsample ? Math.ceil(totalPoints / MAX_POINTS) : 1
|
||||
|
||||
// Create a map of timestamp -> data point (using timestamp as key for better performance)
|
||||
const timeMap = new Map<number, Record<string, number | string>>()
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
const metric = metricIds[index]
|
||||
if (!query.data) return
|
||||
if (!query.data || query.data.length === 0) return
|
||||
|
||||
query.data.forEach((point) => {
|
||||
const date = new Date(point.time)
|
||||
// Process data points efficiently (with downsampling if needed)
|
||||
query.data.forEach((point, pointIndex) => {
|
||||
// Skip points if downsampling
|
||||
if (shouldDownsample && pointIndex % step !== 0) return
|
||||
|
||||
// Short time for X-axis display (just time)
|
||||
const timeStr = date.toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
const timestamp = new Date(point.time).getTime()
|
||||
|
||||
// Full date/time for tooltip
|
||||
const fullDateTime = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const key = date.getTime().toString()
|
||||
|
||||
if (!timeMap.has(key)) {
|
||||
timeMap.set(key, {
|
||||
time: timeStr,
|
||||
fullDateTime: fullDateTime,
|
||||
timestamp: date.getTime()
|
||||
if (!timeMap.has(timestamp)) {
|
||||
const date = new Date(timestamp)
|
||||
timeMap.set(timestamp, {
|
||||
time: formatTime(date),
|
||||
fullDateTime: formatDateTime(date),
|
||||
timestamp
|
||||
})
|
||||
}
|
||||
|
||||
const entry = timeMap.get(key)!
|
||||
const entry = timeMap.get(timestamp)!
|
||||
entry[metric] = point.value
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by timestamp
|
||||
return Array.from(timeMap.values()).sort((a, b) => {
|
||||
// Sort by timestamp and convert to array
|
||||
const result = Array.from(timeMap.values()).sort((a, b) => {
|
||||
return (a.timestamp as number) - (b.timestamp as number)
|
||||
})
|
||||
|
||||
return result
|
||||
}, [queries, metricIds])
|
||||
|
||||
const colors = visualization?.colors || [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
]
|
||||
// Memoize colors to avoid recreating array
|
||||
const colors = useMemo(() =>
|
||||
visualization?.colors || [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
],
|
||||
[visualization?.colors]
|
||||
)
|
||||
|
||||
// Memoize lines to avoid recreating on every render
|
||||
const lines = useMemo(() =>
|
||||
metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false} // Disable dots for better performance
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={true}
|
||||
name={formatMetricName(metric)}
|
||||
isAnimationActive={false} // Disable animations for better performance
|
||||
/>
|
||||
)),
|
||||
[metricIds, colors]
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -190,7 +241,11 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 50 }}>
|
||||
<LineChart
|
||||
data={chartData}
|
||||
margin={{ top: 5, right: 20, left: 0, bottom: 50 }}
|
||||
syncId="dashboard-charts" // Sync charts for better performance
|
||||
>
|
||||
{visualization?.showGrid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
)}
|
||||
@@ -200,6 +255,7 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
interval="preserveStartEnd" // Reduce number of ticks
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
@@ -216,22 +272,13 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
formatter={(value: number) => [value.toFixed(2)]}
|
||||
/>
|
||||
{visualization?.showLegend !== false && <Legend />}
|
||||
{metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={{ r: 2, strokeWidth: 0 }}
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={true}
|
||||
name={formatMetricName(metric)}
|
||||
/>
|
||||
))}
|
||||
{lines}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
export default memo(LineChartWidget)
|
||||
|
||||
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { wellnessApi } from '../../api'
|
||||
import { useWellnessState } from '../../hooks/useWellnessState'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const StepIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
interface RunSuitabilityWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetProps) {
|
||||
const { healthDeviceId, city } = useWellnessState()
|
||||
|
||||
// Use device from config or shared state
|
||||
const deviceId = config.deviceIds[0] || healthDeviceId
|
||||
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['run-suitability', deviceId, widgetCity],
|
||||
queryFn: async () => {
|
||||
if (!deviceId || !widgetCity) {
|
||||
throw new Error('Device ID and city are required')
|
||||
}
|
||||
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
enabled: !!deviceId && !!widgetCity,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'No data available'}
|
||||
</span>
|
||||
</div>
|
||||
{(!deviceId || !widgetCity) && (
|
||||
<p className="text-xs text-base-content/60">
|
||||
{!deviceId && 'Select a health device'}
|
||||
{!deviceId && !widgetCity && ' and '}
|
||||
{!widgetCity && 'Select a city'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'GO': return 'text-success'
|
||||
case 'MODERATE': return 'text-warning'
|
||||
case 'NO': return 'text-error'
|
||||
default: return 'text-base-content'
|
||||
}
|
||||
}
|
||||
|
||||
const getBgColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'GO': return 'bg-success/10 border-success/20'
|
||||
case 'MODERATE': return 'bg-warning/10 border-warning/20'
|
||||
case 'NO': return 'bg-error/10 border-error/20'
|
||||
default: return 'bg-base-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-2">{config.title}</h2>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center border-4 ${getStatusColor(data.status)} ${getBgColor(data.status)}`}>
|
||||
<span className={`text-2xl font-black ${getStatusColor(data.status)}`}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary Reason */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm">{data.primary_reason}</p>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<div className="flex gap-2 text-xs text-base-content/60 justify-center">
|
||||
<span>Weather: {data.scores.weather.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Air: {data.scores.air_quality.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Health: {data.scores.health.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Insights */}
|
||||
{data.detailed_insights.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Conditions:</div>
|
||||
<ul className="text-xs space-y-0.5">
|
||||
{data.detailed_insights.slice(0, 2).map((insight, i) => (
|
||||
<li key={i} className="opacity-80">• {insight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Recommendations */}
|
||||
{data.time_recommendations.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Best Time:</div>
|
||||
<div className="text-xs opacity-80">• {data.time_recommendations[0]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.suggestions[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Health Stats */}
|
||||
<div className="text-xs text-base-content/60 flex gap-2 justify-center pt-1 border-t border-base-300 flex-shrink-0 mt-auto">
|
||||
<span className="flex items-center gap-1">
|
||||
<StepIcon />
|
||||
{data.health_data.steps.toLocaleString()} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,118 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { weatherApi } from '../../api'
|
||||
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SunIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<line x1="12" y1="2" x2="12" y2="5" />
|
||||
<line x1="12" y1="19" x2="12" y2="22" />
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
|
||||
<line x1="2" y1="12" x2="5" y2="12" />
|
||||
<line x1="19" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const CloudIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const PartlyCloudyIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<path d="M5 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 17H5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const FogIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M4 10h11a3 3 0 000-6 4.5 4.5 0 00-8.91 1" />
|
||||
<line x1="3" y1="15" x2="17" y2="15" />
|
||||
<line x1="5" y1="19" x2="19" y2="19" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const RainIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
<line x1="8" y1="17" x2="8" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="22" />
|
||||
<line x1="16" y1="17" x2="16" y2="21" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const SnowIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 14a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 14H5z" />
|
||||
<line x1="11" y1="16" x2="11" y2="22" />
|
||||
<line x1="8.5" y1="18" x2="13.5" y2="20" />
|
||||
<line x1="8.5" y1="20" x2="13.5" y2="18" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const ThunderIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
<polyline points="12 16 10 20 14 20 12 24" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const ThermometerIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
|
||||
<line x1="12" y1="8" x2="12" y2="11" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const DropletIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const WindIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M3 12h9a3 3 0 10-3-3" />
|
||||
<path d="M5 18h11a3 3 0 11-3 3" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const CloudCoverIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M6 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0119 17H6z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const RainDropIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M7 14a5 5 0 0010 0c0-4-5-9-5-9s-5 5-5 9z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
@@ -16,7 +127,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
const response = await weatherApi.getCurrent({ city })
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
refetchInterval: 5000, // Refresh every 5 seconds
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
@@ -45,19 +156,18 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
|
||||
if (!weather) return null
|
||||
|
||||
// Weather code to icon mapping
|
||||
const getWeatherIcon = (code: number) => {
|
||||
if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear
|
||||
if (code === 2) return '⛅' // Partly cloudy
|
||||
if (code === 3) return '☁️' // Overcast
|
||||
if (code >= 45 && code <= 48) return '🌫️' // Fog
|
||||
if (code >= 51 && code <= 55) return '🌦️' // Drizzle
|
||||
if (code >= 61 && code <= 65) return '🌧️' // Rain
|
||||
if (code >= 71 && code <= 77) return '🌨️' // Snow
|
||||
if (code >= 80 && code <= 82) return '🌧️' // Rain showers
|
||||
if (code >= 85 && code <= 86) return '🌨️' // Snow showers
|
||||
if (code >= 95) return '⛈️' // Thunderstorm
|
||||
return '🌡️'
|
||||
if (code === 0 || code === 1) return <SunIcon className="w-16 h-16 text-warning" />
|
||||
if (code === 2) return <PartlyCloudyIcon className="w-16 h-16 text-primary" />
|
||||
if (code === 3) return <CloudIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 45 && code <= 48) return <FogIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 51 && code <= 55) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 61 && code <= 65) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 71 && code <= 77) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 80 && code <= 82) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 85 && code <= 86) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 95) return <ThunderIcon className="w-16 h-16 text-primary" />
|
||||
return <ThermometerIcon className="w-16 h-16 text-primary" />
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -66,7 +176,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
{/* Weather Icon */}
|
||||
<div className="text-5xl mb-1">{getWeatherIcon(weather.weather_code)}</div>
|
||||
<div className="mb-1 text-primary">{getWeatherIcon(weather.weather_code)}</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||
@@ -82,20 +192,20 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||
{/* Additional Info */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">💧</span>
|
||||
<DropletIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">💨</span>
|
||||
<WindIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">☁️</span>
|
||||
<CloudCoverIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.cloud_cover}%</span>
|
||||
</div>
|
||||
{weather.precipitation > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-60">🌧️</span>
|
||||
<RainDropIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.precipitation} mm</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { widgetRegistry } from './registry'
|
||||
|
||||
@@ -5,10 +6,34 @@ interface WidgetContainerProps {
|
||||
config: WidgetConfig
|
||||
onRemove?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
export default function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) {
|
||||
function WidgetContainer({ config, onRemove, onEdit, onHeightChange }: WidgetContainerProps) {
|
||||
const WidgetComponent = widgetRegistry[config.type]
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!onHeightChange || !contentRef.current) return
|
||||
|
||||
const node = contentRef.current
|
||||
|
||||
const emitHeight = () => {
|
||||
onHeightChange(node.scrollHeight)
|
||||
}
|
||||
|
||||
emitHeight()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
emitHeight()
|
||||
})
|
||||
|
||||
resizeObserver.observe(node)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [onHeightChange, config.id])
|
||||
|
||||
if (!WidgetComponent) {
|
||||
return (
|
||||
@@ -21,10 +46,9 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group h-full">
|
||||
{/* Drag handle and actions */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-b from-base-300/90 to-transparent">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
|
||||
<div className="relative group h-full w-full">
|
||||
<div className="absolute top-2 left-2 right-2 z-20 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100 shadow-md text-xs border border-base-300 pointer-events-auto">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
@@ -41,11 +65,15 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
</svg>
|
||||
Drag
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-1 pointer-events-auto">
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onEdit(config.id)}
|
||||
type="button"
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-base-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onEdit(config.id)
|
||||
}}
|
||||
title="Edit widget"
|
||||
>
|
||||
<svg
|
||||
@@ -66,8 +94,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onRemove(config.id)}
|
||||
type="button"
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-error hover:text-error-content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onRemove(config.id)
|
||||
}}
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg
|
||||
@@ -89,10 +121,12 @@ export default function WidgetContainer({ config, onRemove, onEdit }: WidgetCont
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actual widget */}
|
||||
<div className="h-full">
|
||||
{/* Allow overlay to float without reserving layout space */}
|
||||
<div className="w-full" ref={contentRef}>
|
||||
<WidgetComponent config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WidgetContainer)
|
||||
|
||||
@@ -3,4 +3,5 @@ export { default as LineChartWidget } from './LineChartWidget'
|
||||
export { default as StatWidget } from './StatWidget'
|
||||
export { default as GaugeWidget } from './GaugeWidget'
|
||||
export { default as AiInsightWidget } from './AiInsightWidget'
|
||||
export { default as CalendarWidget } from './CalendarWidget'
|
||||
export { widgetRegistry } from './registry'
|
||||
|
||||
@@ -8,6 +8,10 @@ import AiInsightWidget from './AiInsightWidget'
|
||||
import AirQualityWidget from './AirQualityWidget'
|
||||
import WeatherWidget from './WeatherWidget'
|
||||
import ComfortIndexWidget from './ComfortIndexWidget'
|
||||
import RunSuitabilityWidget from './RunSuitabilityWidget'
|
||||
import HealthStatsWidget from './HealthStatsWidget'
|
||||
import CalendarWidget from './CalendarWidget'
|
||||
import DailyBriefingWidget from './DailyBriefingWidget'
|
||||
|
||||
interface WidgetProps {
|
||||
config: WidgetConfig
|
||||
@@ -22,4 +26,8 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'air-quality': AirQualityWidget,
|
||||
'weather': WeatherWidget,
|
||||
'comfort-index': ComfortIndexWidget,
|
||||
'run-suitability': RunSuitabilityWidget,
|
||||
'health-stats': HealthStatsWidget,
|
||||
'calendar': CalendarWidget,
|
||||
'daily-briefing': DailyBriefingWidget,
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
|
||||
.widget-card .card-body {
|
||||
padding: 1rem; /* 16px instead of 32px */
|
||||
padding: 0.5rem; /* 8px - can be overridden by inline p-2 class */
|
||||
}
|
||||
|
||||
/* Lighter shadow for widgets */
|
||||
@@ -10,11 +10,12 @@
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
/* Ensure cards fill their container properly */
|
||||
/* Ensure cards fill their container properly and align to grid */
|
||||
.widget-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.widget-card .card-body {
|
||||
@@ -23,3 +24,35 @@
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Allow flex children to shrink */
|
||||
}
|
||||
|
||||
/* Gridstack alignment */
|
||||
.grid-stack-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.grid-stack-item-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Gridstack drag handle */
|
||||
.grid-stack-item .drag-handle {
|
||||
cursor: move;
|
||||
pointer-events: auto !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Ensure gridstack items are interactive */
|
||||
.grid-stack-item {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Gridstack resize handles */
|
||||
.grid-stack-item .ui-resizable-handle {
|
||||
pointer-events: auto !important;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
50
frontend/src/contexts/AuthContext.tsx
Normal file
50
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -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<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(
|
||||
() => 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 (
|
||||
<AuthContext.Provider value={{ isAuthenticated: !!token, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider')
|
||||
return context
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { dashboardLayoutApi } from '../api'
|
||||
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index'
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' | 'run-suitability' | 'health-stats' | 'calendar' | 'daily-briefing'
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
@@ -20,6 +21,17 @@ export interface WidgetConfig {
|
||||
height?: number
|
||||
city?: string
|
||||
}
|
||||
calendar?: {
|
||||
icalUrl: string
|
||||
timeRangeHours?: number
|
||||
}
|
||||
briefing?: {
|
||||
briefingType: 'schedule' | 'environment' | 'full'
|
||||
city: string
|
||||
healthDeviceId?: string
|
||||
calendarUrl?: string
|
||||
calendarRangeHours?: number
|
||||
}
|
||||
position?: {
|
||||
x: number
|
||||
y: number
|
||||
@@ -43,29 +55,132 @@ const DEFAULT_CONFIG: DashboardConfig = {
|
||||
const STORAGE_KEY = 'iot-dashboard-config'
|
||||
|
||||
/**
|
||||
* Hook to manage dashboard configuration with localStorage persistence
|
||||
* Hook to manage dashboard configuration with backend sync and localStorage fallback
|
||||
* Single-user system: No authentication required
|
||||
*/
|
||||
export function useDashboardConfig() {
|
||||
const [config, setConfig] = useState<DashboardConfig>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as DashboardConfig
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard config:', error)
|
||||
}
|
||||
return DEFAULT_CONFIG
|
||||
})
|
||||
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [layoutId, setLayoutId] = useState<number | null>(null)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
|
||||
// Persist to localStorage whenever config changes
|
||||
// Load config from backend or localStorage
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Try to load from backend
|
||||
try {
|
||||
const response = await dashboardLayoutApi.getDefault()
|
||||
const layout = response.data
|
||||
setConfig(layout.config)
|
||||
setLayoutId(layout.id)
|
||||
} catch (error: any) {
|
||||
// No default layout found, try to create one or use localStorage fallback
|
||||
console.log('No default layout found, using localStorage or creating new')
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as DashboardConfig
|
||||
setConfig(parsed)
|
||||
// Save to backend
|
||||
try {
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: parsed,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to save to backend:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard config:', error)
|
||||
// Fallback to localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
setConfig(JSON.parse(stored) as DashboardConfig)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load from localStorage:', e)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
isInitialLoadRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard config:', error)
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
}, [config])
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
if (layoutId) {
|
||||
// Update existing layout
|
||||
await dashboardLayoutApi.update(layoutId, {
|
||||
config: config,
|
||||
})
|
||||
} else {
|
||||
// Create new layout
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: config,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to save to backend:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save to localStorage and debounced backend save
|
||||
useEffect(() => {
|
||||
if (!isInitialLoadRef.current) {
|
||||
// Save to localStorage immediately
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
|
||||
// Auto-save to backend with debounce (2 seconds after last change)
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
if (layoutId) {
|
||||
await dashboardLayoutApi.update(layoutId, {
|
||||
config: config,
|
||||
})
|
||||
console.log('Dashboard auto-saved to backend')
|
||||
} else {
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: config,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
console.log('Dashboard created and auto-saved to backend')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to auto-save to backend:', error)
|
||||
}
|
||||
}, 2000)
|
||||
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [config, layoutId])
|
||||
|
||||
|
||||
const addWidget = (widget: WidgetConfig) => {
|
||||
setConfig((prev) => ({
|
||||
@@ -124,5 +239,7 @@ export function useDashboardConfig() {
|
||||
resetConfig,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
saveConfig,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
|
||||
155
frontend/src/hooks/useGridstack.ts
Normal file
155
frontend/src/hooks/useGridstack.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { GridStack } from 'gridstack'
|
||||
import 'gridstack/dist/gridstack.min.css'
|
||||
|
||||
// Define the widget type based on gridstack.js structure
|
||||
export type GridStackWidget = {
|
||||
id?: string
|
||||
x?: number
|
||||
y?: number
|
||||
w?: number
|
||||
h?: number
|
||||
minW?: number
|
||||
minH?: number
|
||||
maxW?: number
|
||||
maxH?: number
|
||||
noResize?: boolean
|
||||
noMove?: boolean
|
||||
locked?: boolean
|
||||
[key: string]: any // Allow additional properties
|
||||
}
|
||||
|
||||
interface UseGridstackOptions {
|
||||
columns?: number
|
||||
cellHeight?: number
|
||||
margin?: number
|
||||
minRow?: number
|
||||
onLayoutChange?: (items: GridStackWidget[]) => void
|
||||
disableResize?: boolean
|
||||
disableDrag?: boolean
|
||||
}
|
||||
|
||||
export function useGridstack(options: UseGridstackOptions = {}) {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const gridInstanceRef = useRef<GridStack | null>(null)
|
||||
const {
|
||||
columns = 4,
|
||||
cellHeight = 150,
|
||||
margin = 12,
|
||||
minRow = 1,
|
||||
onLayoutChange,
|
||||
disableResize = false,
|
||||
disableDrag = false,
|
||||
} = options
|
||||
|
||||
// Initialize gridstack
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
|
||||
// Clean up existing instance
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.destroy(false)
|
||||
}
|
||||
|
||||
// Create new gridstack instance
|
||||
// Gridstack will automatically detect and manage elements with data-gs-* attributes
|
||||
const grid = GridStack.init(
|
||||
{
|
||||
column: columns,
|
||||
cellHeight,
|
||||
margin,
|
||||
minRow,
|
||||
resizable: {
|
||||
handles: 'e, se, s, sw, w',
|
||||
},
|
||||
disableResize,
|
||||
disableDrag,
|
||||
float: false,
|
||||
animate: true,
|
||||
acceptWidgets: false,
|
||||
// Removed handle option - entire widget is draggable for better UX
|
||||
},
|
||||
gridRef.current
|
||||
)
|
||||
|
||||
// Handle layout change
|
||||
if (onLayoutChange) {
|
||||
grid.on('change', () => {
|
||||
const serialized = grid.save(false) as GridStackWidget[]
|
||||
onLayoutChange(serialized)
|
||||
})
|
||||
}
|
||||
|
||||
gridInstanceRef.current = grid
|
||||
|
||||
return () => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.destroy(false)
|
||||
gridInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [columns, cellHeight, margin, minRow, disableResize, disableDrag, onLayoutChange])
|
||||
|
||||
// Convert existing elements to gridstack widgets
|
||||
const makeWidgets = useCallback(() => {
|
||||
if (gridInstanceRef.current && gridRef.current) {
|
||||
const items = gridRef.current.querySelectorAll('.grid-stack-item:not(.ui-draggable)')
|
||||
items.forEach((item) => {
|
||||
gridInstanceRef.current!.makeWidget(item as HTMLElement)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load items into grid
|
||||
const loadItems = useCallback((items: GridStackWidget[]) => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.load(items)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add item to grid
|
||||
const addItem = useCallback((item: GridStackWidget) => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.addWidget(item)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Remove item from grid
|
||||
const removeItem = useCallback((id: string) => {
|
||||
if (gridInstanceRef.current) {
|
||||
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||
if (el) {
|
||||
gridInstanceRef.current.removeWidget(el as HTMLElement, false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update item
|
||||
const updateItem = useCallback((id: string, updates: Partial<GridStackWidget>) => {
|
||||
if (gridInstanceRef.current) {
|
||||
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||
if (el) {
|
||||
gridInstanceRef.current.update(el as HTMLElement, updates)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get current layout
|
||||
const getLayout = useCallback((): GridStackWidget[] => {
|
||||
if (gridInstanceRef.current) {
|
||||
return gridInstanceRef.current.save(false) as GridStackWidget[]
|
||||
}
|
||||
return []
|
||||
}, [])
|
||||
|
||||
return {
|
||||
gridRef,
|
||||
makeWidgets,
|
||||
loadItems,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
getLayout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,8 @@ export function useTelemetrySeries({
|
||||
return response.data as Telemetry[]
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
refetchInterval: enabled ? 5000 : false, // Refresh every 5 seconds
|
||||
staleTime: 3000, // Consider data fresh for 3 seconds
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
40
frontend/src/hooks/useWellnessState.tsx
Normal file
40
frontend/src/hooks/useWellnessState.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
interface WellnessState {
|
||||
healthDeviceId: string | null
|
||||
city: string
|
||||
setHealthDeviceId: (id: string | null) => void
|
||||
setCity: (city: string) => void
|
||||
}
|
||||
|
||||
const WellnessStateContext = createContext<WellnessState | undefined>(undefined)
|
||||
|
||||
export function WellnessStateProvider({ children }: { children: ReactNode }) {
|
||||
const [healthDeviceId, setHealthDeviceId] = useState<string | null>(null)
|
||||
const [city, setCity] = useState<string>('Skopje')
|
||||
|
||||
return (
|
||||
<WellnessStateContext.Provider
|
||||
value={{
|
||||
healthDeviceId,
|
||||
city,
|
||||
setHealthDeviceId,
|
||||
setCity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WellnessStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWellnessState() {
|
||||
const context = useContext(WellnessStateContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWellnessState must be used within WellnessStateProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,14 @@
|
||||
--dui-themes: light, dark, cupcake, corporate;
|
||||
}
|
||||
|
||||
/* Ensure full viewport height */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
|
||||
@@ -8,27 +8,49 @@ export const apiClient = axios.create({
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true, // For session auth
|
||||
});
|
||||
|
||||
// Add request interceptor for JWT token (if using JWT)
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
// 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 error handling
|
||||
// Add response interceptor for token refresh on 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Handle unauthorized - redirect to login
|
||||
localStorage.removeItem('access_token');
|
||||
// window.location.href = '/login';
|
||||
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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import GridLayout from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import { useDashboardConfig } from '../hooks'
|
||||
@@ -6,18 +6,35 @@ import { WidgetContainer } from '../components/widgets'
|
||||
import AddWidgetModal from '../components/AddWidgetModal'
|
||||
import EditWidgetModal from '../components/EditWidgetModal'
|
||||
|
||||
const GRID_COLUMNS = 5
|
||||
const GRID_MARGIN: [number, number] = [8, 6]
|
||||
const ROW_HEIGHT = 90
|
||||
const HEIGHT_PADDING = 0
|
||||
const ROW_UNIT = ROW_HEIGHT + GRID_MARGIN[1]
|
||||
const MAX_AUTO_ROWS = 6
|
||||
|
||||
export default function Dashboard() {
|
||||
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig } = useDashboardConfig()
|
||||
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig, saveConfig } = useDashboardConfig()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingWidget, setEditingWidget] = useState<string | null>(null)
|
||||
const [gridWidth, setGridWidth] = useState(1200)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [gridWidth, setGridWidth] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth
|
||||
}
|
||||
return GRID_COLUMNS * (ROW_HEIGHT + GRID_MARGIN[0])
|
||||
})
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Update grid width on resize
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (gridContainerRef.current) {
|
||||
setGridWidth(gridContainerRef.current.offsetWidth)
|
||||
const rect = gridContainerRef.current.getBoundingClientRect()
|
||||
setGridWidth(rect.width)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
setGridWidth(window.innerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,16 +60,44 @@ export default function Dashboard() {
|
||||
})
|
||||
}
|
||||
|
||||
const layout = config.widgets.map((widget) => ({
|
||||
i: widget.id,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widget.position?.w || 1,
|
||||
h: widget.position?.h || 2,
|
||||
minW: 1,
|
||||
minH: 1,
|
||||
maxW: 4,
|
||||
}))
|
||||
const layout = config.widgets.map((widget) => {
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
return {
|
||||
i: widget.id,
|
||||
x: position.x ?? 0,
|
||||
y: position.y ?? Infinity,
|
||||
w: Math.max(position.w ?? 1, 1),
|
||||
h: Math.max(position.h ?? 1, 1),
|
||||
minW: 1,
|
||||
minH: 1,
|
||||
maxW: GRID_COLUMNS,
|
||||
}
|
||||
})
|
||||
|
||||
const handleWidgetHeightChange = useCallback(
|
||||
(widgetId: string, contentHeight: number) => {
|
||||
const widget = config.widgets.find((w) => w.id === widgetId)
|
||||
if (!widget) return
|
||||
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
const currentRows = Math.max(position.h ?? 1, 1)
|
||||
const desiredPixelHeight = contentHeight + HEIGHT_PADDING
|
||||
const targetRows = Math.min(
|
||||
MAX_AUTO_ROWS,
|
||||
Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT))
|
||||
)
|
||||
|
||||
if (Math.abs(targetRows - currentRows) >= 1) {
|
||||
updateWidget(widgetId, {
|
||||
position: {
|
||||
...position,
|
||||
h: targetRows,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[config.widgets, updateWidget]
|
||||
)
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportConfig()
|
||||
@@ -83,8 +128,23 @@ export default function Dashboard() {
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleSaveDashboard = async () => {
|
||||
setIsSaving(true)
|
||||
setSaveStatus('idle')
|
||||
try {
|
||||
const success = await saveConfig()
|
||||
setSaveStatus(success ? 'success' : 'error')
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard configuration:', error)
|
||||
setSaveStatus('error')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-6 space-y-6 min-h-screen">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
@@ -92,7 +152,36 @@ export default function Dashboard() {
|
||||
Customize your view with modular widgets
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Auto-save enabled
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleSaveDashboard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
Save Now
|
||||
</button>
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-success text-sm">Saved!</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleExport}
|
||||
@@ -148,32 +237,34 @@ export default function Dashboard() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full" ref={gridContainerRef}>
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={4}
|
||||
rowHeight={150}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={[12, 12]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id} className="h-full">
|
||||
<WidgetContainer
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
onEdit={() => setEditingWidget(widget.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
<div className="w-full" ref={gridContainerRef}>
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={GRID_COLUMNS}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={GRID_MARGIN}
|
||||
containerPadding={[0, 0]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id} className="h-full">
|
||||
<WidgetContainer
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
onEdit={() => setEditingWidget(widget.id)}
|
||||
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddWidgetModal
|
||||
|
||||
71
frontend/src/pages/Login.tsx
Normal file
71
frontend/src/pages/Login.tsx
Normal file
@@ -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 (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div className="card w-96 bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl mb-4">IoT Dashboard Login</h2>
|
||||
{error && <div className="alert alert-error text-sm">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Username</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input input-bordered"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <span className="loading loading-spinner" /> : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
infrastructure/.gitignore
vendored
4
infrastructure/.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
mosquitto/certs/
|
||||
mosquitto/data/
|
||||
mosquitto/logs/
|
||||
mosquitto/logs/
|
||||
.env
|
||||
.env*
|
||||
129
infrastructure/compose.prod.yml
Normal file
129
infrastructure/compose.prod.yml
Normal file
@@ -0,0 +1,129 @@
|
||||
services:
|
||||
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2.0
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "9001:9001"
|
||||
- "8883:8883"
|
||||
volumes:
|
||||
- ./mosquitto/:/mosquitto/:Z
|
||||
restart: unless-stopped
|
||||
|
||||
timescaledb:
|
||||
image: timescale/timescaledb:latest-pg17
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-iot_data}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- timescaledb-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
device-manager:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis/device-manager:latest
|
||||
ports:
|
||||
- "8000:8000"
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data}
|
||||
- CA_CERT_PATH=/app/certs/ca.crt
|
||||
- CA_KEY_PATH=/app/certs/ca.key
|
||||
- CRL_PATH=/app/certs/ca.crl
|
||||
volumes:
|
||||
- ./mosquitto/certs:/app/certs:z
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
mqtt-ingestion:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis/mqtt-ingestion:latest
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- MQTT_BROKER=mosquitto
|
||||
- MQTT_PORT=1883
|
||||
- MQTT_USER=${MQTT_USER:-}
|
||||
- MQTT_PASS=${MQTT_PASS:-}
|
||||
depends_on:
|
||||
- mosquitto
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
db-write:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis/db-write:latest
|
||||
environment:
|
||||
- REDIS_HOST=redis
|
||||
- REDIS_PORT=6379
|
||||
- REDIS_DB=0
|
||||
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data}
|
||||
- CONSUMER_GROUP_NAME=db_writer
|
||||
- CONSUMER_NAME=worker-01
|
||||
- BATCH_SIZE=100
|
||||
- BATCH_TIMEOUT_SEC=5
|
||||
- STREAM_PATTERN=mqtt_stream:*
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
mqtt-ingestion:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- POSTGRES_HOST=timescaledb
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-iot_data}
|
||||
- DEVICE_MANAGER_URL=http://device-manager:8000
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||
- DEBUG=False
|
||||
- ALLOWED_HOSTS=*
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
device-manager:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis/frontend:latest
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
gpt-service:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest
|
||||
environment:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
timescaledb-data:
|
||||
@@ -8,14 +8,14 @@ services:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
mqtt:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2.0
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "9001:9001"
|
||||
- "8883:8883"
|
||||
volumes:
|
||||
- ./mosquitto/:/mosquitto/:Z
|
||||
- ./mosquitto/:/mosquitto/
|
||||
restart: unless-stopped
|
||||
|
||||
timescaledb:
|
||||
@@ -29,6 +29,94 @@ services:
|
||||
volumes:
|
||||
- timescaledb-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
device-manager:
|
||||
build:
|
||||
context: ../services/device_manager
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- ../services/device_manager/.env
|
||||
volumes:
|
||||
- ./mosquitto/certs:/app/certs:z
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
mqtt-ingestion:
|
||||
build:
|
||||
context: ../services/mqtt_ingestion
|
||||
env_file:
|
||||
- .env.mqi
|
||||
depends_on:
|
||||
- mosquitto
|
||||
- redis
|
||||
restart: unless-stopped
|
||||
|
||||
db-write:
|
||||
build:
|
||||
context: ../services/db_write
|
||||
env_file:
|
||||
- .env.dbw
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
mqtt-ingestion:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
backend:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- POSTGRES_HOST=timescaledb
|
||||
- POSTGRES_PORT=5432
|
||||
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example}
|
||||
- POSTGRES_DB=${POSTGRES_DB:-iot_data}
|
||||
- DEVICE_MANAGER_URL=http://device-manager:8000
|
||||
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}6
|
||||
- DEBUG=False
|
||||
- ALLOWED_HOSTS=*
|
||||
depends_on:
|
||||
timescaledb:
|
||||
condition: service_healthy
|
||||
device-manager:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: /api
|
||||
VITE_DEVICE_MANAGER_URL: ${VITE_DEVICE_MANAGER_URL:-http://localhost/api}
|
||||
VITE_MQTT_BROKER: ${VITE_MQTT_BROKER:-localhost}
|
||||
VITE_MQTT_PORT: ${VITE_MQTT_PORT:-8883}
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- django
|
||||
restart: unless-stopped
|
||||
|
||||
gpt-service:
|
||||
image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest
|
||||
environment:
|
||||
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||
ports:
|
||||
- "8001:8001"
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from rest_framework import serializers
|
||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||
from iotDashboard.dashboard_models import DashboardLayout
|
||||
|
||||
|
||||
class DeviceCertificateSerializer(serializers.ModelSerializer):
|
||||
@@ -75,3 +76,13 @@ class DashboardOverviewSerializer(serializers.Serializer):
|
||||
certificates_expiring_soon = serializers.IntegerField()
|
||||
recent_telemetry = TelemetrySerializer(many=True)
|
||||
devices_with_metrics = DeviceMetricsSerializer(many=True)
|
||||
|
||||
|
||||
# Dashboard Layout Serializers
|
||||
class DashboardLayoutSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for dashboard layouts (single-user system)."""
|
||||
|
||||
class Meta:
|
||||
model = DashboardLayout
|
||||
fields = ['id', 'name', 'config', 'is_default', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import DeviceViewSet, TelemetryViewSet, DashboardViewSet, WeatherViewSet
|
||||
from .views import (
|
||||
DeviceViewSet, TelemetryViewSet, DashboardViewSet, WeatherViewSet,
|
||||
WellnessViewSet, DashboardLayoutViewSet, CalendarViewSet
|
||||
)
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'devices', DeviceViewSet, basename='device')
|
||||
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
|
||||
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
|
||||
router.register(r'weather', WeatherViewSet, basename='weather')
|
||||
router.register(r'wellness', WellnessViewSet, basename='wellness')
|
||||
router.register(r'dashboard-layouts', DashboardLayoutViewSet, basename='dashboard-layout')
|
||||
router.register(r'calendar', CalendarViewSet, basename='calendar')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""DRF ViewSets for IoT Dashboard API."""
|
||||
|
||||
import requests
|
||||
from datetime import timedelta
|
||||
from datetime import timedelta, datetime
|
||||
from urllib.parse import urlparse
|
||||
from django.utils import timezone
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework import viewsets, status, permissions
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from asgiref.sync import async_to_sync
|
||||
|
||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||
from iotDashboard.dashboard_models import DashboardLayout
|
||||
from iotDashboard.device_manager_client import (
|
||||
DeviceManagerClient,
|
||||
DeviceManagerAPIError
|
||||
@@ -18,13 +20,24 @@ from iotDashboard import weather_client
|
||||
from iotDashboard.comfort_index import (
|
||||
ComfortMetrics,
|
||||
ComfortIndexCalculator,
|
||||
calculate_comfort_index_from_telemetry,
|
||||
)
|
||||
from iotDashboard.run_suitability import (
|
||||
RunSuitabilityCalculator,
|
||||
WeatherData,
|
||||
AirQualityData,
|
||||
HealthData,
|
||||
)
|
||||
from iotDashboard.health_insights import (
|
||||
HealthInsightsCalculator,
|
||||
HealthMetrics,
|
||||
EnvironmentalContext,
|
||||
)
|
||||
from .serializers import (
|
||||
DeviceSerializer,
|
||||
DeviceCreateSerializer,
|
||||
TelemetrySerializer,
|
||||
DashboardOverviewSerializer,
|
||||
DashboardLayoutSerializer,
|
||||
)
|
||||
|
||||
|
||||
@@ -75,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
|
||||
@@ -89,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):
|
||||
@@ -284,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."""
|
||||
@@ -352,7 +376,7 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
)
|
||||
return Response({'metrics': list(metrics)})
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
@action(detail=False, methods=['post'], url_path='analyze', url_name='analyze')
|
||||
def analyze(self, request):
|
||||
"""Analyze telemetry data using GPT service."""
|
||||
# Parse request parameters
|
||||
@@ -384,7 +408,14 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
time__gte=timezone.now() - timedelta(hours=hours)
|
||||
)
|
||||
|
||||
if metric:
|
||||
# Support multiple metrics (comma-separated)
|
||||
metrics = request.data.get('metrics')
|
||||
if metrics:
|
||||
# Multiple metrics provided as list
|
||||
metric_list = metrics if isinstance(metrics, list) else [m.strip() for m in metrics.split(',')]
|
||||
queryset = queryset.filter(metric__in=metric_list)
|
||||
elif metric:
|
||||
# Single metric (backward compatible)
|
||||
queryset = queryset.filter(metric=metric)
|
||||
|
||||
telemetry = queryset.order_by('-time')[:limit]
|
||||
@@ -422,9 +453,9 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
custom_prompt=custom_prompt
|
||||
)
|
||||
return Response({
|
||||
'analysis': result.analysis,
|
||||
'prompt_type': result.prompt_type,
|
||||
'data_points_analyzed': result.data_points_analyzed
|
||||
'analysis': result['analysis'],
|
||||
'prompt_type': result['prompt_type'],
|
||||
'data_points_analyzed': result['data_points_analyzed']
|
||||
})
|
||||
|
||||
except gpt_service_client.GPTServiceError as e:
|
||||
@@ -576,3 +607,552 @@ class WeatherViewSet(viewsets.ViewSet):
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
|
||||
class CalendarViewSet(viewsets.ViewSet):
|
||||
""" ViewSet for Calendar"""
|
||||
|
||||
@action(detail=False,methods=['get'])
|
||||
def fetch(self,request):
|
||||
""" Fetch calendar events """
|
||||
calendar_url = request.query_params.get('calendar_url')
|
||||
if not calendar_url:
|
||||
return Response(
|
||||
{'error': 'calendar_url parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
parsed = urlparse(calendar_url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
return Response(
|
||||
{'error': 'Only http/https calendar URLs are supported'},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
# Keep timeout small to avoid hanging the API worker
|
||||
calendar_response = requests.get(calendar_url, timeout=10)
|
||||
calendar_response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Failed to fetch calendar feed',
|
||||
'details': str(exc),
|
||||
},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
|
||||
text = calendar_response.text or ''
|
||||
if not text.strip():
|
||||
return Response(
|
||||
{'error': 'Calendar feed returned no data'},
|
||||
status=status.HTTP_502_BAD_GATEWAY,
|
||||
)
|
||||
|
||||
return Response({'calendar_data': text})
|
||||
|
||||
|
||||
class WellnessViewSet(viewsets.ViewSet):
|
||||
"""ViewSet for wellness analysis combining health and environmental data."""
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def run_suitability(self, request):
|
||||
"""
|
||||
Calculate run suitability combining weather, air quality, and health data.
|
||||
|
||||
Query params:
|
||||
- health_device_id: Device ID for health metrics (required)
|
||||
- city: City name for weather/air quality (required)
|
||||
- time_of_day: Optional time override (ISO format)
|
||||
"""
|
||||
health_device_id = request.query_params.get('health_device_id')
|
||||
city = request.query_params.get('city')
|
||||
time_of_day_str = request.query_params.get('time_of_day')
|
||||
|
||||
if not health_device_id:
|
||||
return Response(
|
||||
{'error': 'health_device_id parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not city:
|
||||
return Response(
|
||||
{'error': 'city parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate health device exists
|
||||
try:
|
||||
Device.objects.get(id=health_device_id)
|
||||
except Device.DoesNotExist:
|
||||
return Response(
|
||||
{'error': f'Health device {health_device_id} not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Fetch weather data
|
||||
try:
|
||||
weather_data = weather_client.get_weather_by_city(city)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to fetch weather data: {str(e)}'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
# Fetch air quality data
|
||||
try:
|
||||
raw_air_quality = weather_client.get_air_quality(city.lower())
|
||||
air_quality_data = weather_client.parse_air_quality_data(raw_air_quality, city.lower())
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to fetch air quality data: {str(e)}'},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
|
||||
# Get health metrics from telemetry
|
||||
health_metrics = {}
|
||||
health_metric_names = {
|
||||
'steps': ['steps', 'step_count'],
|
||||
'active_calories': ['active_calories', 'calories'],
|
||||
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||
}
|
||||
|
||||
for key, possible_names in health_metric_names.items():
|
||||
for metric_name in possible_names:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(device_id=health_device_id, metric=metric_name)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
health_metrics[key] = float(latest.value)
|
||||
break
|
||||
|
||||
# Get current time
|
||||
if time_of_day_str:
|
||||
try:
|
||||
current_time = datetime.fromisoformat(time_of_day_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
current_time = timezone.now()
|
||||
else:
|
||||
current_time = timezone.now()
|
||||
|
||||
# Prepare data for calculator
|
||||
weather = WeatherData(
|
||||
temperature=weather_data.get('temperature', 20),
|
||||
apparent_temperature=weather_data.get('apparent_temperature', 20),
|
||||
wind_speed=weather_data.get('wind_speed', 0),
|
||||
precipitation=weather_data.get('precipitation', 0),
|
||||
rain=weather_data.get('rain', 0),
|
||||
weather_code=weather_data.get('weather_code', 0),
|
||||
humidity=weather_data.get('humidity', 50),
|
||||
cloud_cover=weather_data.get('cloud_cover', 0),
|
||||
)
|
||||
|
||||
air_quality = AirQualityData(
|
||||
pm25=air_quality_data.get('measurements', {}).get('pm25', {}).get('average'),
|
||||
pm10=air_quality_data.get('measurements', {}).get('pm10', {}).get('average'),
|
||||
status=air_quality_data.get('status', 'Unknown'),
|
||||
)
|
||||
|
||||
health = HealthData(
|
||||
steps_today=health_metrics.get('steps', 0),
|
||||
active_calories=health_metrics.get('active_calories', 0),
|
||||
heart_rate=health_metrics.get('heart_rate'),
|
||||
resting_heart_rate=health_metrics.get('resting_heart_rate'),
|
||||
daily_goal_steps=10000, # Default goal
|
||||
)
|
||||
|
||||
# Calculate run suitability
|
||||
result = RunSuitabilityCalculator.calculate(
|
||||
weather=weather,
|
||||
air_quality=air_quality,
|
||||
health=health,
|
||||
current_time=current_time
|
||||
)
|
||||
|
||||
return Response({
|
||||
'status': result.status,
|
||||
'overall_score': result.overall_score,
|
||||
'scores': {
|
||||
'weather': result.weather_score,
|
||||
'air_quality': result.air_quality_score,
|
||||
'health': result.health_score,
|
||||
},
|
||||
'primary_reason': result.primary_reason,
|
||||
'detailed_insights': result.detailed_insights,
|
||||
'time_recommendations': result.time_recommendations,
|
||||
'suggestions': result.suggestions,
|
||||
'weather_data': {
|
||||
'temperature': weather.temperature,
|
||||
'wind_speed': weather.wind_speed,
|
||||
'precipitation': weather.precipitation,
|
||||
'description': weather_data.get('weather_description'),
|
||||
},
|
||||
'air_quality_data': {
|
||||
'pm25': air_quality.pm25,
|
||||
'pm10': air_quality.pm10,
|
||||
'status': air_quality.status,
|
||||
},
|
||||
'health_data': {
|
||||
'steps': health.steps_today,
|
||||
'active_calories': health.active_calories,
|
||||
'heart_rate': health.heart_rate,
|
||||
},
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to calculate run suitability: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def health_insights(self, request):
|
||||
"""
|
||||
Get contextual health insights with environmental correlations.
|
||||
|
||||
Query params:
|
||||
- health_device_id: Device ID for health metrics (required)
|
||||
- city: City name for weather/air quality context (optional)
|
||||
"""
|
||||
health_device_id = request.query_params.get('health_device_id')
|
||||
city = request.query_params.get('city')
|
||||
|
||||
if not health_device_id:
|
||||
return Response(
|
||||
{'error': 'health_device_id parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate health device exists
|
||||
try:
|
||||
Device.objects.get(id=health_device_id)
|
||||
except Device.DoesNotExist:
|
||||
return Response(
|
||||
{'error': f'Health device {health_device_id} not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Get health metrics from telemetry
|
||||
health_metrics = {}
|
||||
health_metric_names = {
|
||||
'steps': ['steps', 'step_count'],
|
||||
'active_calories': ['active_calories', 'calories'],
|
||||
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||
'sleep_duration': ['sleep_duration', 'sleep'],
|
||||
}
|
||||
|
||||
for key, possible_names in health_metric_names.items():
|
||||
for metric_name in possible_names:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(device_id=health_device_id, metric=metric_name)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
health_metrics[key] = float(latest.value)
|
||||
break
|
||||
|
||||
# Get environmental context (optional)
|
||||
env_context = EnvironmentalContext()
|
||||
if city:
|
||||
try:
|
||||
weather_data = weather_client.get_weather_by_city(city)
|
||||
env_context.temperature = weather_data.get('temperature')
|
||||
env_context.humidity = weather_data.get('humidity')
|
||||
env_context.weather_description = weather_data.get('weather_description')
|
||||
except Exception:
|
||||
pass # Weather optional
|
||||
|
||||
try:
|
||||
raw_air_quality = weather_client.get_air_quality(city.lower())
|
||||
air_quality_data = weather_client.parse_air_quality_data(raw_air_quality, city.lower())
|
||||
env_context.pm25 = air_quality_data.get('measurements', {}).get('pm25', {}).get('average')
|
||||
env_context.pm10 = air_quality_data.get('measurements', {}).get('pm10', {}).get('average')
|
||||
env_context.air_quality_status = air_quality_data.get('status')
|
||||
except Exception:
|
||||
pass # Air quality optional
|
||||
|
||||
# Prepare health metrics
|
||||
health = HealthMetrics(
|
||||
steps=int(health_metrics.get('steps', 0)),
|
||||
active_calories=int(health_metrics.get('active_calories', 0)),
|
||||
heart_rate=health_metrics.get('heart_rate'),
|
||||
resting_heart_rate=health_metrics.get('resting_heart_rate'),
|
||||
sleep_duration=health_metrics.get('sleep_duration'),
|
||||
)
|
||||
|
||||
# Calculate insights
|
||||
result = HealthInsightsCalculator.calculate(health, env_context)
|
||||
|
||||
# Format insights for response
|
||||
insights_list = [
|
||||
{
|
||||
'metric': insight.metric,
|
||||
'value': insight.value,
|
||||
'context': insight.context,
|
||||
'correlation': insight.correlation,
|
||||
'recommendation': insight.recommendation,
|
||||
}
|
||||
for insight in result.insights
|
||||
]
|
||||
|
||||
return Response({
|
||||
'health_metrics': result.health_metrics,
|
||||
'environmental_context': result.environmental_context,
|
||||
'insights': insights_list,
|
||||
'correlations': result.correlations,
|
||||
'recommendations': result.recommendations,
|
||||
'trend_indicators': result.trend_indicators,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to calculate health insights: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def daily_briefing(self, request):
|
||||
"""
|
||||
Generate a daily briefing combining environment, schedule, and health data.
|
||||
|
||||
POST body:
|
||||
- briefing_type: 'schedule', 'environment', or 'full' (required)
|
||||
- city: City name for weather/air quality (required)
|
||||
- health_device_id: Device ID for health metrics (optional)
|
||||
- calendar_url: iCal URL for calendar events (optional)
|
||||
- calendar_range_hours: Hours to look ahead for events (default 24)
|
||||
"""
|
||||
briefing_type = request.data.get('briefing_type', 'full')
|
||||
city = request.data.get('city')
|
||||
health_device_id = request.data.get('health_device_id')
|
||||
calendar_url = request.data.get('calendar_url')
|
||||
calendar_range_hours = int(request.data.get('calendar_range_hours', 24))
|
||||
|
||||
if briefing_type not in ('schedule', 'environment', 'full'):
|
||||
return Response(
|
||||
{'error': 'briefing_type must be schedule, environment, or full'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if not city:
|
||||
return Response(
|
||||
{'error': 'city parameter is required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
current_time = timezone.now()
|
||||
|
||||
# Gather indoor data from all devices
|
||||
indoor_data = {}
|
||||
indoor_metrics = ['temperature', 'humidity', 'co2', 'CO2', 'noise', 'light', 'pm25', 'pm2.5']
|
||||
|
||||
for metric in indoor_metrics:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(metric__iexact=metric)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
# Normalize metric names
|
||||
normalized = metric.lower().replace('.', '')
|
||||
if normalized == 'co2':
|
||||
indoor_data['co2_ppm'] = float(latest.value)
|
||||
elif normalized == 'pm25':
|
||||
indoor_data['indoor_pm25'] = float(latest.value)
|
||||
else:
|
||||
indoor_data[f'{normalized}'] = float(latest.value)
|
||||
|
||||
# Gather outdoor data (weather + air quality)
|
||||
outdoor_data = {}
|
||||
try:
|
||||
weather = weather_client.get_weather_by_city(city)
|
||||
outdoor_data['temperature'] = weather.get('temperature')
|
||||
outdoor_data['apparent_temperature'] = weather.get('apparent_temperature')
|
||||
outdoor_data['humidity'] = weather.get('humidity')
|
||||
outdoor_data['weather'] = weather.get('weather_description')
|
||||
outdoor_data['wind_speed'] = weather.get('wind_speed')
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Failed to fetch weather: {e}") if hasattr(self, 'logger') else None
|
||||
|
||||
try:
|
||||
raw_aq = weather_client.get_air_quality(city.lower())
|
||||
aq = weather_client.parse_air_quality_data(raw_aq, city.lower())
|
||||
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:
|
||||
pass # Air quality optional
|
||||
|
||||
# Gather health data if device specified
|
||||
health_data = None
|
||||
if health_device_id:
|
||||
health_data = {}
|
||||
health_metric_names = {
|
||||
'steps': ['steps', 'step_count'],
|
||||
'active_calories': ['active_calories', 'calories'],
|
||||
'heart_rate': ['heart_rate', 'heart_rate_bpm', 'hr'],
|
||||
'resting_heart_rate': ['resting_heart_rate', 'resting_hr'],
|
||||
}
|
||||
|
||||
for key, possible_names in health_metric_names.items():
|
||||
for metric_name in possible_names:
|
||||
latest = (
|
||||
Telemetry.objects
|
||||
.filter(device_id=health_device_id, metric=metric_name)
|
||||
.order_by('-time')
|
||||
.first()
|
||||
)
|
||||
if latest:
|
||||
health_data[key] = float(latest.value)
|
||||
break
|
||||
|
||||
# Parse calendar events if URL provided
|
||||
calendar_events = None
|
||||
if calendar_url:
|
||||
try:
|
||||
cal_response = requests.get(calendar_url, timeout=10)
|
||||
cal_response.raise_for_status()
|
||||
cal_text = cal_response.text
|
||||
|
||||
# Parse iCal using icalendar library or simple parsing
|
||||
calendar_events = self._parse_ical_events(
|
||||
cal_text,
|
||||
current_time,
|
||||
calendar_range_hours
|
||||
)
|
||||
except Exception:
|
||||
pass # Calendar optional
|
||||
|
||||
# Call GPT service
|
||||
result = async_to_sync(gpt_service_client.get_gpt_client().generate_daily_briefing)(
|
||||
briefing_type=briefing_type,
|
||||
current_time=current_time.isoformat(),
|
||||
indoor_data=indoor_data if indoor_data else None,
|
||||
outdoor_data=outdoor_data if outdoor_data else None,
|
||||
health_data=health_data,
|
||||
calendar_events=calendar_events,
|
||||
)
|
||||
|
||||
# Add context data to response
|
||||
result['context'] = {
|
||||
'indoor': indoor_data,
|
||||
'outdoor': outdoor_data,
|
||||
'health': health_data,
|
||||
'calendar_event_count': len(calendar_events) if calendar_events else 0,
|
||||
}
|
||||
|
||||
return Response(result)
|
||||
|
||||
except gpt_service_client.GPTServiceError as e:
|
||||
return Response(
|
||||
{
|
||||
'error': e.message,
|
||||
'details': e.details,
|
||||
'gpt_service_available': False
|
||||
},
|
||||
status=e.status_code or status.HTTP_503_SERVICE_UNAVAILABLE
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{'error': f'Failed to generate daily briefing: {str(e)}'},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def _parse_ical_events(self, ical_text: str, start_time, range_hours: int):
|
||||
"""Parse iCal text and extract events within the time range."""
|
||||
from datetime import timedelta
|
||||
import re
|
||||
|
||||
events = []
|
||||
end_time = start_time + timedelta(hours=range_hours)
|
||||
|
||||
# Simple iCal parsing (handles basic VEVENT blocks)
|
||||
vevent_pattern = re.compile(r'BEGIN:VEVENT.*?END:VEVENT', re.DOTALL)
|
||||
|
||||
for match in vevent_pattern.finditer(ical_text):
|
||||
event_text = match.group()
|
||||
event = {}
|
||||
|
||||
# Extract summary
|
||||
summary_match = re.search(r'SUMMARY[^:]*:(.+?)(?:\r?\n|$)', event_text)
|
||||
if summary_match:
|
||||
event['summary'] = summary_match.group(1).strip()
|
||||
else:
|
||||
event['summary'] = 'Untitled'
|
||||
|
||||
# Extract start time
|
||||
dtstart_match = re.search(r'DTSTART[^:]*:(\d{8}T?\d{0,6}Z?)', event_text)
|
||||
if dtstart_match:
|
||||
dt_str = dtstart_match.group(1)
|
||||
try:
|
||||
if 'T' in dt_str:
|
||||
# DateTime format
|
||||
if dt_str.endswith('Z'):
|
||||
dt = datetime.strptime(dt_str, '%Y%m%dT%H%M%SZ')
|
||||
else:
|
||||
dt = datetime.strptime(dt_str[:15], '%Y%m%dT%H%M%S')
|
||||
event['start'] = dt.strftime('%I:%M %p')
|
||||
else:
|
||||
# Date only (all-day event)
|
||||
dt = datetime.strptime(dt_str, '%Y%m%d')
|
||||
event['start'] = 'All day'
|
||||
|
||||
# Check if event is within range
|
||||
if dt.replace(tzinfo=None) < start_time.replace(tzinfo=None):
|
||||
continue
|
||||
if dt.replace(tzinfo=None) > end_time.replace(tzinfo=None):
|
||||
continue
|
||||
except ValueError:
|
||||
event['start'] = 'TBD'
|
||||
|
||||
# Extract location
|
||||
location_match = re.search(r'LOCATION[^:]*:(.+?)(?:\r?\n|$)', event_text)
|
||||
if location_match:
|
||||
event['location'] = location_match.group(1).strip()
|
||||
|
||||
if event.get('summary'):
|
||||
events.append(event)
|
||||
|
||||
# Sort by start time and limit
|
||||
return events[:15]
|
||||
|
||||
|
||||
# Dashboard Layout Views
|
||||
class DashboardLayoutViewSet(viewsets.ModelViewSet):
|
||||
"""ViewSet for managing dashboard layouts (single-user system)."""
|
||||
|
||||
serializer_class = DashboardLayoutSerializer
|
||||
permission_classes = [permissions.AllowAny] # No auth required for single-user system
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return all layouts (single-user system)."""
|
||||
return DashboardLayout.objects.all()
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def default(self, request):
|
||||
"""Get the default layout."""
|
||||
layout = DashboardLayout.get_default()
|
||||
return Response(self.get_serializer(layout).data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def set_default(self, request, pk=None):
|
||||
"""Set a layout as the default."""
|
||||
layout = self.get_object()
|
||||
# Unset other defaults
|
||||
DashboardLayout.objects.filter(is_default=True).update(is_default=False)
|
||||
# Set this one as default
|
||||
layout.is_default = True
|
||||
layout.save()
|
||||
return Response(self.get_serializer(layout).data)
|
||||
|
||||
|
||||
|
||||
48
iotDashboard/auth_backend.py
Normal file
48
iotDashboard/auth_backend.py
Normal file
@@ -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
|
||||
64
iotDashboard/dashboard_models.py
Normal file
64
iotDashboard/dashboard_models.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Dashboard layout models for persistent storage in PostgreSQL.
|
||||
|
||||
Single-user system: Stores dashboard configuration for the default user.
|
||||
PostgreSQL is chosen over Redis because:
|
||||
- Dashboard layouts are persistent configuration data (not cache)
|
||||
- Need ACID guarantees for data integrity
|
||||
- Low read/write frequency (performance difference negligible)
|
||||
- Already using PostgreSQL for Django
|
||||
"""
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
class DashboardLayout(models.Model):
|
||||
"""Stores dashboard configuration in PostgreSQL.
|
||||
|
||||
Single-user system: Only one default layout is stored.
|
||||
This is persistent configuration data that should survive server restarts.
|
||||
PostgreSQL provides:
|
||||
- ACID guarantees
|
||||
- Complex queries
|
||||
- Backup/restore capabilities
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
default='default',
|
||||
unique=True,
|
||||
help_text="Layout name (e.g., 'default', 'mobile', 'work')"
|
||||
)
|
||||
config = models.JSONField(
|
||||
help_text="Full dashboard configuration JSON"
|
||||
)
|
||||
is_default = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether this is the default layout"
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "dashboard_layouts"
|
||||
indexes = [
|
||||
models.Index(fields=["is_default"]),
|
||||
models.Index(fields=["name"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} (default: {self.is_default})"
|
||||
|
||||
@classmethod
|
||||
def get_default(cls):
|
||||
"""Get the default layout, or create one if it doesn't exist."""
|
||||
layout = cls.objects.filter(is_default=True).first()
|
||||
if layout:
|
||||
return layout
|
||||
# Create default if none exists
|
||||
return cls.objects.create(
|
||||
name='default',
|
||||
config={'widgets': [], 'layout': 'grid', 'refreshInterval': 30000},
|
||||
is_default=True
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -56,6 +56,75 @@ class GPTServiceClient:
|
||||
logger.error(f"GPT service health check failed: {e}")
|
||||
raise
|
||||
|
||||
async def generate_daily_briefing(
|
||||
self,
|
||||
briefing_type: Literal["schedule", "environment", "full"],
|
||||
current_time: str,
|
||||
indoor_data: Optional[Dict[str, Any]] = None,
|
||||
outdoor_data: Optional[Dict[str, Any]] = None,
|
||||
health_data: Optional[Dict[str, Any]] = None,
|
||||
calendar_events: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a daily briefing for office workers.
|
||||
|
||||
Args:
|
||||
briefing_type: 'schedule', 'environment', or 'full'
|
||||
current_time: Current time in ISO format
|
||||
indoor_data: Indoor environment readings
|
||||
outdoor_data: Weather and air quality data
|
||||
health_data: Health/fitness metrics
|
||||
calendar_events: List of upcoming calendar events
|
||||
|
||||
Returns:
|
||||
Dict with status_emoji, status_line, insights, recommendations
|
||||
|
||||
Raises:
|
||||
GPTServiceError: If request fails
|
||||
"""
|
||||
payload = {
|
||||
"briefing_type": briefing_type,
|
||||
"current_time": current_time,
|
||||
}
|
||||
|
||||
if indoor_data:
|
||||
payload["indoor_data"] = indoor_data
|
||||
if outdoor_data:
|
||||
payload["outdoor_data"] = outdoor_data
|
||||
if health_data:
|
||||
payload["health_data"] = health_data
|
||||
if calendar_events:
|
||||
payload["calendar_events"] = calendar_events
|
||||
|
||||
try:
|
||||
logger.info(f"Requesting {briefing_type} daily briefing")
|
||||
response = await self.client.post("/daily-briefing", json=payload)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
logger.info(f"Daily briefing generated successfully")
|
||||
return result
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = e.response.text
|
||||
logger.error(f"GPT service returned error {e.response.status_code}: {error_detail}")
|
||||
raise GPTServiceError(
|
||||
message=f"GPT service error: {error_detail}",
|
||||
status_code=e.response.status_code,
|
||||
details={"response": error_detail}
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
logger.error(f"Failed to connect to GPT service: {e}")
|
||||
raise GPTServiceError(
|
||||
message=f"GPT service unavailable: {str(e)}",
|
||||
status_code=503,
|
||||
details={"error": str(e)}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate daily briefing: {e}")
|
||||
raise GPTServiceError(
|
||||
message=f"Briefing generation failed: {str(e)}",
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
async def analyze_telemetry(
|
||||
self,
|
||||
telemetry_data: List[Dict[str, Any]],
|
||||
|
||||
354
iotDashboard/health_insights.py
Normal file
354
iotDashboard/health_insights.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Health Insights Calculator
|
||||
|
||||
Correlates health metrics with environmental data to provide contextual insights.
|
||||
Shows how weather and air quality affect health metrics.
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthMetrics:
|
||||
"""Current health metrics."""
|
||||
steps: int = 0
|
||||
active_calories: int = 0
|
||||
heart_rate: Optional[float] = None
|
||||
resting_heart_rate: Optional[float] = None
|
||||
sleep_duration: Optional[float] = None # minutes
|
||||
timestamp: Optional[datetime] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class EnvironmentalContext:
|
||||
"""Environmental context for health insights."""
|
||||
temperature: Optional[float] = None
|
||||
humidity: Optional[float] = None
|
||||
pm25: Optional[float] = None
|
||||
pm10: Optional[float] = None
|
||||
air_quality_status: Optional[str] = None
|
||||
weather_description: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthInsight:
|
||||
"""Individual health insight."""
|
||||
metric: str
|
||||
value: float
|
||||
context: str
|
||||
correlation: Optional[str] = None
|
||||
recommendation: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthInsightsResult:
|
||||
"""Health insights with environmental context."""
|
||||
health_metrics: Dict[str, float]
|
||||
environmental_context: Dict[str, Optional[float]]
|
||||
insights: List[HealthInsight]
|
||||
correlations: List[str]
|
||||
recommendations: List[str]
|
||||
trend_indicators: List[str]
|
||||
|
||||
|
||||
class HealthInsightsCalculator:
|
||||
"""Calculate contextual health insights from health and environmental data."""
|
||||
|
||||
# Thresholds for correlations
|
||||
TEMP_HR_CORRELATION_THRESHOLD = 3.0 # bpm per degree C above 22°C
|
||||
PM25_HR_THRESHOLD = 20.0 # μg/m³ - above this may affect HR
|
||||
PM10_HR_THRESHOLD = 30.0 # μg/m³ - above this may affect HR
|
||||
OPTIMAL_TEMP_FOR_ACTIVITY = (18.0, 22.0) # °C
|
||||
|
||||
@staticmethod
|
||||
def analyze_heart_rate(
|
||||
hr: Optional[float],
|
||||
resting_hr: Optional[float],
|
||||
env: EnvironmentalContext
|
||||
) -> List[HealthInsight]:
|
||||
"""Analyze heart rate with environmental context."""
|
||||
insights = []
|
||||
|
||||
if hr is None:
|
||||
return insights
|
||||
|
||||
# Base insight
|
||||
hr_insight = HealthInsight(
|
||||
metric="Heart Rate",
|
||||
value=hr,
|
||||
context=f"Current: {hr:.0f} bpm"
|
||||
)
|
||||
|
||||
# Compare with resting HR
|
||||
if resting_hr is not None:
|
||||
elevation = hr - resting_hr
|
||||
if elevation > 20:
|
||||
hr_insight.context += f" (elevated by {elevation:.0f} bpm from resting)"
|
||||
if env.temperature is not None and env.temperature > 25:
|
||||
hr_insight.correlation = f"High temperature ({env.temperature:.1f}°C) may be contributing to elevated HR"
|
||||
hr_insight.recommendation = "Stay hydrated and avoid intense activity in heat"
|
||||
elif elevation > 10:
|
||||
hr_insight.context += f" (slightly elevated by {elevation:.0f} bpm)"
|
||||
|
||||
# Temperature correlation
|
||||
if env.temperature is not None:
|
||||
if env.temperature > 25:
|
||||
expected_hr_increase = (env.temperature - 22) * HealthInsightsCalculator.TEMP_HR_CORRELATION_THRESHOLD
|
||||
if hr_insight.correlation is None:
|
||||
hr_insight.correlation = f"Temperature ({env.temperature:.1f}°C) may increase HR by ~{expected_hr_increase:.0f} bpm"
|
||||
elif env.temperature < 15:
|
||||
hr_insight.correlation = f"Cool temperature ({env.temperature:.1f}°C) - HR may be lower than usual"
|
||||
|
||||
# Air quality correlation
|
||||
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||
if hr_insight.correlation:
|
||||
hr_insight.correlation += f". Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may also affect HR"
|
||||
else:
|
||||
hr_insight.correlation = f"Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may be affecting HR"
|
||||
hr_insight.recommendation = "Consider indoor activity when air quality improves"
|
||||
|
||||
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||
if hr_insight.correlation and "air quality" not in hr_insight.correlation.lower():
|
||||
hr_insight.correlation += f". High PM10 ({env.pm10:.1f} μg/m³) may affect breathing"
|
||||
|
||||
insights.append(hr_insight)
|
||||
return insights
|
||||
|
||||
@staticmethod
|
||||
def analyze_activity(
|
||||
steps: int,
|
||||
calories: int,
|
||||
env: EnvironmentalContext
|
||||
) -> List[HealthInsight]:
|
||||
"""Analyze activity levels with environmental context."""
|
||||
insights = []
|
||||
|
||||
# Steps insight
|
||||
steps_insight = HealthInsight(
|
||||
metric="Steps",
|
||||
value=steps,
|
||||
context=f"Today: {steps:,} steps"
|
||||
)
|
||||
|
||||
# Activity level assessment
|
||||
if steps >= 10000:
|
||||
steps_insight.context += " - Excellent activity level!"
|
||||
elif steps >= 7500:
|
||||
steps_insight.context += " - Good activity level"
|
||||
elif steps >= 5000:
|
||||
steps_insight.context += " - Moderate activity"
|
||||
else:
|
||||
steps_insight.context += " - Low activity today"
|
||||
if env.temperature is not None:
|
||||
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||
if temp_min <= env.temperature <= temp_max:
|
||||
steps_insight.recommendation = f"Perfect weather ({env.temperature:.1f}°C) - great time for outdoor activity!"
|
||||
elif env.temperature > temp_max:
|
||||
steps_insight.recommendation = f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity"
|
||||
else:
|
||||
steps_insight.recommendation = f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity"
|
||||
|
||||
# Weather correlation
|
||||
if env.weather_description:
|
||||
if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower():
|
||||
if steps < 5000:
|
||||
steps_insight.correlation = "Clear weather - perfect for outdoor activity"
|
||||
elif "rain" in env.weather_description.lower() or "snow" in env.weather_description.lower():
|
||||
steps_insight.correlation = f"Weather: {env.weather_description} - may limit outdoor activity"
|
||||
|
||||
insights.append(steps_insight)
|
||||
|
||||
# Calories insight
|
||||
calories_insight = HealthInsight(
|
||||
metric="Active Calories",
|
||||
value=calories,
|
||||
context=f"Today: {calories:,} kcal"
|
||||
)
|
||||
|
||||
if calories >= 500:
|
||||
calories_insight.context += " - Great calorie burn!"
|
||||
elif calories >= 300:
|
||||
calories_insight.context += " - Good calorie burn"
|
||||
|
||||
insights.append(calories_insight)
|
||||
return insights
|
||||
|
||||
@staticmethod
|
||||
def generate_correlations(
|
||||
health: HealthMetrics,
|
||||
env: EnvironmentalContext
|
||||
) -> List[str]:
|
||||
"""Generate correlation statements."""
|
||||
correlations = []
|
||||
|
||||
# HR vs Temperature
|
||||
if health.heart_rate is not None and env.temperature is not None:
|
||||
if env.temperature > 25:
|
||||
correlations.append(f"Your HR ({health.heart_rate:.0f} bpm) may be elevated due to high temperature ({env.temperature:.1f}°C)")
|
||||
elif env.temperature < 15:
|
||||
correlations.append(f"Cool temperature ({env.temperature:.1f}°C) may result in lower HR than usual")
|
||||
|
||||
# HR vs Air Quality
|
||||
if health.heart_rate is not None:
|
||||
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||
correlations.append(f"Elevated HR may be related to poor air quality (PM2.5: {env.pm25:.1f} μg/m³)")
|
||||
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||
correlations.append(f"High PM10 ({env.pm10:.1f} μg/m³) may affect breathing and HR")
|
||||
|
||||
# Activity vs Weather
|
||||
if health.steps > 0 and env.weather_description:
|
||||
if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower():
|
||||
if health.steps >= 10000:
|
||||
correlations.append("Great activity on a clear day!")
|
||||
elif "rain" in env.weather_description.lower():
|
||||
correlations.append("Rainy weather may have limited outdoor activity")
|
||||
|
||||
# Activity vs Temperature
|
||||
if health.steps > 0 and env.temperature is not None:
|
||||
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||
if temp_min <= env.temperature <= temp_max:
|
||||
if health.steps >= 10000:
|
||||
correlations.append(f"Optimal temperature ({env.temperature:.1f}°C) for high activity")
|
||||
elif env.temperature > temp_max:
|
||||
correlations.append(f"Warm weather ({env.temperature:.1f}°C) may reduce activity levels")
|
||||
|
||||
return correlations
|
||||
|
||||
@staticmethod
|
||||
def generate_recommendations(
|
||||
health: HealthMetrics,
|
||||
env: EnvironmentalContext
|
||||
) -> List[str]:
|
||||
"""Generate personalized recommendations."""
|
||||
recommendations = []
|
||||
|
||||
# Activity recommendations
|
||||
if health.steps < 5000:
|
||||
if env.temperature is not None:
|
||||
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||
if temp_min <= env.temperature <= temp_max:
|
||||
recommendations.append(f"Perfect weather ({env.temperature:.1f}°C) - great time for a walk or run!")
|
||||
elif env.temperature > temp_max:
|
||||
recommendations.append(f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity")
|
||||
else:
|
||||
recommendations.append(f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity")
|
||||
|
||||
# Air quality recommendations
|
||||
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||
recommendations.append(f"Air quality is moderate (PM2.5: {env.pm25:.1f} μg/m³) - sensitive individuals should limit outdoor activity")
|
||||
|
||||
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||
recommendations.append(f"High PM10 ({env.pm10:.1f} μg/m³) - consider indoor alternatives")
|
||||
|
||||
# HR recommendations
|
||||
if health.heart_rate is not None and health.resting_heart_rate is not None:
|
||||
elevation = health.heart_rate - health.resting_heart_rate
|
||||
if elevation > 20:
|
||||
if env.temperature is not None and env.temperature > 25:
|
||||
recommendations.append("Elevated HR in hot weather - stay hydrated and rest if needed")
|
||||
elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||
recommendations.append("Elevated HR may be related to air quality - consider indoor rest")
|
||||
else:
|
||||
recommendations.append("Elevated HR - may indicate stress or fatigue, consider rest")
|
||||
|
||||
# Goal progress
|
||||
if health.steps > 0:
|
||||
goal_progress = (health.steps / 10000) * 100
|
||||
if goal_progress < 50:
|
||||
recommendations.append(f"Daily goal: {goal_progress:.0f}% complete - good time for activity")
|
||||
elif goal_progress >= 100:
|
||||
recommendations.append("Daily goal exceeded - excellent work!")
|
||||
|
||||
return recommendations
|
||||
|
||||
@staticmethod
|
||||
def generate_trend_indicators(
|
||||
health: HealthMetrics,
|
||||
env: EnvironmentalContext
|
||||
) -> List[str]:
|
||||
"""Generate trend indicators (simplified - no historical data yet)."""
|
||||
indicators = []
|
||||
|
||||
# Activity trends (would need historical data for real trends)
|
||||
if health.steps >= 10000:
|
||||
indicators.append("High activity level today")
|
||||
elif health.steps >= 7500:
|
||||
indicators.append("Good activity level")
|
||||
|
||||
# Environmental impact indicators
|
||||
if env.temperature is not None:
|
||||
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||
if temp_min <= env.temperature <= temp_max:
|
||||
indicators.append("Optimal temperature for activity")
|
||||
elif env.temperature > temp_max:
|
||||
indicators.append("Warm weather may reduce activity")
|
||||
|
||||
if env.pm25 is not None and env.pm25 < 15:
|
||||
indicators.append("Good air quality - ideal for outdoor activity")
|
||||
elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||
indicators.append("Moderate air quality - may affect activity")
|
||||
|
||||
return indicators
|
||||
|
||||
@classmethod
|
||||
def calculate(
|
||||
cls,
|
||||
health: HealthMetrics,
|
||||
env: EnvironmentalContext
|
||||
) -> HealthInsightsResult:
|
||||
"""
|
||||
Calculate health insights with environmental context.
|
||||
|
||||
Args:
|
||||
health: Current health metrics
|
||||
env: Environmental context
|
||||
|
||||
Returns:
|
||||
HealthInsightsResult with insights, correlations, and recommendations
|
||||
"""
|
||||
# Analyze individual metrics
|
||||
insights = []
|
||||
insights.extend(cls.analyze_heart_rate(health.heart_rate, health.resting_heart_rate, env))
|
||||
insights.extend(cls.analyze_activity(health.steps, health.active_calories, env))
|
||||
|
||||
# Generate correlations
|
||||
correlations = cls.generate_correlations(health, env)
|
||||
|
||||
# Generate recommendations
|
||||
recommendations = cls.generate_recommendations(health, env)
|
||||
|
||||
# Generate trend indicators
|
||||
trend_indicators = cls.generate_trend_indicators(health, env)
|
||||
|
||||
# Prepare health metrics dict
|
||||
health_metrics = {
|
||||
"steps": health.steps,
|
||||
"active_calories": health.active_calories,
|
||||
"heart_rate": health.heart_rate,
|
||||
"resting_heart_rate": health.resting_heart_rate,
|
||||
}
|
||||
|
||||
# Prepare environmental context dict
|
||||
environmental_context = {
|
||||
"temperature": env.temperature,
|
||||
"humidity": env.humidity,
|
||||
"pm25": env.pm25,
|
||||
"pm10": env.pm10,
|
||||
"air_quality_status": env.air_quality_status,
|
||||
"weather_description": env.weather_description,
|
||||
}
|
||||
|
||||
return HealthInsightsResult(
|
||||
health_metrics=health_metrics,
|
||||
environmental_context=environmental_context,
|
||||
insights=insights,
|
||||
correlations=correlations,
|
||||
recommendations=recommendations,
|
||||
trend_indicators=trend_indicators,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
23
iotDashboard/jwt_serializers.py
Normal file
23
iotDashboard/jwt_serializers.py
Normal file
@@ -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
|
||||
@@ -10,73 +10,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SensorType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=50, unique=True)),
|
||||
("unit", models.CharField(max_length=20)),
|
||||
(
|
||||
"protocol",
|
||||
models.CharField(
|
||||
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||
),
|
||||
),
|
||||
("topic", models.CharField(blank=True, max_length=100, null=True)),
|
||||
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="device",
|
||||
name="humidity",
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="device",
|
||||
name="temperature",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="device",
|
||||
name="protocol",
|
||||
field=models.CharField(
|
||||
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Sensor",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("enabled", models.BooleanField(default=True)),
|
||||
(
|
||||
"device",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="sensors",
|
||||
to="iotDashboard.device",
|
||||
),
|
||||
),
|
||||
(
|
||||
"type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="iotDashboard.sensortype",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
# Note: Device model is now managed=False, so we don't modify it in migrations
|
||||
# SensorType and Sensor models are also not in current models.py
|
||||
# This migration is kept for historical reference but operations are removed
|
||||
# to prevent errors with managed=False models
|
||||
]
|
||||
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
@@ -0,0 +1,82 @@
|
||||
# Generated by Django 5.2.7 on 2025-11-19 22:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('iotDashboard', '0002_sensortype_remove_device_humidity_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DeviceCertificate',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||
('certificate_pem', models.TextField()),
|
||||
('private_key_pem', models.TextField(blank=True, null=True)),
|
||||
('issued_at', models.DateTimeField()),
|
||||
('expires_at', models.DateTimeField()),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'device_certificates',
|
||||
'managed': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DeviceCredential',
|
||||
fields=[
|
||||
('id', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||
('credential_type', models.CharField(max_length=50)),
|
||||
('credential_hash', models.TextField()),
|
||||
('created_at', models.DateTimeField()),
|
||||
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'device_credentials',
|
||||
'managed': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Telemetry',
|
||||
fields=[
|
||||
('time', models.DateTimeField(primary_key=True, serialize=False)),
|
||||
('device_id', models.CharField(db_column='device_id', max_length=255)),
|
||||
('metric', models.CharField(max_length=255)),
|
||||
('value', models.FloatField()),
|
||||
('unit', models.CharField(blank=True, max_length=50, null=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'telemetry',
|
||||
'managed': False,
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='device',
|
||||
options={'managed': False},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DashboardLayout',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(default='default', help_text="Layout name (e.g., 'default', 'mobile', 'work')", max_length=255, unique=True)),
|
||||
('config', models.JSONField(help_text='Full dashboard configuration JSON')),
|
||||
('is_default', models.BooleanField(default=True, help_text='Whether this is the default layout')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'db_table': 'dashboard_layouts',
|
||||
'indexes': [models.Index(fields=['is_default'], name='dashboard_l_is_defa_033b71_idx'), models.Index(fields=['name'], name='dashboard_l_name_c36020_idx')],
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='Sensor',
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='SensorType',
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
|
||||
349
iotDashboard/run_suitability.py
Normal file
349
iotDashboard/run_suitability.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
Run Suitability Calculator
|
||||
|
||||
Combines weather, air quality, and health data to determine if conditions are suitable for running.
|
||||
Provides time-based recommendations and personalized insights.
|
||||
"""
|
||||
|
||||
from typing import List, Optional, Tuple
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, time
|
||||
|
||||
|
||||
@dataclass
|
||||
class WeatherData:
|
||||
"""Weather data for run suitability analysis."""
|
||||
temperature: float
|
||||
apparent_temperature: float
|
||||
wind_speed: float # km/h
|
||||
precipitation: float # mm
|
||||
rain: float # mm
|
||||
weather_code: int
|
||||
humidity: float # %
|
||||
cloud_cover: float # %
|
||||
|
||||
|
||||
@dataclass
|
||||
class AirQualityData:
|
||||
"""Air quality data for run suitability analysis."""
|
||||
pm25: Optional[float] = None
|
||||
pm10: Optional[float] = None
|
||||
status: str = "Unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class HealthData:
|
||||
"""Health data for run suitability analysis."""
|
||||
steps_today: int = 0
|
||||
active_calories: int = 0
|
||||
heart_rate: Optional[float] = None
|
||||
resting_heart_rate: Optional[float] = None
|
||||
daily_goal_steps: int = 10000 # Default goal
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunSuitabilityScore:
|
||||
"""Run suitability results with breakdown."""
|
||||
status: str # "GO", "MODERATE", "NO"
|
||||
overall_score: float # 0-100
|
||||
weather_score: float
|
||||
air_quality_score: float
|
||||
health_score: float
|
||||
primary_reason: str
|
||||
detailed_insights: List[str]
|
||||
time_recommendations: List[str]
|
||||
suggestions: List[str]
|
||||
|
||||
|
||||
class RunSuitabilityCalculator:
|
||||
"""Calculate run suitability from weather, air quality, and health data."""
|
||||
|
||||
# Optimal ranges for running
|
||||
OPTIMAL_TEMP_RANGE = (10.0, 20.0) # °C - ideal for running
|
||||
ACCEPTABLE_TEMP_RANGE = (5.0, 25.0) # °C - acceptable but not ideal
|
||||
MAX_WIND_SPEED = 25.0 # km/h - above this is too windy
|
||||
MAX_PRECIPITATION = 0.5 # mm - light drizzle OK, more is not
|
||||
MAX_PM25 = 35.0 # μg/m³ - WHO unhealthy threshold
|
||||
MAX_PM10 = 50.0 # μg/m³ - WHO unhealthy threshold
|
||||
MODERATE_PM25 = 15.0 # μg/m³ - moderate threshold
|
||||
MODERATE_PM10 = 20.0 # μg/m³ - moderate threshold
|
||||
|
||||
# Time-based recommendations
|
||||
BEST_TIMES = [
|
||||
(time(6, 0), time(8, 0), "Early morning - cool temperatures, low pollution"),
|
||||
(time(18, 0), time(20, 0), "Evening - comfortable temperatures, good visibility"),
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def calculate_weather_score(weather: WeatherData) -> Tuple[float, List[str]]:
|
||||
"""Calculate weather suitability score (0-100)."""
|
||||
score = 100.0
|
||||
issues = []
|
||||
|
||||
# Temperature scoring
|
||||
temp = weather.temperature
|
||||
if RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]:
|
||||
# Perfect temperature
|
||||
pass
|
||||
elif RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[1]:
|
||||
# Acceptable but not ideal
|
||||
if temp < RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0]:
|
||||
deviation = RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] - temp
|
||||
score -= deviation * 5 # -5 points per degree below optimal
|
||||
issues.append(f"Cool ({temp:.1f}°C) - dress warmly")
|
||||
else:
|
||||
deviation = temp - RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]
|
||||
score -= deviation * 3 # -3 points per degree above optimal
|
||||
issues.append(f"Warm ({temp:.1f}°C) - stay hydrated")
|
||||
else:
|
||||
# Too cold or too hot
|
||||
if temp < RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0]:
|
||||
score -= 50
|
||||
issues.append(f"Too cold ({temp:.1f}°C) - not suitable for running")
|
||||
else:
|
||||
score -= 50
|
||||
issues.append(f"Too hot ({temp:.1f}°C) - risk of heat exhaustion")
|
||||
|
||||
# Wind scoring
|
||||
if weather.wind_speed > RunSuitabilityCalculator.MAX_WIND_SPEED:
|
||||
score -= 30
|
||||
issues.append(f"High wind ({weather.wind_speed:.1f} km/h) - difficult conditions")
|
||||
elif weather.wind_speed > 15.0:
|
||||
score -= 10
|
||||
issues.append(f"Moderate wind ({weather.wind_speed:.1f} km/h) - may affect pace")
|
||||
|
||||
# Precipitation scoring
|
||||
if weather.precipitation > RunSuitabilityCalculator.MAX_PRECIPITATION:
|
||||
score -= 40
|
||||
if weather.rain > 0:
|
||||
issues.append(f"Rain ({weather.rain:.1f} mm) - slippery conditions")
|
||||
else:
|
||||
issues.append(f"Precipitation ({weather.precipitation:.1f} mm) - wet conditions")
|
||||
|
||||
# Weather code (snow, thunderstorms, etc.)
|
||||
if weather.weather_code >= 71: # Snow
|
||||
score -= 50
|
||||
issues.append("Snow - unsafe for running")
|
||||
elif weather.weather_code >= 95: # Thunderstorm
|
||||
score -= 60
|
||||
issues.append("Thunderstorm - dangerous conditions")
|
||||
|
||||
score = max(0, min(100, score))
|
||||
return score, issues
|
||||
|
||||
@staticmethod
|
||||
def calculate_air_quality_score(air_quality: AirQualityData) -> Tuple[float, List[str]]:
|
||||
"""Calculate air quality suitability score (0-100)."""
|
||||
score = 100.0
|
||||
issues = []
|
||||
|
||||
# PM2.5 scoring
|
||||
if air_quality.pm25 is not None:
|
||||
if air_quality.pm25 > RunSuitabilityCalculator.MAX_PM25:
|
||||
score -= 50
|
||||
issues.append(f"Poor air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - unhealthy for exercise")
|
||||
elif air_quality.pm25 > RunSuitabilityCalculator.MODERATE_PM25:
|
||||
score -= 20
|
||||
issues.append(f"Moderate air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - sensitive individuals should avoid")
|
||||
|
||||
# PM10 scoring
|
||||
if air_quality.pm10 is not None:
|
||||
if air_quality.pm10 > RunSuitabilityCalculator.MAX_PM10:
|
||||
score -= 50
|
||||
issues.append(f"Poor air quality (PM10: {air_quality.pm10:.1f} μg/m³) - unhealthy for exercise")
|
||||
elif air_quality.pm10 > RunSuitabilityCalculator.MODERATE_PM10:
|
||||
score -= 20
|
||||
issues.append(f"Moderate air quality (PM10: {air_quality.pm10:.1f} μg/m³) - may affect breathing")
|
||||
|
||||
# Status-based scoring
|
||||
status_lower = air_quality.status.lower()
|
||||
if "unhealthy" in status_lower or "hazardous" in status_lower:
|
||||
score = min(score, 30)
|
||||
elif "moderate" in status_lower or "sensitive" in status_lower:
|
||||
score = min(score, 70)
|
||||
|
||||
score = max(0, min(100, score))
|
||||
return score, issues
|
||||
|
||||
@staticmethod
|
||||
def calculate_health_score(health: HealthData) -> Tuple[float, List[str]]:
|
||||
"""Calculate health context score (0-100)."""
|
||||
score = 100.0
|
||||
insights = []
|
||||
|
||||
# Check if user is already very active today
|
||||
if health.steps_today > 15000:
|
||||
score -= 10
|
||||
insights.append("High activity today - consider rest or light activity")
|
||||
elif health.steps_today > 10000:
|
||||
insights.append(f"Good activity level ({health.steps_today:,} steps) - ready for a run")
|
||||
|
||||
# Check daily goal progress
|
||||
goal_progress = (health.steps_today / health.daily_goal_steps) * 100 if health.daily_goal_steps > 0 else 0
|
||||
if goal_progress < 50:
|
||||
insights.append(f"Daily goal: {goal_progress:.0f}% complete - good time for a run")
|
||||
elif goal_progress > 100:
|
||||
insights.append("Daily goal exceeded - great job!")
|
||||
|
||||
# Heart rate context
|
||||
if health.heart_rate is not None:
|
||||
if health.heart_rate > 100:
|
||||
score -= 15
|
||||
insights.append(f"Elevated heart rate ({health.heart_rate:.0f} bpm) - may need rest")
|
||||
elif health.heart_rate > 85:
|
||||
score -= 5
|
||||
insights.append(f"Slightly elevated HR ({health.heart_rate:.0f} bpm) - consider lighter activity")
|
||||
|
||||
if health.resting_heart_rate is not None and health.heart_rate is not None:
|
||||
hr_elevation = health.heart_rate - health.resting_heart_rate
|
||||
if hr_elevation > 20:
|
||||
insights.append("Heart rate significantly elevated - may indicate stress or fatigue")
|
||||
|
||||
score = max(0, min(100, score))
|
||||
return score, insights
|
||||
|
||||
@staticmethod
|
||||
def get_time_recommendations(current_time: Optional[datetime] = None) -> List[str]:
|
||||
"""Get time-based recommendations for running."""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
current_hour = current_time.hour
|
||||
recommendations = []
|
||||
|
||||
# Check if current time is in an optimal range
|
||||
in_optimal_time = False
|
||||
for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES:
|
||||
if start_time.hour <= current_hour < end_time.hour:
|
||||
recommendations.append(f"Current time is ideal: {description}")
|
||||
in_optimal_time = True
|
||||
break
|
||||
|
||||
if not in_optimal_time:
|
||||
# Find next optimal time window
|
||||
next_window = None
|
||||
for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES:
|
||||
if current_hour < start_time.hour:
|
||||
# This window is later today
|
||||
hours_until = start_time.hour - current_hour
|
||||
next_window = (hours_until, start_time, description, "today")
|
||||
break
|
||||
|
||||
# If no window found later today, next window is tomorrow morning
|
||||
if next_window is None:
|
||||
first_start, _, first_desc = RunSuitabilityCalculator.BEST_TIMES[0]
|
||||
hours_until = (24 - current_hour) + first_start.hour
|
||||
next_window = (hours_until, first_start, first_desc, "tomorrow")
|
||||
|
||||
if next_window:
|
||||
hours_until, next_start, description, when = next_window
|
||||
recommendations.append(
|
||||
f"Next optimal time: {next_start.strftime('%I:%M %p')} {when} "
|
||||
f"(in {hours_until} hours) - {description}"
|
||||
)
|
||||
|
||||
return recommendations
|
||||
|
||||
@classmethod
|
||||
def calculate(
|
||||
cls,
|
||||
weather: WeatherData,
|
||||
air_quality: AirQualityData,
|
||||
health: HealthData,
|
||||
current_time: Optional[datetime] = None
|
||||
) -> RunSuitabilityScore:
|
||||
"""
|
||||
Calculate overall run suitability.
|
||||
|
||||
Args:
|
||||
weather: Weather data
|
||||
air_quality: Air quality data
|
||||
health: Health data
|
||||
current_time: Current time for recommendations (default: now)
|
||||
|
||||
Returns:
|
||||
RunSuitabilityScore with status, scores, and insights
|
||||
"""
|
||||
if current_time is None:
|
||||
current_time = datetime.now()
|
||||
|
||||
# Calculate component scores
|
||||
weather_score, weather_issues = cls.calculate_weather_score(weather)
|
||||
air_quality_score, air_quality_issues = cls.calculate_air_quality_score(air_quality)
|
||||
health_score, health_insights = cls.calculate_health_score(health)
|
||||
|
||||
# Weighted overall score
|
||||
overall_score = (
|
||||
weather_score * 0.40 +
|
||||
air_quality_score * 0.35 +
|
||||
health_score * 0.25
|
||||
)
|
||||
|
||||
# Determine status
|
||||
if overall_score >= 75:
|
||||
status = "GO"
|
||||
elif overall_score >= 50:
|
||||
status = "MODERATE"
|
||||
else:
|
||||
status = "NO"
|
||||
|
||||
# Primary reason
|
||||
primary_reason = "Conditions are perfect for running!"
|
||||
if status == "NO":
|
||||
if weather_score < 50:
|
||||
primary_reason = weather_issues[0] if weather_issues else "Weather conditions are poor"
|
||||
elif air_quality_score < 50:
|
||||
primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is poor"
|
||||
else:
|
||||
primary_reason = "Conditions are not suitable for running"
|
||||
elif status == "MODERATE":
|
||||
if weather_score < 70:
|
||||
primary_reason = weather_issues[0] if weather_issues else "Weather conditions are moderate"
|
||||
elif air_quality_score < 70:
|
||||
primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is moderate"
|
||||
else:
|
||||
primary_reason = "Conditions are okay, but not ideal"
|
||||
|
||||
# Combine all insights
|
||||
detailed_insights = []
|
||||
detailed_insights.extend(weather_issues)
|
||||
detailed_insights.extend(air_quality_issues)
|
||||
detailed_insights.extend(health_insights)
|
||||
|
||||
# Time recommendations
|
||||
time_recommendations = cls.get_time_recommendations(current_time)
|
||||
|
||||
# Suggestions
|
||||
suggestions = []
|
||||
if status == "GO":
|
||||
suggestions.append("Perfect conditions - enjoy your run!")
|
||||
if health.steps_today < health.daily_goal_steps * 0.5:
|
||||
suggestions.append("Great time to work toward your daily step goal")
|
||||
elif status == "MODERATE":
|
||||
suggestions.append("Conditions are acceptable - consider shorter or easier route")
|
||||
if weather_score < 70:
|
||||
suggestions.append("Dress appropriately for weather conditions")
|
||||
if air_quality_score < 70:
|
||||
suggestions.append("Sensitive individuals should consider indoor alternatives")
|
||||
else:
|
||||
suggestions.append("Consider indoor workout or wait for better conditions")
|
||||
if weather_score < 50:
|
||||
suggestions.append("Check weather forecast for better times")
|
||||
if air_quality_score < 50:
|
||||
suggestions.append("Air quality should improve later - check back in a few hours")
|
||||
|
||||
return RunSuitabilityScore(
|
||||
status=status,
|
||||
overall_score=round(overall_score, 1),
|
||||
weather_score=round(weather_score, 1),
|
||||
air_quality_score=round(air_quality_score, 1),
|
||||
health_score=round(health_score, 1),
|
||||
primary_reason=primary_reason,
|
||||
detailed_insights=detailed_insights,
|
||||
time_recommendations=time_recommendations,
|
||||
suggestions=suggestions,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -50,11 +50,14 @@ INSTALLED_APPS = [
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"rest_framework_simplejwt",
|
||||
"corsheaders",
|
||||
"iotDashboard",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -150,4 +153,32 @@ REST_FRAMEWORK = {
|
||||
'DEFAULT_PARSER_CLASSES': [
|
||||
'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', # 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",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:3000",
|
||||
"http://127.0.0.1:3000",
|
||||
"https://lyncis.ferdzo.xyz",
|
||||
"http://lyncis.ferdzo.xyz",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
@@ -18,31 +18,28 @@ 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")),
|
||||
|
||||
# Main dashboard
|
||||
path("", views.chart, name="index"),
|
||||
path("chart/", views.chart, name="chart"),
|
||||
|
||||
# Device management
|
||||
path("devices/", views.device_list, name="device_list"),
|
||||
path("devices/add/", views.add_device, name="add_device"),
|
||||
path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
||||
path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
||||
|
||||
# Certificate management (MQTT devices only)
|
||||
path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
||||
path("devices/<str:device_id>/certificate/renew/", views.renew_certificate, name="renew_certificate"),
|
||||
|
||||
# Telemetry data API
|
||||
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
|
||||
|
||||
# Legacy/utility endpoints
|
||||
path("devices_api/", views.devices_api, name="devices_api"),
|
||||
path("logout/", views.logout_view, name="logout"),
|
||||
# Legacy template views - DISABLED (using React frontend)
|
||||
# path("", views.chart, name="index"),
|
||||
# path("chart/", views.chart, name="chart"),
|
||||
# path("devices/", views.device_list, name="device_list"),
|
||||
# path("devices/add/", views.add_device, name="add_device"),
|
||||
# path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
||||
# path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
||||
# path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
||||
# path("devices/<str:device_id>/certificate/renew/", views.renew_certificate, name="renew_certificate"),
|
||||
# path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
|
||||
# path("devices_api/", views.devices_api, name="devices_api"),
|
||||
# path("logout/", views.logout_view, name="logout"),
|
||||
]
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"python-dotenv>=1.2.1",
|
||||
"redis>=7.0.1",
|
||||
"requests>=2.32.5",
|
||||
"uvicorn>=0.38.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
# MQTT Data Generator
|
||||
|
||||
A Python script that simulates realistic environmental sensor data and publishes it to your MQTT broker for testing the IoT Dashboard.
|
||||
|
||||
## Features
|
||||
|
||||
✅ **8 Environmental Metrics**: Temperature, Humidity, CO2, Pressure, Light, Noise, PM2.5, VOC
|
||||
✅ **Realistic Patterns**: Daily cycles (office hours, night time)
|
||||
✅ **Random Walk**: Natural sensor drift and variations
|
||||
✅ **Anomaly Injection**: 5% chance of anomalies for testing alerts
|
||||
✅ **Self-Correcting**: Values drift back toward optimal ranges (simulates HVAC)
|
||||
✅ **TLS/mTLS Support**: Secure connections with certificates
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install paho-mqtt
|
||||
|
||||
# Or using uv
|
||||
uv pip install paho-mqtt
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Basic Usage (Localhost, No TLS)
|
||||
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py --device-id office-sensor-01 --interval 5
|
||||
```
|
||||
|
||||
### 2. With Specific Metrics
|
||||
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py \
|
||||
--device-id lab-sensor \
|
||||
--metrics temperature humidity co2 \
|
||||
--interval 10
|
||||
```
|
||||
|
||||
### 3. With TLS (Port 8883)
|
||||
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py \
|
||||
--device-id secure-sensor \
|
||||
--broker localhost \
|
||||
--port 8883 \
|
||||
--tls \
|
||||
--ca-cert infrastructure/mosquitto/certs/ca.crt \
|
||||
--client-cert path/to/device.crt \
|
||||
--client-key path/to/device.key \
|
||||
--interval 5
|
||||
```
|
||||
|
||||
### 4. Limited Duration (Testing)
|
||||
|
||||
```bash
|
||||
# Run for 5 minutes
|
||||
python scripts/mqtt_data_generator.py \
|
||||
--device-id test-sensor \
|
||||
--duration 300 \
|
||||
--interval 2
|
||||
```
|
||||
|
||||
## Command Line Arguments
|
||||
|
||||
| Argument | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `--broker` | No | localhost | MQTT broker hostname |
|
||||
| `--port` | No | 1883 | MQTT broker port (8883 for TLS) |
|
||||
| `--device-id` | **Yes** | - | Device ID for MQTT topics |
|
||||
| `--metrics` | No | all | Specific metrics to publish |
|
||||
| `--interval` | No | 5 | Publish interval in seconds |
|
||||
| `--duration` | No | 0 | Run duration in seconds (0 = infinite) |
|
||||
| `--tls` | No | False | Enable TLS/SSL encryption |
|
||||
| `--ca-cert` | No | - | Path to CA certificate |
|
||||
| `--client-cert` | No | - | Path to client certificate (mTLS) |
|
||||
| `--client-key` | No | - | Path to client private key (mTLS) |
|
||||
|
||||
## Available Metrics
|
||||
|
||||
All metrics follow the standards in `services/gpt_service/METRICS_REFERENCE.md`:
|
||||
|
||||
- **temperature** - Indoor temperature (15-28°C)
|
||||
- **humidity** - Relative humidity (20-70%)
|
||||
- **co2** - Carbon dioxide concentration (400-1500 ppm)
|
||||
- **pressure** - Atmospheric pressure (1000-1030 hPa)
|
||||
- **light** - Illuminance (200-1000 lux)
|
||||
- **noise** - Sound level (30-70 dB)
|
||||
- **pm25** - Fine particulate matter (0-50 µg/m³)
|
||||
- **voc** - Volatile organic compounds (0-500 ppb)
|
||||
|
||||
## MQTT Topic Format
|
||||
|
||||
Published to standard format: `devices/{device_id}/{metric}`
|
||||
|
||||
Examples:
|
||||
- `devices/office-sensor-01/temperature`
|
||||
- `devices/office-sensor-01/humidity`
|
||||
- `devices/lab-sensor/co2`
|
||||
|
||||
## Data Patterns
|
||||
|
||||
### Daily Cycles
|
||||
- **Business hours (9-17)**: Higher CO2, temperature, noise
|
||||
- **Night time (22-6)**: Lower light, CO2, noise
|
||||
- **All day**: Natural variations within ranges
|
||||
|
||||
### Realistic Behavior
|
||||
- **Random walk**: Small incremental changes
|
||||
- **Self-correcting**: HVAC-like drift toward optimal ranges
|
||||
- **Anomalies**: 5% chance of spikes/drops for alert testing
|
||||
|
||||
### Example Values
|
||||
|
||||
```
|
||||
[14:23:45] Iteration 1
|
||||
📊 temperature: 21.34 °C -> devices/office-sensor-01/temperature
|
||||
📊 humidity: 45.67 % -> devices/office-sensor-01/humidity
|
||||
📊 co2: 678.0 ppm -> devices/office-sensor-01/co2
|
||||
📊 pressure: 1015.23 hPa -> devices/office-sensor-01/pressure
|
||||
📊 light: 456.0 lux -> devices/office-sensor-01/light
|
||||
📊 noise: 42.5 dB -> devices/office-sensor-01/noise
|
||||
📊 pm25: 8.3 µg/m³ -> devices/office-sensor-01/pm25
|
||||
📊 voc: 120.0 ppb -> devices/office-sensor-01/voc
|
||||
```
|
||||
|
||||
## Multi-Device Simulation
|
||||
|
||||
Run multiple instances with different device IDs:
|
||||
|
||||
```bash
|
||||
# Terminal 1 - Office sensor
|
||||
python scripts/mqtt_data_generator.py --device-id office-01 &
|
||||
|
||||
# Terminal 2 - Lab sensor
|
||||
python scripts/mqtt_data_generator.py --device-id lab-01 &
|
||||
|
||||
# Terminal 3 - Warehouse sensor
|
||||
python scripts/mqtt_data_generator.py --device-id warehouse-01 &
|
||||
```
|
||||
|
||||
## Testing Scenarios
|
||||
|
||||
### Normal Operations
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py \
|
||||
--device-id normal-sensor \
|
||||
--interval 5
|
||||
```
|
||||
|
||||
### High-Frequency Monitoring
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py \
|
||||
--device-id fast-sensor \
|
||||
--interval 1
|
||||
```
|
||||
|
||||
### Limited Metrics (CO2 monitoring)
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py \
|
||||
--device-id co2-monitor \
|
||||
--metrics co2 temperature humidity \
|
||||
--interval 10
|
||||
```
|
||||
|
||||
## Integration with IoT Dashboard
|
||||
|
||||
1. **Register device** (if using device_manager):
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/devices/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"Office Sensor","location":"Building A - Floor 2"}'
|
||||
```
|
||||
|
||||
2. **Start data generator** with the device ID:
|
||||
```bash
|
||||
python scripts/mqtt_data_generator.py --device-id <device_id>
|
||||
```
|
||||
|
||||
3. **View in dashboard**: Data will appear in the frontend automatically
|
||||
|
||||
4. **Test AI analysis**: Use the AI Insights widget to analyze patterns
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Connection Refused
|
||||
- Check MQTT broker is running: `docker compose -f infrastructure/compose.yml ps`
|
||||
- Verify port: 1883 (plain) or 8883 (TLS)
|
||||
|
||||
### TLS Certificate Errors
|
||||
- Ensure CA certificate path is correct
|
||||
- For mTLS, verify client cert/key match device registration
|
||||
- Check certificate hasn't expired
|
||||
|
||||
### No Data in Dashboard
|
||||
- Verify mqtt_ingestion service is running
|
||||
- Check Redis stream: `redis-cli XLEN mqtt:ingestion`
|
||||
- Verify db_write service is running
|
||||
- Check device_id matches registered device
|
||||
|
||||
### High CPU Usage
|
||||
- Increase `--interval` value
|
||||
- Reduce number of metrics
|
||||
- Run fewer instances
|
||||
|
||||
## Performance
|
||||
|
||||
- **CPU**: ~1-2% per instance at 5s interval
|
||||
- **Memory**: ~20MB per instance
|
||||
- **Network**: ~100 bytes per metric per publish
|
||||
- **Recommended**: Max 10 instances on a single machine
|
||||
|
||||
## Example Output
|
||||
|
||||
```
|
||||
============================================================
|
||||
🌡️ MQTT Environmental Sensor Data Generator
|
||||
============================================================
|
||||
Device ID: office-sensor-01
|
||||
Metrics: temperature, humidity, co2, pressure, light, noise, pm25, voc
|
||||
Interval: 5s
|
||||
Duration: Infinite
|
||||
============================================================
|
||||
✓ Connected to MQTT broker at localhost:1883
|
||||
|
||||
[14:23:45] Iteration 1
|
||||
📊 temperature: 21.34 °C -> devices/office-sensor-01/temperature
|
||||
📊 humidity: 45.67 % -> devices/office-sensor-01/humidity
|
||||
📊 co2: 678.0 ppm -> devices/office-sensor-01/co2
|
||||
📊 pressure: 1015.23 hPa -> devices/office-sensor-01/pressure
|
||||
📊 light: 456.0 lux -> devices/office-sensor-01/light
|
||||
📊 noise: 42.5 dB -> devices/office-sensor-01/noise
|
||||
📊 pm25: 8.3 µg/m³ -> devices/office-sensor-01/pm25
|
||||
📊 voc: 120.0 ppb -> devices/office-sensor-01/voc
|
||||
|
||||
[14:23:50] Iteration 2
|
||||
...
|
||||
```
|
||||
|
||||
## Stopping the Generator
|
||||
|
||||
- **Ctrl+C**: Graceful shutdown
|
||||
- **Automatic**: If `--duration` specified
|
||||
|
||||
## License
|
||||
|
||||
Part of the IoT Dashboard project.
|
||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
COPY --from=builder /app/src/ /app/src/
|
||||
|
||||
COPY --from=builder /app/*.py /app/
|
||||
|
||||
RUN adduser -D -u 1000 appuser && \
|
||||
|
||||
@@ -6,4 +6,5 @@ __pycache__/
|
||||
*.pyd
|
||||
*.crl
|
||||
*.crt
|
||||
*.pem
|
||||
*.pem
|
||||
*.sh
|
||||
|
||||
34
services/device_manager/gen_ca.sh
Normal file
34
services/device_manager/gen_ca.sh
Normal file
@@ -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)"
|
||||
@@ -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"
|
||||
|
||||
7
services/gpt_service/.dockerignore
Normal file
7
services/gpt_service/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env*
|
||||
.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.log
|
||||
11
services/gpt_service/Dockerfile
Normal file
11
services/gpt_service/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN uv sync
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uv", "run", "uvicorn","main:app", "--host", "0.0.0.0" ,"--port", "8001"]
|
||||
@@ -1,5 +1,6 @@
|
||||
from openai import OpenAI
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
|
||||
import logging
|
||||
@@ -372,3 +373,245 @@ Keep all text concise: summary under 50 words, each item under 20 words.""",
|
||||
except Exception as e:
|
||||
self.logger.error(f"Analysis failed: {str(e)}")
|
||||
raise
|
||||
|
||||
async def generate_daily_briefing(
|
||||
self,
|
||||
briefing_type: str,
|
||||
current_time: str,
|
||||
indoor_data: Dict[str, Any] | None = None,
|
||||
outdoor_data: Dict[str, Any] | None = None,
|
||||
health_data: Dict[str, Any] | None = None,
|
||||
calendar_events: List[Dict[str, Any]] | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a daily briefing for office workers.
|
||||
|
||||
Args:
|
||||
briefing_type: 'schedule', 'environment', or 'full'
|
||||
current_time: Current time in ISO format
|
||||
indoor_data: Indoor environment readings (temp, humidity, co2, etc.)
|
||||
outdoor_data: Weather and air quality data
|
||||
health_data: Health/fitness metrics (steps, heart rate, etc.)
|
||||
calendar_events: List of upcoming calendar events
|
||||
|
||||
Returns:
|
||||
Dict with status_line, status_emoji, insights, recommendations, context
|
||||
"""
|
||||
try:
|
||||
# Build context sections
|
||||
context_sections = []
|
||||
|
||||
# Current time context
|
||||
try:
|
||||
dt = datetime.fromisoformat(current_time.replace('Z', '+00:00'))
|
||||
time_str = dt.strftime("%A, %B %d at %I:%M %p")
|
||||
except (ValueError, AttributeError):
|
||||
time_str = current_time
|
||||
context_sections.append(f"Current Time: {time_str}")
|
||||
|
||||
# Indoor environment
|
||||
if indoor_data:
|
||||
indoor_lines = ["Indoor Environment:"]
|
||||
for key, value in indoor_data.items():
|
||||
if value is not None:
|
||||
indoor_lines.append(f" - {key}: {value}")
|
||||
context_sections.append("\n".join(indoor_lines))
|
||||
|
||||
# Outdoor conditions
|
||||
if outdoor_data:
|
||||
outdoor_lines = ["Outdoor Conditions:"]
|
||||
for key, value in outdoor_data.items():
|
||||
if value is not None:
|
||||
outdoor_lines.append(f" - {key}: {value}")
|
||||
context_sections.append("\n".join(outdoor_lines))
|
||||
|
||||
# Health metrics
|
||||
if health_data:
|
||||
health_lines = ["Health & Activity:"]
|
||||
for key, value in health_data.items():
|
||||
if value is not None:
|
||||
health_lines.append(f" - {key}: {value}")
|
||||
context_sections.append("\n".join(health_lines))
|
||||
|
||||
# Calendar events
|
||||
if calendar_events:
|
||||
cal_lines = ["Upcoming Schedule:"]
|
||||
for event in calendar_events[:10]: # Limit to 10 events
|
||||
start = event.get('start', 'TBD')
|
||||
summary = event.get('summary', 'Untitled')
|
||||
location = event.get('location', '')
|
||||
loc_str = f" at {location}" if location else ""
|
||||
cal_lines.append(f" - {start}: {summary}{loc_str}")
|
||||
context_sections.append("\n".join(cal_lines))
|
||||
|
||||
context = "\n\n".join(context_sections)
|
||||
|
||||
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.
|
||||
|
||||
{context}
|
||||
|
||||
Environmental thresholds for reference:
|
||||
- CO2 > 1000ppm: cognitive performance drops, ventilate before focused work
|
||||
- PM2.5 > 35: poor outdoor air, avoid outdoor activities
|
||||
- Temperature 20-24°C: optimal for focus work
|
||||
- Steps goal: 10,000/day, take breaks every 2 hours
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status_emoji": "🟢|🟡|🔴",
|
||||
"status_line": "One sentence summary of schedule/activity outlook",
|
||||
"insights": [
|
||||
"Actionable insight tied to specific time or event",
|
||||
"Another insight about activity or scheduling",
|
||||
"Insight about outdoor activity timing based on conditions"
|
||||
],
|
||||
"recommendations": [
|
||||
"Primary recommendation with specific timing",
|
||||
"Secondary recommendation"
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- Tie insights to SPECIFIC upcoming events or time slots
|
||||
- Suggest activity breaks during schedule gaps
|
||||
- Recommend outdoor activity timing based on air quality
|
||||
- Keep each insight under 25 words
|
||||
- Be specific about times (e.g., "before your 2pm meeting" not "later today")
|
||||
- Focus on productivity, movement, and work-life balance""",
|
||||
|
||||
"environment": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||
Generate an Environment Briefing focused on indoor workspace conditions.
|
||||
|
||||
{context}
|
||||
|
||||
Environmental thresholds and their impacts:
|
||||
- CO2 400-800ppm: optimal | 800-1000ppm: acceptable | >1000ppm: ventilate (affects cognition)
|
||||
- Temperature 20-22°C: optimal | 18-24°C: acceptable | outside: adjust
|
||||
- Humidity 40-60%: optimal | <30%: too dry | >70%: too humid
|
||||
- PM2.5 0-12: good | 12-35: moderate | >35: unhealthy
|
||||
- Light 400-500 lux: optimal for desk work
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status_emoji": "🟢|🟡|🔴",
|
||||
"status_line": "One sentence summary of workspace conditions",
|
||||
"insights": [
|
||||
"Insight about most important environmental factor with action",
|
||||
"Insight about another condition affecting work",
|
||||
"Insight about ventilation or air quality"
|
||||
],
|
||||
"recommendations": [
|
||||
"Primary action to improve workspace",
|
||||
"Secondary recommendation"
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- If CO2 is high, mention opening windows BEFORE the next meeting/focused work
|
||||
- Tie environmental actions to upcoming activities when possible
|
||||
- Be specific about what to do (e.g., "open window" not "improve ventilation")
|
||||
- Mention how conditions affect cognitive performance
|
||||
- Keep each insight under 25 words""",
|
||||
|
||||
"full": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||
Generate a comprehensive Daily Briefing combining schedule, environment, and health.
|
||||
|
||||
{context}
|
||||
|
||||
Environmental thresholds:
|
||||
- CO2 > 1000ppm: cognitive performance drops
|
||||
- PM2.5 > 35: poor outdoor air quality
|
||||
- Temperature 20-24°C: optimal for focus
|
||||
|
||||
Health targets:
|
||||
- 10,000 steps/day
|
||||
- Movement break every 2 hours
|
||||
- Heart rate zones for activity planning
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status_emoji": "🟢|🟡|🔴",
|
||||
"status_line": "One sentence overall summary",
|
||||
"insights": [
|
||||
"Most important cross-domain insight (environment + schedule)",
|
||||
"Health/activity insight tied to schedule",
|
||||
"Environmental condition affecting upcoming work",
|
||||
"Outdoor activity timing recommendation"
|
||||
],
|
||||
"recommendations": [
|
||||
"Primary recommendation combining multiple factors",
|
||||
"Secondary recommendation"
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- Connect environment to schedule (e.g., "ventilate before your debugging session")
|
||||
- Suggest activity timing based on air quality AND schedule gaps
|
||||
- Prioritize actionable insights over status reports
|
||||
- Be specific about times and actions
|
||||
- Keep each insight under 25 words"""
|
||||
}
|
||||
|
||||
prompt = prompts.get(briefing_type, prompts["full"])
|
||||
|
||||
system_prompt = """You are an expert wellness coach for knowledge workers. You help office workers (especially software engineers and tech/finance professionals) maintain optimal productivity and health by providing actionable, timing-specific advice.
|
||||
|
||||
Your insights should:
|
||||
1. Be tied to specific times or upcoming events
|
||||
2. Explain WHY something matters (e.g., "CO2 affects focus")
|
||||
3. Give specific actions (e.g., "open the window now" not "improve air")
|
||||
4. Consider work patterns (deep work, meetings, breaks)
|
||||
|
||||
Always respond with valid JSON only, no markdown formatting."""
|
||||
|
||||
self.logger.info(f"Generating {briefing_type} briefing")
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=1000
|
||||
)
|
||||
|
||||
result_text = response.choices[0].message.content
|
||||
self.logger.info(f"Briefing generated. Tokens used: {response.usage.total_tokens}")
|
||||
|
||||
# Parse JSON response
|
||||
import json
|
||||
# Clean potential markdown wrapping
|
||||
if result_text.startswith("```"):
|
||||
result_text = result_text.split("```")[1]
|
||||
if result_text.startswith("json"):
|
||||
result_text = result_text[4:]
|
||||
result_text = result_text.strip()
|
||||
|
||||
result = json.loads(result_text)
|
||||
|
||||
return {
|
||||
"status_emoji": result.get("status_emoji", "🟢"),
|
||||
"status_line": result.get("status_line", "Briefing generated"),
|
||||
"insights": result.get("insights", []),
|
||||
"recommendations": result.get("recommendations", []),
|
||||
"briefing_type": briefing_type,
|
||||
"generated_at": current_time,
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Failed to parse briefing JSON: {e}")
|
||||
self.logger.error(f"Raw response: {result_text}")
|
||||
return {
|
||||
"status_emoji": "⚠️",
|
||||
"status_line": "Briefing generated with formatting issues",
|
||||
"insights": [result_text[:200] if result_text else "Unable to generate insights"],
|
||||
"recommendations": [],
|
||||
"briefing_type": briefing_type,
|
||||
"generated_at": current_time,
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Briefing generation failed: {str(e)}")
|
||||
raise
|
||||
|
||||
@@ -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...")
|
||||
|
||||
|
||||
@@ -48,6 +46,34 @@ class AnalyzeResponse(BaseModel):
|
||||
data_points_analyzed: int
|
||||
|
||||
|
||||
class CalendarEvent(BaseModel):
|
||||
summary: str
|
||||
start: str
|
||||
end: str | None = None
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class DailyBriefingRequest(BaseModel):
|
||||
briefing_type: Literal["schedule", "environment", "full"] = Field(
|
||||
default="full",
|
||||
description="Type of briefing to generate"
|
||||
)
|
||||
current_time: str = Field(..., description="Current time in ISO format")
|
||||
indoor_data: Dict[str, Any] | None = Field(None, description="Indoor environment readings")
|
||||
outdoor_data: Dict[str, Any] | None = Field(None, description="Weather and air quality data")
|
||||
health_data: Dict[str, Any] | None = Field(None, description="Health/fitness metrics")
|
||||
calendar_events: List[CalendarEvent] | None = Field(None, description="Upcoming calendar events")
|
||||
|
||||
|
||||
class DailyBriefingResponse(BaseModel):
|
||||
status_emoji: str
|
||||
status_line: str
|
||||
insights: List[str]
|
||||
recommendations: List[str]
|
||||
briefing_type: str
|
||||
generated_at: str
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "gpt_service"}
|
||||
@@ -68,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,
|
||||
@@ -90,4 +114,39 @@ async def analyze_telemetry(request: AnalyzeRequest):
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Analysis failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/daily-briefing", response_model=DailyBriefingResponse)
|
||||
async def generate_daily_briefing(request: DailyBriefingRequest):
|
||||
"""
|
||||
Generate a daily briefing for office workers.
|
||||
Combines environment, schedule, and health data into actionable insights.
|
||||
"""
|
||||
try:
|
||||
if not gpt_service:
|
||||
raise HTTPException(status_code=503, detail="GPT service not initialized")
|
||||
|
||||
logger.info(f"Generating {request.briefing_type} briefing")
|
||||
|
||||
calendar_events = None
|
||||
if request.calendar_events:
|
||||
calendar_events = [event.model_dump() for event in request.calendar_events]
|
||||
|
||||
result = await gpt_service.generate_daily_briefing(
|
||||
briefing_type=request.briefing_type,
|
||||
current_time=request.current_time,
|
||||
indoor_data=request.indoor_data,
|
||||
outdoor_data=request.outdoor_data,
|
||||
health_data=request.health_data,
|
||||
calendar_events=calendar_events,
|
||||
)
|
||||
|
||||
return DailyBriefingResponse(**result)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Briefing generation failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Briefing generation failed: {str(e)}")
|
||||
@@ -8,5 +8,6 @@ dependencies = [
|
||||
"fastapi>=0.121.0",
|
||||
"openai>=2.6.1",
|
||||
"pydantic>=2.12.3",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
COPY --from=builder /app/src/ /app/src/
|
||||
|
||||
COPY --from=builder /app/*.py /app/
|
||||
|
||||
RUN adduser -D -u 1000 appuser && \
|
||||
|
||||
@@ -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
|
||||
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
|
||||
BIN
thesis/Miscellaneous/Logo-Finki.jpg
Normal file
BIN
thesis/Miscellaneous/Logo-Finki.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
thesis/Miscellaneous/Logo-Ukim.png
Normal file
BIN
thesis/Miscellaneous/Logo-Ukim.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
thesis/Miscellaneous/architecture.png
Normal file
BIN
thesis/Miscellaneous/architecture.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 171 KiB |
BIN
thesis/Miscellaneous/er_diagram.png
Normal file
BIN
thesis/Miscellaneous/er_diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
55
thesis/Source files/00_titlepage.tex
Normal file
55
thesis/Source files/00_titlepage.tex
Normal file
@@ -0,0 +1,55 @@
|
||||
\begin{titlepage}
|
||||
|
||||
\begin{center}
|
||||
\setstretch{1.5}
|
||||
|
||||
% Лого на УКИМ и ФИНКИ
|
||||
\begin{figure}[h]
|
||||
\centering
|
||||
\begin{minipage}{3cm}
|
||||
\centering
|
||||
\includegraphics[width=3cm]{Miscellaneous/Logo-Ukim.png}
|
||||
\end{minipage}
|
||||
\begin{minipage}{3cm}
|
||||
\centering
|
||||
\includegraphics[width=3cm]{Miscellaneous/Logo-Finki.jpg}
|
||||
\end{minipage}
|
||||
\end{figure}
|
||||
|
||||
% Име на универзитет и факултет
|
||||
\vspace{1em}
|
||||
{\Large \UniversityName} \\
|
||||
{\Large \FacultyName} \\
|
||||
|
||||
% Тип на проект
|
||||
\vspace{2em}
|
||||
{\Large \textsc{\ProjectType}} \\
|
||||
|
||||
\vfill
|
||||
|
||||
% Наслов на проект
|
||||
{\Huge \textbf{\ProjectTitle}} \\
|
||||
|
||||
\vfill
|
||||
|
||||
\setstretch{1}
|
||||
% Ментор на проект
|
||||
\begin{minipage}[t]{.49\textwidth}
|
||||
\large \textbf{Ментор}\par
|
||||
\ProjectMentor
|
||||
\end{minipage}
|
||||
\hfill
|
||||
% Автор на проект
|
||||
\begin{minipage}[t]{.45\textwidth}
|
||||
\large \hfill\textbf{Кандидат}\par
|
||||
\hfill \ProjectCandidate \par
|
||||
\hfill \ProjectCandidateIndex
|
||||
\end{minipage}
|
||||
|
||||
% Датум на изработка на проект
|
||||
\vspace{2cm}
|
||||
{\large \ProjectDate \vspace{-2mm}}
|
||||
\rule{\textwidth}{0.5mm}
|
||||
\end{center}
|
||||
|
||||
\end{titlepage}
|
||||
41
thesis/Source files/01_abstract.tex
Normal file
41
thesis/Source files/01_abstract.tex
Normal file
@@ -0,0 +1,41 @@
|
||||
\newpage
|
||||
\pagenumbering{roman}
|
||||
\setcounter{page}{2}
|
||||
|
||||
\phantomsection
|
||||
\addcontentsline{toc}{section}{Апстракт}
|
||||
|
||||
\begin{center}
|
||||
{\Large \textbf{Апстракт}}
|
||||
\end{center}
|
||||
|
||||
\vspace{0.5cm}
|
||||
|
||||
Во овој дипломски труд обработувам практична и актуелна тема од
|
||||
денешницата: следење и подобрување на здравата работна околина и
|
||||
поддршка на здравјето на вработените во модерните работни простори.
|
||||
Секојдневието на инженерите често вклучува долги часови работа во
|
||||
затворени простории, при што малите промени во квалитетот на воздухот и
|
||||
удобноста можат да предизвикаат замор, намалена концентрација и пад во
|
||||
ефикасноста. Потребен е систем кој автоматски ги следи овие параметри и
|
||||
овозможува навремена анализа, со што и навремена реакција за подобрување
|
||||
на состојбата. Така во рамките на овој дипломски труд изработувам IoT
|
||||
(Internet of Things) систем која прибира податоци од повеќе различни
|
||||
извори: сензори за околината, Android паметни уреди како и надворешни
|
||||
сервиси за временска прогноза и загадување. Системот обезбедува
|
||||
автоматизирано следење, собирање, визуелизација како и обработка со
|
||||
помош на вештачка интелигенција. Платформата е изработена врз
|
||||
микро-сервисна архитектура, користејќи MQTT како главен протокол за
|
||||
комуникација измеѓу уредите и платформата, взаемна автентикација со TLS,
|
||||
\texttt{Redis Streams} за непречена проток на отчитувањата до \texttt{TimescaleDB} базата
|
||||
на податоци, а врз сето тоа развиена е и веб контролна табла, сервис за
|
||||
интелигентна анализа на собраните податоци со помош на вештачка
|
||||
интелигенција, како и Android апликација што овозможува прибирање на
|
||||
фитнес податоци. На овој начин, развиената платформа претставува
|
||||
целокупен пристап за објективно мерење, анализа и интерпретација на
|
||||
различните фактори кои влијаат врз продуктивноста и здравјето во
|
||||
модерните работни простории.
|
||||
|
||||
\vspace{0.5cm}
|
||||
|
||||
\textbf{Клучни зборови:} \textit{IoT, MQTT, mTLS, микросервиси, TimescaleDB, вештачка интелигенција, работна околина, телеметрија.}
|
||||
85
thesis/Source files/02_introduction.tex
Normal file
85
thesis/Source files/02_introduction.tex
Normal file
@@ -0,0 +1,85 @@
|
||||
\newpage
|
||||
\pagenumbering{arabic}
|
||||
\setcounter{page}{1}
|
||||
|
||||
\section{Вовед}
|
||||
|
||||
Во современите работни простории се посветува сè поголемо внимание врз
|
||||
квалитетот на работната околина и здравствените навики на работниците,
|
||||
особено кај инженерите и ИТ професионалците кои се долго време во
|
||||
затворени простории како и пред компјутер на своите бироа. Најразличните
|
||||
параметри како температура, влажност на воздухот, светлина,
|
||||
концентрација на различни гасови како CO2, NO2, CO, бучавата и
|
||||
осветлувањето во просторијата имаат значајна улога во продуктивноста,
|
||||
фокусот па и општата благосостојба на човекот, но во најголемиот дел од
|
||||
компаниите тие не се следат континуирано или објективно. Токму оваа
|
||||
потреба, мене лично, а и на другите околу мене за подобро и подетално
|
||||
разбирање на условите во работната средина претставува основна
|
||||
мотивација за развој на оваа платформа која овозможува автоматизирано
|
||||
прибирање, визуелизација и анализа на релевантни податоци.
|
||||
|
||||
На пазарот постојат повеќе решенија за IoT мониторинг, но секое од нив
|
||||
има свои ограничувања. Home Assistant е популарна open-source платформа
|
||||
која овозможува интеграција на различни паметни уреди, но бара
|
||||
комплексна конфигурација и техничко знаење за поставување, а не
|
||||
обезбедува вградени механизми за безбедна автентикација на уредите.
|
||||
Комерцијалните решенија како AWS IoT Core и Azure IoT Hub нудат напредни
|
||||
enterprise функционалности, решенија и висока скалабилност, но нивната
|
||||
цена и комплексност ги прават непрактични за индивидуална употреба или
|
||||
мали организации. ThingsBoard претставува добра платформа за
|
||||
визуелизација на IoT податоци, но нема вградена интеграција со модели за
|
||||
вештачка интелигенција за напредна анализа. Од друга страна,
|
||||
здравствените платформи како Google Fit, Samsung Health, Mi Fitness и
|
||||
други, работат во затворени екосистеми и не дозволуваат едноставна
|
||||
интеграција со околински сензори или сопствени системи.
|
||||
|
||||
Иако постојат многу поединечни решенија за мониторинг на одделни
|
||||
параметри, или пак можност за интеграција на разни уреди, ниту еден од
|
||||
нив не претставува интегриран систем кој истовремено ги спојува
|
||||
околинските услови, здравствените навики на корисниците како и
|
||||
надворешните услови како временска прогноза или квалитетот на воздухот.
|
||||
Дополнително, се соочуваат со разни проблеми како недоволно интуитивен и
|
||||
напреден кориснички интерфејс, небезбеден начин на испраќање на
|
||||
информациите, како и неефикасен начин на складирање на собраните
|
||||
податоци. Овие недостатоци создаваат простор и потреба за посеопфатно и
|
||||
безбедно решение.
|
||||
|
||||
Целта на овој дипломски труд е да се изработи скалабилна, безбедна и
|
||||
интуитивна платформа за автоматизирано прибирање, складирање,
|
||||
визуелизација и анализа на податоци од сите достапни извори, со што се
|
||||
добива увид со широк спектар за работната околина, условите во неа, како
|
||||
и за самиот работник. Специфичните цели на проектот се:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Дизајн и имплементација на микросервисна архитектура која овозможува
|
||||
модуларност и независно скалирање на компонентите
|
||||
\item
|
||||
Имплементација на безбедна комуникација помеѓу уредите и платформата
|
||||
со взаемна TLS (mTLS) автентикација и X.509 сертификати
|
||||
\item
|
||||
Развој на ефикасен систем за прием и складирање на временски податоци
|
||||
користејќи \texttt{Redis Streams} и \texttt{TimescaleDB}
|
||||
\item
|
||||
Интеграција на сервис за анализа и интерпретација на собраните
|
||||
податоци со помош вештачка интелигенција и машинско учење
|
||||
\item
|
||||
Изработка на интуитивна веб контролна табла со прилагодливи widgets за
|
||||
визуелизација и приказ на информациите
|
||||
\item
|
||||
Развој на Android апликација за прибирање и испраќање на здравствени и
|
||||
фитнес податоци
|
||||
\end{itemize}
|
||||
|
||||
Во втората глава од овој дипломски труд ја разгледувам теоретската
|
||||
основа потребна за разбирање на системот: IoT концептите, MQTT
|
||||
протоколот, безбедносните механизми со mTLS и X.509 сертификати,
|
||||
микросервисната архитектура и временските бази на податоци. Во третата
|
||||
глава го претставувам дизајнот на системот, вклучувајќи ја
|
||||
архитектурата, протокот на податоци, безбедносниот модел и дизајнот на
|
||||
базата на податоци. Четвртата глава е посветена на имплементацијата на
|
||||
сите компоненти: сервисите за управување со уреди, прием на MQTT пораки,
|
||||
запишување во база, вештачка интелигенција, веб контролната табла и
|
||||
Android апликацијата. Во петтата глава ги прикажувам резултатите од
|
||||
тестирањето и работата на системот, а во шестата глава го сумирам
|
||||
заклучокот и предлагам идни насоки за развој.
|
||||
237
thesis/Source files/03_theory.tex
Normal file
237
thesis/Source files/03_theory.tex
Normal file
@@ -0,0 +1,237 @@
|
||||
\newpage
|
||||
|
||||
\section{Основи на IoT и применети технологии}
|
||||
|
||||
Ова поглавје ги опфаќа основните технички концепти врз кои се базира
|
||||
развиената платформа. Прикажани се клучните технологии, протоколи и
|
||||
архитектонски принципи што овозможуваат безбедно, сигурно и скалабилно
|
||||
функционирање на системот. Со разбирање на овие теоретски основи станува
|
||||
појасно зошто одредени технолошки решенија се избрани и како тие
|
||||
директно придонесуваат кон целокупната архитектура и функционирањето на
|
||||
самата платформата.
|
||||
|
||||
\subsection{Интернет на Нештата}
|
||||
|
||||
Интернет на нештата (Internet of Things или IoT) претставува технолошки
|
||||
концепт кој се однесува околу поврзување на голем број мали уреди со
|
||||
интернетот со цел размена на податоци, обработка и автоматизација на
|
||||
истите. Терминот прв пат е воведен кон крајот на 90-тите од страна на
|
||||
Кевин Ештон, професор од МИТ. А во последните години достигнува голема
|
||||
популарност со порастот на интернетот, зголемената достапност на
|
||||
електронски компоненти, нивното поевтинување како и нивното усовршување
|
||||
што овозможува полесна и поевтина имплементација на истите. Денес се
|
||||
проценува дека во 2025 година постојат околу 20 милијарди IoT уреди, а
|
||||
се проценува дека нивната бројка ќе се искачи до над 30 милијарди до
|
||||
2030та година. IoT наоѓа широка примена во индустрија, здравство,
|
||||
транспорт, земјоделие, паметни домови и многу други области.
|
||||
|
||||
Архитектурата на IoT системите најчесто се состои од три главни слоеви:
|
||||
Perception, Network и Application слојот. Perception слојот ги опфаќа
|
||||
сите уреди со сензори и актуатори кои се поставени во некоја околина,
|
||||
Network слојот, односно мрежниот слој е слојот кој е задолжен за пренос
|
||||
на информациите од и до самите уреди преку разни мрежни технологии како
|
||||
WiFi, Bluetooth, 4G/5G, LoRaWAN како и други поедноставни радио
|
||||
протоколи, како и специјализираните протоколи за истите како MQTT, HTTP,
|
||||
AMQP. А додека пак Application слојот, односно апликацискиот претставува
|
||||
највисокото ниво, и тој слој е задолжен за обработка, собирање,
|
||||
складирање, анализа, прикажување на податоците од и за уредите.
|
||||
|
||||
Покрај широката примена, IoT системите и индустријата имаат бројни
|
||||
предизвици. Еден од нив е безбедноста, бидејќи најчесто уредите се со
|
||||
мала процесорска моќ и батериски напојувани не можат да извршуваат
|
||||
комплексни криптографски операции. Скалабилноста претставува огромен
|
||||
проблем бидејќи во IoT системите се очекува да комуницираат истовремено
|
||||
стотици, а некогаш и илјадници. Интероперабилноста е исто клучен фактор
|
||||
за успех, најчесто таа е ограничена од разни стандарди и протоколи кои
|
||||
често знаат да се лиценцирани и приватни. Справувањето со големиот
|
||||
волумен на податоци е исто така доста сложено, што бара специјализирани
|
||||
системи за обработка и посебни бази на податоци за истите.
|
||||
|
||||
Сите овие предизвици се релевантни и за развиената платформа во овој
|
||||
труд. Потребата за сигурна комуникација се адресира со имплементација на
|
||||
mTLS и X.509 сертификати. Скалабилноста и ефикасното управување со
|
||||
податоците се постигнуваат со користење на \texttt{Redis Streams} и \texttt{TimescaleDB},
|
||||
додека микро-сервисната архитектура обезбедува флексибилност, независно
|
||||
развивање и лесно проширување на системот. Со тоа, избраната архитектура
|
||||
директно ги решава клучните слабости во традиционалните IoT решенија.
|
||||
|
||||
\subsection{MQTT}
|
||||
|
||||
Со ефикасност и сигурност како цел во преносот на телеметриските податоци
|
||||
во IoT светот, изборот на комуникацискиот протокол има клучна улога. За
|
||||
разлика од де факто стандардот за веб комуникација, HTTP, протокол кој
|
||||
се базира на request/response моделот, MQTT (Message Queuing Telemetry
|
||||
Transport) користи publish/subsrcibe модел, што го прави погоден за
|
||||
системите со голем број на уреди и ограничени ресурси. MQTT е развиен
|
||||
токму за овие намени, за средини со нестабилна мрежна конекција, мал
|
||||
проток и уреди со ограничена процесорска и енергетска моќ, што го прави
|
||||
идеален кандидат за IoT системи.
|
||||
|
||||
За разлика од HTTP, каде клиентот мора постојано да испраќа барања за да
|
||||
провери дали има нови податоци, MQTT работи на принципот
|
||||
publish/subscribe. Уредите кои се „произведуваат`` податоци(publishers)
|
||||
ги испраќаат пораките до централен сервер наречен \texttt{broker}, додека
|
||||
апликациите кои сакаат да ги „конзумираат`` тие податоци subscribers, се
|
||||
претплатуваат на одредени теми. На овој начин, \texttt{broker}-от автоматски ги
|
||||
доставува пораките до сите заинтересирани корисници без тие константно
|
||||
да испраќаат барања.
|
||||
|
||||
Комуникацијата преку MQTT е организирана преку така наречени теми
|
||||
(topics), кои имаат хиерархиска структура и овозможува организација на
|
||||
пораките. На пример, тема од облик devices/sensor01/temperature јасно
|
||||
укажува дека пораката се однесува на температура измерена од конкретен
|
||||
уред. MQTT дополнително поддржува и т.н. wildcard знаци, со што се
|
||||
овозможува флексибилно претплатување на повеќе теми истовремено, што е
|
||||
особено корисно во системи со голем број уреди.
|
||||
|
||||
Исто така многу важен механизам кај MQTT е Quality of Service(QoS), кој
|
||||
го дефинира нивото на сигурност при достава на пораките. Протоколот
|
||||
поддржува три нивоа на QoS, кои овозможуваат баланс помеѓу брзината на
|
||||
комуникација и сигурност во доставата. Овој механизам е особено битен
|
||||
кај системи каде е важно пораките да не се изгубат, но истовремено да не
|
||||
се оптоварува мрежата.
|
||||
|
||||
Покрај овие можности, MQTT нуди и дополнителни механизми како retained
|
||||
пораки и last will пораки. Retained пораките се пораки кои остануваат во
|
||||
\texttt{topic}-от, и секој нов претплатник ќе ја добие таа порака, за разликата
|
||||
од другите нормални пораки кои ги добиваат само ако биле претплатени во
|
||||
истиот момент кога била испратена. Додека пак last will пораката
|
||||
претставува начин еден испраќач (publisher) кога непланирано ќе се
|
||||
дисконектира да остави некаква трага. Благодарение на овие
|
||||
карактеристики, MQTT претставува стандарден избор за комуникација во IoT
|
||||
платформите, вклучувајќи го и платформата која ја развивам во рамките на
|
||||
овој дипломски труд.
|
||||
|
||||
\subsection{Безбедност - mTLS и X.509 сертификати}
|
||||
|
||||
Безбедноста претставува еден од најкритичните аспекти кај IoT системите,
|
||||
бидејќи уредите најчесто се поставени на небезбедни локации и
|
||||
комуницираат преку небезбедни канали. Во изминатите години се забележани
|
||||
бројни напади врз IoT уредите, каде илјадници уреди се компромитирани
|
||||
поради слаба софтверска безбедност. Денес тие претставуваат една од
|
||||
главните мети на хакерски напади поради нивното сѐ присуство и
|
||||
ранливост. Еден таков познат напад е Mirai botnet во 2016, преку кој беа
|
||||
компромитирани стотици илјади уреди поради користење слаби лозинки или
|
||||
стандардни лозинки. Поради таквите примери класичната добро позната
|
||||
автентикација со лозинка и корисничко име се смета за не соодветно
|
||||
решение.
|
||||
|
||||
За заштита на комуникацијата кај IoT уредите се користи TLS (Transport
|
||||
Layer Security) протоколот, кој обезбедува енкрипција на податоците,
|
||||
интегритет и автентикација на двете страни во комуникацијата. При TCP
|
||||
конекција, TLS спроведува размена на сертификати за потврда на
|
||||
идентитетот. Kај стандардниот TLS, како на пример за web комуникација,
|
||||
се врши еднострана идентификација, односно серверот се идентификува пред
|
||||
клиентот за да покаже дека тој е стварно тој, додека пак кај mutual TLS
|
||||
(mTLS) и клиентот и серверот меѓусебно се автентицираат. Ова е посебно
|
||||
важно кај IoT уредите, бидејќи секој уред мора да биде јасно
|
||||
идентификуван со што се спречува неовластен пристап до системот.
|
||||
|
||||
Автентикацијата кај mTLS се заснова врз X.509 дигитални сертификати.
|
||||
Секој сертификат содржи податоци за сопственикот, јавен криптографски
|
||||
клуч, неговата важност, неговиот сериски број како и дигитален потпис од
|
||||
доверлив издавач, односно така наречен Certificate Authority (CA). Преку
|
||||
концептот наречен „ chain of trust`` се обезбедува сигурност дека
|
||||
сертификатот е издаден од валидна и доверлива институција,.
|
||||
|
||||
Во рамките на системот развиен за овој дипломски труд се користи интерен
|
||||
Certificate Authority, преку кој се издаваат X.509 сертификати за секој
|
||||
уред. Процесот на генерирање и управување со сертификати е имплементиран
|
||||
во рамките на сервисот за управување со уреди (\texttt{device\_manager}), додека
|
||||
пак MQTT \texttt{broker}-от врши верификација на сертификатите при секое
|
||||
поврзување. Дополнително, се користи и Certificate Revocation List
|
||||
(CRL), со што се овозможува одземање на пристапот на компромитирани или
|
||||
неактивни уреди, што спречува користење на тие валидни цели за
|
||||
малициозни цели.
|
||||
|
||||
Со примената на mTLS се обезбедува високо ниво на безбедност, доверлива
|
||||
идентификација на уредите и заштита на податоците при нивниот пренос.
|
||||
|
||||
\subsection{Микросервисна архитектура}
|
||||
|
||||
Традиционалниот пристап во развојот на софтверските системи најчесто се
|
||||
базира на монолитна архитектура, каде целата функционалност на една
|
||||
апликација е имплементирана во една целина. Овој начин е доста
|
||||
едноставен за почетна имплементација, но како што се зголемува
|
||||
комплексноста и бројот на корисници, одржувањето, скалабилноста на
|
||||
системот и развојот стануваат се потешки за менаџирање. Спротивно на тоа
|
||||
микросервисната архитектура го дели системот на повеќе мали, логички
|
||||
независни целини наречени сервиси, од кои секој извршува една точно
|
||||
јасно дефинирана функција и комуницира со останатите најчесто преку
|
||||
мрежа.
|
||||
|
||||
Главните предности на микросервисната архитектура се флексибилноста,
|
||||
скалабилноста и можноста секој да се развива независно. Секој
|
||||
микросервис може да се развива во различна насока, со различни
|
||||
технологии и програмски јазици во зависност од потребата и намената.
|
||||
Дополнително, проблем со еден од сервисите не значи дека и целиот систем
|
||||
ќе престане со работа. Тоа дополнително ја зголемува отпорноста на
|
||||
грешки на системот. Овој пристап е доста користен и погоден за IoT
|
||||
системи, бидејќи различни компоненти имаат различни намени, барања за
|
||||
перформанси и скалабилност.
|
||||
|
||||
Покрај своите бројни предности, микросервисната архитектура како и се
|
||||
друго има и свои недостатоци и предизвици. Комуникацијата помеѓу
|
||||
микросервисите се одвива преку мрежа, што може да доведе до дополнително
|
||||
каснење и грешки. Дистрибуираните системи се потешки за управување и
|
||||
синхронизирање во споредба со монолитната архитектура. Нивното
|
||||
дебагирање и откривање на проблеми е доста покомплексно, бидејќи треба
|
||||
следење и анализирање на повеќе сервиси кои работат одвоено.
|
||||
|
||||
За надминување на ваквите предизвици, во микросервисините системи често
|
||||
се применува асинхрона комуникација преку message queue механизми. Во
|
||||
рамките на оваа дипломска се користи \texttt{Redis Streams} како комуникациски
|
||||
посредник помеѓу некои сервиси. Преку концептот на \texttt{consumer groups},
|
||||
повеќе worker процеси можат паралелно да читаат и обработуваат еден ист
|
||||
stream. Дополнително, обезбедува at-least-once достава, со што се
|
||||
намалува ризикот од губење на податоци.
|
||||
|
||||
Во платформа која се развива како дел од оваа дипломска работа се
|
||||
имплементирани повеќе микросервиси со јасно дефинирани одговорности.
|
||||
Сервисот \texttt{device\_manager} е задолжен за регистрација на уредите и
|
||||
управување со X.509 сертификати. \texttt{mqtt\_ingestion} сервисот го прима
|
||||
телеметрискиот сообраќај и го запишува во \texttt{Redis Streams}. \texttt{db\_write}
|
||||
сервисот ја презема улогата на обработка и запишување на податоците во
|
||||
базата на податоци. \texttt{gpt\_service} е задолжен за паметна анализа на
|
||||
собраните податоци со помош на вештачка интелигенција , додека Django ни
|
||||
служи како позадина за веб-контролната табла и оркестрира и комуницира
|
||||
со другите сервиси и базата на податоци во која се запишуваат
|
||||
телеметриските податоци. Со ваквата организација, секој сервис има јасна
|
||||
улога, што го прави системот модуларен, проширлив и лесен за одржување.
|
||||
|
||||
\subsection{Временски бази на податоци}
|
||||
|
||||
IoT системите генерираат голем број на податоци во кратки временски
|
||||
интервали, што резултира со голем број на INSERT операции во базата на
|
||||
податоци. Ваквиот тип на податоци најчесто се анализираат според
|
||||
временски опсези, како последен час, последен ден или месец, а не според
|
||||
нивните класични релациски односи. А исто така често се извршуваат
|
||||
агрегациски функции, максимално и минимални вредности во временски
|
||||
период. Поради ваквите карактеристики, најчесто користените релациски
|
||||
бази на податоци не се оптимални за ваков тип на податоци без надградби
|
||||
за оптимизација.
|
||||
|
||||
Како решение за управување со податоци од временски серии, во рамките на
|
||||
дипломскиот труд се користи \texttt{TimescaleDB}, кој претставува екстензија за
|
||||
\texttt{PostgreSQL} широко користената релациона база на податоци. Ова овозможува
|
||||
користење на секојдневна релациона база заедно со временски серии на
|
||||
податоци. Клучниот концепт кај оваа база се така наречените \texttt{hypertables},
|
||||
кои овозможуваат автоматско партиционирање на податоците кои се
|
||||
временски серии, според временската димензија и дополнителни клучеви, со
|
||||
што се постигнува висока ефикасност за запишување и читање.
|
||||
Дополнително, \texttt{TimescaleDB} поддржува компресија на стари податоци, што
|
||||
помага за намалување на просторот кој го зафаќа базата на податоци, како
|
||||
и континуирани агрегации (continous aggregations), кои овозможуваат
|
||||
автоматско пресметување на агрегатни вредности по претходно зададени
|
||||
временски интервали.
|
||||
|
||||
Во платформата развиена за овој дипломски труд, телеметриските податоци
|
||||
се складирани во посебна \texttt{telemetry} табела, чија структура е прилагодена
|
||||
на временската природа на податоците, односно е \texttt{hypertable}. Примарниот
|
||||
клуч е составен од времето на мерење, идентификаторот на уредот и типот
|
||||
на метриката, односно (time, device\_id, metric). Ваквиот редослед на
|
||||
клучевите овозможува ефикасни пребарување најчесто користени во
|
||||
системот, како на пример прикажување на податоци за конкретен уред во
|
||||
одреден временски период.
|
||||
|
||||
Со прикажаните концепти за IoT, комуникациски протоколи, безбедност, микросервисна архитектура и временски бази на податоци се поставува теоретската основа за дизајнот на конкретниот систем. Во следното поглавје детално е опишана архитектурата на развиената платформа и меѓусебната интеракција на нејзините компоненти.
|
||||
282
thesis/Source files/04_design.tex
Normal file
282
thesis/Source files/04_design.tex
Normal file
@@ -0,0 +1,282 @@
|
||||
\newpage
|
||||
|
||||
\section{Дизајн на системот}
|
||||
|
||||
\subsection{Архитектура на платформата}
|
||||
|
||||
Развиената платформа е изградена врз микросервисна архитектура, со цел
|
||||
да обезбеди висока скалабилност, добра флексибилност и отпорност на
|
||||
грешки и проблеми. Наместо целата логика да е сместена во една монолитна
|
||||
апликација, функционалноста е поделена во повеќе независни микросервиси
|
||||
кои меѓусебно комуницираат преку мрежа. Ваквиот пристап овозможува
|
||||
изолирање на одговорностите по сервиси, подобра контрола на ресурсите и
|
||||
поедноставно одржување, додавање нови функционалности и скалирање.
|
||||
|
||||
Архитектурата на платформата е составена од повеќе специјализирани
|
||||
микросервиси, од кои секој има јасно дефинира улога во целокупниот
|
||||
процес. Сервисот \texttt{device\_manager}, имплементиран со \texttt{FastAPI}, е задолжен
|
||||
за регистрација на уредите и управување со нивните X.509 сертификати.
|
||||
Сервисот \texttt{mqtt\_ingestion} е одговорен за прием на MQTT пораките од
|
||||
уредите и нивно проследување кон \texttt{Redis Streams}. \texttt{db\_write} сервисот ја
|
||||
презема обработката на податоците од message queue и нивно запишување во
|
||||
базата на податоци. \texttt{gpt\_service} овозможува интелигентна анализа на
|
||||
собраните податоци со примена на вештачка интелигенција. Django служи
|
||||
како комуникациски слој помеѓу сите микросервиси и frontend апликацијата
|
||||
изградена со React која ни овозможува визуелизација на податоците,
|
||||
интеракција со истите, и целосна табла за контрола со уредите.
|
||||
|
||||
При дизајнот на платформата беа во главно применети принципите на loose
|
||||
coupling и single responsibility, со цел да се обезбеди јасна поделба на
|
||||
одговорностите и минимална зависност меѓу сервисите. Секој микросервис
|
||||
извршува една конкретна функционалност и комуницира со останатите
|
||||
исклучиво преку дефинирани API-интерфејси или message queue механизми.
|
||||
На овој начин се овозможува поедноставно тестирање, полесна интеграција
|
||||
на нови функционалности и локализирано решавање на дефекти без да се
|
||||
наруши стабилноста на целиот систем. Дополнително ваквиот пристап
|
||||
овозможува независно хоризонтално скалирање на поединечни сервиси, во
|
||||
зависност од реалното оптоварување, што е особено значајно за IoT
|
||||
платформи со променлив број на активни уреди.
|
||||
|
||||
За размена на податоци помеѓу микросервисите задолжени за прием и
|
||||
обработка на телеметриските информации е имплементиран асинхрон
|
||||
комуникациски модел базиран на \texttt{Redis Streams}. Сервисот \texttt{mqtt\_ingestion}
|
||||
функционира како producer и ги запишува сите пристигнати MQTT пораки во
|
||||
централен stream, додека сервисот \texttt{db\_write} функционира како consumer и
|
||||
ги чита пораките преку механизмот на \texttt{consumer groups}.
|
||||
|
||||
Ваквиот пристап овозможува откачување(decoupling) на сервисите на прием
|
||||
и обработка, автоматско балансирање на товарот при повеќе активни worker
|
||||
инстанции, како и задржување на пораките во случајот на привремен преки
|
||||
или преоптоварување на downstream сервисите. Дополнително, \texttt{Redis Streams}
|
||||
обезбедува at-least-once семантика, со што се намалува ризикот на губење
|
||||
на телеметриските податоци. Целокупната комуникациска архитектура и движењето на податоците е прикажано на архитектурниот дијаграм (слика~\ref{fig:system-architecture}).
|
||||
|
||||
\begin{figure}[htbp]
|
||||
\centering
|
||||
\includegraphics[width=\textwidth]{Miscellaneous/architecture.png}
|
||||
\caption{Архитектура на платформата за мониторинг на работна околина}
|
||||
\label{fig:system-architecture}
|
||||
\end{figure}
|
||||
|
||||
\subsection{Проток на податоци}
|
||||
|
||||
Протокот на податоци во рамките на развиената платформа е дизајниран
|
||||
така што овозможува сигурно, скалабилно и ефикасно движење на
|
||||
информациите од крајните IoT уреди до веб интерфејсот. Процесот опфаќа
|
||||
повеќе фази: регистрација на уред, безбедно поврзување преку mTLS,
|
||||
испраќање и обработка на телеметриски податоци, нивно складирање во база
|
||||
на податоци и конечно визуелизација преку frontend апликацијата.
|
||||
Целокупниот тек е прикажан со дијаграм. (сл 3.2)
|
||||
|
||||
\subsubsection{Регистрација на уред}
|
||||
|
||||
Процесот започнува со регистрација на нов IoT уред во системот.
|
||||
Корисникот иницира регистрација преку веб контролната табла имплементира
|
||||
со React. При оваа постапка, frontend-от испраќа HTTP POST барање до
|
||||
Django REST API кое содржи основни информации за уредот, како што се
|
||||
име, локација и тип на уред.
|
||||
|
||||
Django го проследува ова барање кон интерниот \texttt{device\_manager}
|
||||
микросервис, кој е задолжен за управување со уредите и нивните дигитални
|
||||
сертификати. Device\_manager креира нов запис во табелата devices во
|
||||
базата на податоци и иницира процес на генерирање на X.509 сертификат за
|
||||
конкретниот уред. По успешното генерирање и потпишување на сертификатот
|
||||
од страна на интерниот Certificate Authority (CA), приватниот клуч и
|
||||
сертификатот му се враќаат на корисникот. Овие податоци понатаму се
|
||||
имплементираат на самиот уред, со што тој станува подготвен за безбедно
|
||||
поврзување на системот.
|
||||
|
||||
\subsubsection{Поврзување на уред преку mTLS}
|
||||
|
||||
По успешната регистрација, уредот иницира TCP конекција кон MQTT
|
||||
\texttt{broker}-от преку порт-от 8883 кој е наменет за MQTT со SSL. При
|
||||
воспоставување на конекцијата се извршува mTLS handshake постапка, при
|
||||
што уредот го презентира својот X.509 сертификат пред \texttt{broker}-от, а
|
||||
\texttt{broker}-от го презентира својот сертификат пред уредот.
|
||||
|
||||
\texttt{Mosquitto broker}-от врши верификација на сертификатот преку:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Проверка на потписот од интерниот CA
|
||||
\item
|
||||
Проверка на валидноста (датум на важност)
|
||||
\item
|
||||
Проверка во Certificate Revocation List (CRL)
|
||||
\end{itemize}
|
||||
|
||||
Доколку сите проверки се успешни, конекцијата се прифаќа и уредот добива
|
||||
дозвола за publish. Во спротивно, доколку сертификатот е невалиден,
|
||||
конекцијата автоматски се одбива.
|
||||
|
||||
\subsubsection{Испраќање и процесирање на телеметрија}
|
||||
|
||||
Откако уредот е успешно поврзан со MQTT \texttt{broker}-от, тој започнува со
|
||||
публикување на телеметриски податоци на MQTT теми во облик
|
||||
„devices/\{device\_id\}/\{metric\}`` каде што device\_id претставува
|
||||
уникатен идентификатор на уредот, а metric го означува тимот на
|
||||
измерениот параметар (на пр. Температура, влажност, CO2).
|
||||
|
||||
Сервисот \texttt{mqtt\_ingestion} е претплатен на сите релевантни теми и
|
||||
функционира како централен приемник на сите MQTT пораки. По приемот на
|
||||
секоја порака, податоците се парсираат и се запишуваат како нов запис во
|
||||
Redis Stream-от \texttt{mqtt:ingestion}.
|
||||
|
||||
Сервисот \texttt{db\_write} функционира како consumer во рамките на \texttt{Redis}
|
||||
\texttt{consumer group} и ги чита пораките од stream-от асинхроно. Податоците се
|
||||
групираат и се запишуваат во \texttt{TimescaleDB} базата како нови записи во
|
||||
\texttt{telemetry} \texttt{hypertable}. На овој начин се обезбедува висок проток на
|
||||
податоци, buffering при оптоварување и at-least-once гаранција за
|
||||
достава.
|
||||
|
||||
\subsubsection{Визуелизација и пристап до податоците}
|
||||
|
||||
Откако податоците се складирани во базата, тие стануваат достапни за
|
||||
визуелизација преку веб-контролната табла. Frontend апликација направена
|
||||
со React комуницира со Django REST API преку HTTP барања, при што API
|
||||
слојот врши SQL барања за извлекување на податоците во зададените
|
||||
временски интервали.
|
||||
|
||||
Добиените податоци се прикажуваат преку интерактивни графици, виџети и
|
||||
табели, овозможувајќи им на корисниците да добијат увид во реално време
|
||||
и историски во состојбата на работната околина и активностите.
|
||||
|
||||
\subsubsection{Интеграција на интелигентна анализа}
|
||||
|
||||
Покрај стандардната визуелизација, платформата овозможува интелигентна
|
||||
анализа на податоците преку gpt\_service микросервисот. Овој сервис се
|
||||
повикува директно со потребите податоци кои треба да ги обработи со
|
||||
помош на AI модел и генерира текстуални анализи, препораки и детекција
|
||||
на аномалии. Резултатите од AI анализата се прикажуваат во frontend
|
||||
апликацијата како виџети што овозможува дополнителен слој на
|
||||
интерпретација на постоечките податоци.
|
||||
|
||||
\subsection{Безбедносен модел на платформата}
|
||||
|
||||
Безбедноста е еден од клучните аспекти при дизајнот на оваа IoT
|
||||
платформа, поради фактот што најголемиот дел од уредите се поставени во
|
||||
небезбедни средини и комуницираат преку јавни мрежи. Класичната
|
||||
автентикација, како што е споменато претходно, со корисничко име и
|
||||
лозинка не е соодветна за вакви системи, бидејќи често претставува чест
|
||||
извор на безбедности пропусти. Поради тоа, во рамките на оваа платформа
|
||||
е имплементиран безбедносен модел базиран на mutual TLS (mTLS).
|
||||
|
||||
Кај mTLS, и клиентот (IoT уредот) и серверот (MQTT \texttt{broker}-от) меѓусебно
|
||||
се автентицираат преку X.509 дигитални сертификати. На овој начин се
|
||||
обезбедува двострана проверка на идентитетот, како и енкрипција и
|
||||
интегритет на податоците при нивниот пренос. Секој уред поседува
|
||||
уникатен сертификат, со што се овозможува прецизна идентификација и
|
||||
контрола на пристапот до системот.
|
||||
|
||||
Во основата на овој модел се наоѓа интерен Certificate Authority (CA),
|
||||
која ги потпишува сите сертификати во системот. Процесот на издавање на
|
||||
сертификат се реализира преку \texttt{device\_manager} сервисот, кој при
|
||||
регистрација на уред генерира сертификат и приватен клуч, ги складира
|
||||
релевантните податоци во базата и му ги враќа на корисникот за
|
||||
инсталација на уредот.
|
||||
|
||||
За управување со компромитирани или неактивни уреди се користи механизам
|
||||
за повлекување на сертификати преку Certificate Revocation List (CRL).
|
||||
При повлекување на сертификатот, тој се означува како невалиден во
|
||||
системот, CRL листата се ажурира и MQTT \texttt{broker}-от автоматски го одбива
|
||||
секој понатамошен обид за поврзување со тој сертификат.
|
||||
|
||||
Изборот на mTLS обезбедува високо ниво на безбедност, елиминација на
|
||||
лозинки, силна криптографска идентификација на уредите и заштита од
|
||||
напади како man-in-the-middle, што го прави овој пристап особено погоден
|
||||
за IoT платформи.
|
||||
|
||||
\subsection{Дизајн на базата на податоци}
|
||||
|
||||
Складирањето на податоците во системот е дизајнирано така што овозможува
|
||||
сигурно управување со уредите, дигиталните сертификати и телеметриските
|
||||
мерења. Поради временската природа и големиот волумен на податоци,
|
||||
базата на податоци е реализирана со \texttt{PostgreSQL} со \texttt{TimescaleDB}
|
||||
екстензија.
|
||||
|
||||
\subsubsection{Табела \texttt{devices}}
|
||||
|
||||
Табелата \texttt{devices} ги содржи основните информации за секој регистриран
|
||||
уред во системот во неа се складираат податоци како:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Id - единствен идентификатор на уредот
|
||||
\item
|
||||
name - име на уредот
|
||||
\item
|
||||
Location - локација уредот
|
||||
\item
|
||||
Created\_at - време на регистрација
|
||||
\end{itemize}
|
||||
|
||||
Оваа табела претставува централна точка за поврзување на сите останати
|
||||
податоци во системот
|
||||
|
||||
\subsubsection{Табела \texttt{device\_certificates}}
|
||||
|
||||
Табелата \texttt{device\_certificates} се користи за управување со X.509
|
||||
сертификатите поврзани со уредите. Таа содржи:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Id - сериски број на сертификатот
|
||||
\item
|
||||
Device\_id - надворешен клуч кон табелата devices
|
||||
\item
|
||||
Issued\_at - датум на издавање
|
||||
\item
|
||||
Expires\_at -- датум на истекување
|
||||
\item
|
||||
Revoked\_at - датум на повлекување (доколку постои)
|
||||
\end{itemize}
|
||||
|
||||
Преку оваа табела се обезбедува информации за целиот животен циклус на
|
||||
секој сертификат
|
||||
|
||||
\subsubsection{Табела \texttt{telemetry}}
|
||||
|
||||
Табелата \texttt{telemetry} претставува \texttt{TimescaleDB hypertable} и е
|
||||
оптимизирана за складирање на временски серии од телеметриски податоци.
|
||||
Таа содржи:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Device\_id - идентификатор на уредот
|
||||
\item
|
||||
Metric - тип на измерениот параметар
|
||||
\item
|
||||
Value - измерена вредност
|
||||
\item
|
||||
Timestamp - време на мерење
|
||||
\end{itemize}
|
||||
|
||||
Податоците се автоматски партиционирани по време, што овозможува високо
|
||||
ниво на ефикасност при внесување и пребарување на податоци. Примарниот
|
||||
клуч е составен од (timestamp, device\_id, metric), што овозможува
|
||||
оптимизирани прашалници при анализа на податоци по уред и временски
|
||||
опсег.
|
||||
|
||||
\texttt{TimescaleDB} e избрана поради неколку технички предности како што се:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Оптимизирана за работа со временски серии
|
||||
\item
|
||||
Автоматска компресија
|
||||
\item
|
||||
Континуирани агрегации за пресметка на просечни вредности
|
||||
\item
|
||||
Скалабилност и компатибилност со \texttt{PostgreSQL}
|
||||
\end{itemize}
|
||||
|
||||
Со ова се овозможува ефикасно, стабилно и долгорочно чување и користење
|
||||
на телеметриските податоци од платформата. Целокупната структура на базата
|
||||
на податоци е прикажана на ER дијаграмот (слика~\ref{fig:er-diagram}).
|
||||
|
||||
\begin{figure}[htbp]
|
||||
\centering
|
||||
\includegraphics[width=\textwidth]{Miscellaneous/er_diagram.png}
|
||||
\caption{ER дијаграм на базата на податоци на платформата}
|
||||
\label{fig:er-diagram}
|
||||
\end{figure}
|
||||
764
thesis/Source files/05_implementation.tex
Normal file
764
thesis/Source files/05_implementation.tex
Normal file
@@ -0,0 +1,764 @@
|
||||
\newpage
|
||||
|
||||
\section{Имплементација}
|
||||
|
||||
Во оваа глава се прикажува практичната имплементација на платформата и
|
||||
нејзината архитектура претходно опишана. Претставени се клучни делови од
|
||||
backend-от, микросервисната инфраструктура и frontend апликацијата.
|
||||
Ставен е акцент на најрелевантните и најинтересните сегменти кои
|
||||
демонстрираат реализација на архитектонските одлуки, безбедносните
|
||||
механизми и протокот на податоци низ системот.
|
||||
|
||||
\subsection{Backend имплементација}
|
||||
|
||||
Backend делот е имплементиран со користење на Django и Django REST
|
||||
Framework (DRF) и претставува централна контролна точка за комуникација
|
||||
помеѓу frontend апликацијата, микросервисите и базата на податоци.
|
||||
Django REST API-то функционира како API gateway, преку кој се
|
||||
реализираат сите операции поврзани со уредите, сертификатите,
|
||||
телеметриските податоци и интелигентната анализа.
|
||||
|
||||
Иако системот користи \texttt{PostgreSQL} со \texttt{TimescaleDB} за складирање на
|
||||
телеметрија, Django не управува со овие табели во write режим, Django
|
||||
има read-only пристап, со што се избегнува мешање на Django ORM во
|
||||
ingestion процесот, кој целосно се реализира преку микросервисите
|
||||
mqtt\_ingestion и db\_write. Овој пристап овозможува висока
|
||||
конзистентност, безбедно пишување на податоците и одвојување на
|
||||
ingestion логиката од API слојот.
|
||||
|
||||
\subsubsection{Django REST API како комуникациски слој}
|
||||
|
||||
Django REST Framework (DRF) е искористен како централен комуникациски
|
||||
слој помеѓу frontend апликација, микросервисите и базата на податоци.
|
||||
Django API има улога на API gateway, преку кој се реализираат сите
|
||||
надворешни барања од корисничкиот интерфејс, додека вистинската бизнис
|
||||
логика (сертификати, AI анализа, ingest) е делегирана на посебни
|
||||
микросервиси.
|
||||
|
||||
Во рамките на овој слој, за управување со уредите и телеметриските
|
||||
податоци се користат DRF ModelViewSet и ReadOnlyModelViewSet. Основната
|
||||
дефиниција е реализирана преку:
|
||||
|
||||
\begin{minted}{python}
|
||||
class DeviceViewSet(viewsets.ModelViewSet):
|
||||
queryset = Device.objects.all()
|
||||
serializer_class = DeviceSerializer
|
||||
|
||||
|
||||
class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""ViewSet for telemetry data queries."""
|
||||
|
||||
queryset = Telemetry.objects.all()
|
||||
serializer_class = TelemetrySerializer
|
||||
\end{minted}
|
||||
|
||||
DRF автоматски обезбедува CRUD операции, додека другите дополнителни
|
||||
функционалности се обезбедуваат преку користење на @action декоратор, со
|
||||
што се овозможуваат различни API повици , како што се регистрација на
|
||||
уреди, обновување на сертификати, како и пристап до телеметриски
|
||||
податоци
|
||||
|
||||
Посебна карактеристика на оваа имплементација е тоа што моделите Device,
|
||||
DeviceCertificate и Telemtery се дефинирани како read-only модели,
|
||||
бидејќи Django не треба да учествува во додавање или било каква измена
|
||||
во овие табели, туку само да ги чита потребните податоци од овие табели.
|
||||
Телеметриските податоци се внесуваат преку микросервисите
|
||||
\texttt{mqtt\_ingestion} и \texttt{db\_write}, со што се избегнува двојно запишување,
|
||||
конфликт на податоци и се обезбедува подобра скалабилност на системот.
|
||||
Додека пак за сертификатите се грижи само \texttt{device\_manager} микросервисот.
|
||||
|
||||
Дополнително, Django API слојот има строго дефинирани \textbf{граници на
|
||||
одговорност}. Тој не обработува сурови MQTT пораки, не генерира
|
||||
сертификати и не комуницира директно со IoT уредите. Со оваа поделба,
|
||||
веб апликацијата останува лесна, безбедна и отпорна на преоптоварување,
|
||||
додека високофреквентната комуникација и обработката на податоци се
|
||||
извршуваат во специјализирани сервиси.
|
||||
|
||||
Од безбедносен аспект, Django API претставува заштитна бариера помеѓу
|
||||
јавниот кориснички интерфејс и внатрешната инфраструктура. Сите
|
||||
чувствителни операции, како регистрација на уреди и пристап до
|
||||
сертификати, се извршуваат преку автентицирани и логички контролирани
|
||||
API повици, со што се спречува директен пристап до микросервисите од
|
||||
надворешна страна.
|
||||
|
||||
Со ваквата архитектура, Django REST API слојот обезбедува:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
централизирана точка за комуникација,
|
||||
\item
|
||||
логичка сегрегација на одговорностите,
|
||||
\item
|
||||
зголемена безбедност,
|
||||
\item
|
||||
и чиста интеграција помеѓу корисничкиот интерфејс и backend
|
||||
инфраструктурата.
|
||||
\end{itemize}
|
||||
|
||||
\subsubsection{Валидација на податоци и серијализација со DRF Serializers}
|
||||
|
||||
Во рамките на Django API слојот, валидацијата, трансформацијата и
|
||||
форматирањето на податоците се реализираат преку Django REST Framework
|
||||
Serializer и ModelSerializer класи. Serializer-ите претставуваат
|
||||
критична компонента од безбедносен аспект, бидејќи овозможуваат целосна
|
||||
контрола врз податоците кои се примаат и испраќаат преку API
|
||||
интерфејсот.
|
||||
|
||||
При регистрација на нов IoT уред се користи посебен serializer за
|
||||
валидација на влезните податоци:
|
||||
|
||||
\begin{minted}{python}
|
||||
class DeviceCreateSerializer(serializers.Serializer):
|
||||
"""Serializer for device registration requests."""
|
||||
|
||||
name = serializers.CharField(max_length=255)
|
||||
location = serializers.CharField(max_length=255,
|
||||
required=False,
|
||||
allow_blank=True)
|
||||
protocol = serializers.ChoiceField(choices=
|
||||
['mqtt', 'http', 'webhook'],
|
||||
default='mqtt')
|
||||
connection_config = serializers.JSONField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class TelemetrySerializer(serializers.ModelSerializer):
|
||||
"""Serializer for telemetry data."""
|
||||
|
||||
class Meta:
|
||||
model = Telemetry
|
||||
fields = ['time', 'device_id', 'metric', 'value', 'unit']
|
||||
|
||||
|
||||
class DeviceMetricsSerializer(serializers.Serializer):
|
||||
"""Serializer for device metrics list."""
|
||||
|
||||
device_id = serializers.CharField()
|
||||
device_name = serializers.CharField()
|
||||
metrics = serializers.ListField(child=serializers.CharField())
|
||||
|
||||
|
||||
class DashboardOverviewSerializer(serializers.Serializer):
|
||||
"""Serializer for dashboard overview data."""
|
||||
|
||||
total_devices = serializers.IntegerField()
|
||||
active_devices = serializers.IntegerField()
|
||||
mqtt_devices = serializers.IntegerField()
|
||||
http_devices = serializers.IntegerField()
|
||||
certificates_expiring_soon = serializers.IntegerField()
|
||||
recent_telemetry = TelemetrySerializer(many=True)
|
||||
devices_with_metrics = DeviceMetricsSerializer(many=True)
|
||||
\end{minted}
|
||||
|
||||
|
||||
Овој serializer обезбедува:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
задолжително внесување на име на уредот (name),
|
||||
\item
|
||||
опционално дефинирање на локација (location),
|
||||
\item
|
||||
избор на комуникациски протокол (mqtt, http или webhook),
|
||||
\item
|
||||
можност за дополнителна конфигурација преку JSON структура
|
||||
(connection\_config).
|
||||
\end{itemize}
|
||||
|
||||
Со ваквиот пристап се гарантира дека кон device\_manager микросервисот
|
||||
ќе се испратат коректно форматирани и логички валидни податоци, со што
|
||||
се намалува можноста за грешки во внатрешните слоеви.
|
||||
|
||||
На овој начин, Django REST Framework serializer-ите овозможуваат:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
сигурна валидација на влезни податоци,
|
||||
\item
|
||||
унифициран излезен формат за frontend апликацијата,
|
||||
\item
|
||||
раздвојување на логиката за форматирање од бизнис-логиката,
|
||||
\item
|
||||
и стабилен API договор помеѓу сите компоненти во системот.
|
||||
\end{itemize}
|
||||
|
||||
\subsubsection{Регистрација на уреди преку \texttt{device\_manager} микросервис}
|
||||
|
||||
Регистрацијата на нов IoT уред во системот се реализира преку
|
||||
централизирана комуникација на Django API слојот и \texttt{device\_manager}
|
||||
микросервисот. Django не врши директна манипулација со сертификатите или
|
||||
било какви криптографски функционалности, туку ја има улогата посредник
|
||||
кој ги проследува валидираните податоци кон сервисот задолжен за
|
||||
управување со безбедноста.
|
||||
|
||||
Регистрацијата се иницира преку POST барање кон Django API, при што
|
||||
најпрво се врши валидација на податоците преку DeviceCreateSerializer.
|
||||
Откако податоците ќе се потврдат како валидни, тие се проследуваат кон
|
||||
\texttt{device\_manager} преку посебен клиент за комуникација со микросервисите.
|
||||
|
||||
\texttt{device\_manager} сервисот потоа:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
креира нов запис за уредот во базата на податоци,
|
||||
\item
|
||||
генерира X.509 сертификат и приватен криптографски клуч,
|
||||
\item
|
||||
го потпишува сертификатот со интерниот Certificate Authority,
|
||||
\item
|
||||
и ги враќа генерираните безбедносни податоци назад кон Django.
|
||||
\end{itemize}
|
||||
|
||||
Django API, како одговор кон frontend апликацијата, ги враќа потребните
|
||||
информации за onboarding на уредот, како што се device\_id,
|
||||
сертификатот, приватниот клуч и податокот за важноста на сертификатот.
|
||||
На овој начин се обезбедува целосно автоматизиран и безбеден процес на
|
||||
регистрација без директна изложеност на криптографските логика кон
|
||||
корисничкиот слој.
|
||||
|
||||
\begin{minted}{python}
|
||||
response = device_manager.register_device(
|
||||
name=serializer.validated_data['name'],
|
||||
location=serializer.validated_data.get('location'),
|
||||
protocol=serializer.validated_data.get('protocol', 'mqtt'),
|
||||
)
|
||||
|
||||
queryset = Telemetry.objects.filter(
|
||||
device_id=device.id,
|
||||
time__gte=timezone.now() - timedelta(hours=hours)
|
||||
)
|
||||
|
||||
if metric:
|
||||
queryset = queryset.filter(metric=metric)
|
||||
|
||||
queryset = queryset.order_by('-time')[:limit]
|
||||
\end{minted}
|
||||
|
||||
Покрај регистрацијата, преку Django API се реализира и управување со
|
||||
животниот циклус на уредите, односно:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
повлекување на сертификати (revoke),
|
||||
\item
|
||||
обновување на сертификати (renew),
|
||||
\item
|
||||
како и целосно бришење на уредите од системот.
|
||||
\end{itemize}
|
||||
|
||||
Сите овие операции се извршуваат исклучиво преку device\_manager
|
||||
микросервисот, со што се задржува централизирана контрола врз
|
||||
безбедносниот модел на системот.
|
||||
|
||||
\subsection{Имплементација на микросервисната архитектура}
|
||||
|
||||
Микросервисната архитектура претставува клучен дел од имплементацијата
|
||||
на оваа платформа, бидејќи овозможува раздвојување на комплексната
|
||||
логика во повеќе независни сервиси со јасно дефинирани одговорности.
|
||||
Секој микросервис функционира како посебна апликација со сопствена
|
||||
конфигурација, животен циклус, начин на комуникација, со што се
|
||||
зголемува флексибилноста, одржливоста и скалабилноста.
|
||||
|
||||
Во рамките на оваа платформа се користат микросервиси за:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Управување со уреди и сертификати
|
||||
\item
|
||||
Интелигентна анализа со помош на ВИ
|
||||
\item
|
||||
Прибирање на телеметриските податоци од уредите преку MQTT
|
||||
\end{itemize}
|
||||
|
||||
\subsubsection{Device\_manager микросервис - управување со уреди и сертификати}
|
||||
|
||||
Микросервисот device\_manager има централна улога во платформата,
|
||||
задолжен е за менаџирање со уредите како и безбедната комуникација
|
||||
измеѓу. Тој е задолжен за креирање на уреди, издавање, обновување и
|
||||
повлекување на X.509 сертификати, како и управување со клучевите. Овој
|
||||
сервис е имплементиран со користење на FastAPI, што овозможува високи
|
||||
перформанси и лесна интеграција со останатите делови од системот.
|
||||
|
||||
За разлика од Django backend-от кој има улога на API gateway,
|
||||
device\_manager директно работи со:
|
||||
|
||||
\begin{itemize}
|
||||
\item
|
||||
Генерирање на приватни клучеви
|
||||
\item
|
||||
Издавање на сертификати преку интерниот CA
|
||||
\item
|
||||
Креирање и ажурирање на CRL (Certificate Revocation List)
|
||||
\item
|
||||
И чување на податоците за сертификатите во базата на податоци
|
||||
\end{itemize}
|
||||
|
||||
Процесот на регистрација на еден уред во рамки на device\_manager
|
||||
микросервисот се состои од неколку чекори. Најпрво се креира запис за
|
||||
уредот во базата на податоци, по што се генерира приватен криптографски
|
||||
клуч. Потоа со тој приватен клуч се гради X.509 сертификат кој се
|
||||
потпишува со приватниот клуч на интерниот CA. На овој начин се
|
||||
обезбедува доверлива автентикација на секој уред во системот.
|
||||
|
||||
\begin{minted}{python}
|
||||
|
||||
@app.post("/devices/register")
|
||||
async def register_device(request: DeviceRegisterRequest,
|
||||
db: Session = Depends(get_db)):
|
||||
device = Device(
|
||||
name=request.name,
|
||||
location=request.location,
|
||||
protocol=request.protocol
|
||||
)
|
||||
db.add(device)
|
||||
db.commit()
|
||||
|
||||
if request.protocol == "mqtt":
|
||||
cert_pem,
|
||||
key_pem,
|
||||
ca_pem = cert_manager.generate_device_certificate(device.id)
|
||||
|
||||
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||
db_cert = DeviceCertificate(
|
||||
id=format(cert.serial_number, 'x'),
|
||||
device_id=device.id,
|
||||
expires_at=cert.not_valid_after_utc
|
||||
)
|
||||
db.add(db_cert)
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"device_id": device.id,
|
||||
"certificate_pem": cert_pem.decode(),
|
||||
"private_key_pem": key_pem.decode(),
|
||||
"ca_certificate_pem": ca_pem.decode(),
|
||||
}
|
||||
|
||||
\end{minted}
|
||||
\begin{minted}{python}
|
||||
|
||||
def generate_device_certificate(device_id: str) -> tuple[bytes, bytes, bytes]:
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
|
||||
subject = x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, device_id),
|
||||
x509.NameAttribute(NameOID.ORGANIZATION_NAME, "Lyncis IoT"),
|
||||
])
|
||||
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(ca_cert.subject)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.utcnow())
|
||||
.not_valid_after(datetime.utcnow() + timedelta(days=365))
|
||||
.sign(ca_private_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
return (
|
||||
cert.public_bytes(Encoding.PEM),
|
||||
private_key.private_bytes(Encoding.PEM, ...),
|
||||
ca_cert.public_bytes(Encoding.PEM)
|
||||
)
|
||||
\end{minted}
|
||||
|
||||
Од прикажаната имплементација со код може да се забележи дека целиот процес
|
||||
на криптографска идентификација е изолиран во рамките на device\_manager
|
||||
микросервисот. Со користење на RSA клучеви со должина од 2048 бита и
|
||||
потпишување преку интерниот CA, се обезбедува сигурна идентификација и
|
||||
доверлива комуникација помеѓу уредите и MQTT инфраструктурата. На овој
|
||||
начин се елиминира потребата од лозинки и се зголемува целокупното ниво
|
||||
на безбедност на системот.
|
||||
|
||||
\subsubsection{mqtt\_ingestion микросервис - прием на телеметриски податоци преку MQTT}
|
||||
|
||||
Микросервисот \texttt{mqtt\_ingestion} има улога на централна точка на прием на
|
||||
сите телеметриски податоци кои ги испраќаат IoT уредите до MQTT
|
||||
\texttt{broker}-от. Неговата основна задача е сигурно и безбедно да ги прими
|
||||
пораките од MQTT \texttt{broker}-от, да изврши почетна валидација и да ги
|
||||
проследи преку \texttt{Redis Stream} до сервисот за запишување во базата на
|
||||
податоци.
|
||||
|
||||
По воспоставување на конекцијата со MQTT \texttt{broker}-от, \texttt{mqtt\_ingestion} автоматски се претплатува на \texttt{topic}-от \texttt{devices/\#}, што значи дека слуша
|
||||
за сите пораки пратени во \texttt{topic}-от \texttt{device}. Ова овозможува автоматска
|
||||
претплата секаде, без динамичко претплатување на секој \texttt{topic} за секој
|
||||
поврзан уред.
|
||||
|
||||
При пристигнување на порака се повикува callback функцијата \texttt{on\_message},
|
||||
во која се парсира \texttt{topic}-от и \texttt{payload}-от.
|
||||
|
||||
\begin{minted}{python}
|
||||
def _on_message(self, client, userdata, msg):
|
||||
topic_parts = msg.topic.split("/")
|
||||
if len(topic_parts) != 3 or topic_parts[0] != "devices":
|
||||
logger.warning(f"Invalid topic format: {msg.topic}")
|
||||
return
|
||||
|
||||
device_id = topic_parts[1]
|
||||
sensor_type = topic_parts[2]
|
||||
value = float(msg.payload.decode())
|
||||
|
||||
self.message_handler(device_id, sensor_type, value)
|
||||
\end{minted}
|
||||
|
||||
\begin{minted}{python}
|
||||
def _on_connect(self, client, userdata, flags, rc):
|
||||
if rc == 0:
|
||||
client.subscribe("devices/#") # Subscribe to all device topics
|
||||
\end{minted}
|
||||
|
||||
\begin{minted}{python}
|
||||
def write_sensor_data(self, device_id: str, sensor_type: str, value: float):
|
||||
timestamp = datetime.utcnow().isoformat()
|
||||
stream_key = "mqtt:ingestion"
|
||||
|
||||
stream_data = {
|
||||
"device_id": device_id,
|
||||
"metric": sensor_type,
|
||||
"value": str(value),
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
self.redis_client.xadd(stream_key, stream_data, maxlen=10000)
|
||||
\end{minted}
|
||||
|
||||
\subsubsection{db\_write микросервис - запишување на телеметриски податоци во база}
|
||||
|
||||
Микросервисот \texttt{db\_write} има улога на посредник помеѓу \texttt{Redis Streams}
|
||||
и \texttt{TimescaleDB} базата на податоци. Негова главна одговорност е сигурно, ефикасно и
|
||||
континунирано да ги презема телеметриските податоци кои претходно биле примени и иницијално
|
||||
обработени од \texttt{mqtt\_ingetsion} микросервисот, и да ги зачува во временската база на податци.
|
||||
|
||||
Овој микросервис работи како \texttt{consumer} во рамките на Redis stream-от \texttt{mqtt:ingestion}. Со користње на consumer group механизам се овозможува
|
||||
повеќе инстанци од истиот микросервис да читаат stream паралелно, што овозможува хоризонтално скалирање и золемена отпорност на оптоварување
|
||||
|
||||
При читање на пораките, секој запис содржи податоци за идентификаторот на уредот, типот на метриката, измерената вредност и временската озанка. По приемот на податоците, тие се трансофрмираат во соодветен формат за TimescaleDB и се додаваат во batch, по што следи групно запишување во базата.
|
||||
\begin{minted}{python}
|
||||
def read_batch(self, batch_size: int, timeout_ms: int) -> List[StreamMessage]:
|
||||
results = self.redis_client.xreadgroup(
|
||||
groupname=config.consumer.group_name,
|
||||
consumername=config.consumer.consumer_name,
|
||||
streams={self.stream_name: ">"},
|
||||
count=batch_size,
|
||||
block=timeout_ms,
|
||||
)
|
||||
|
||||
messages = []
|
||||
for stream_key, entries in results:
|
||||
for message_id, fields in entries:
|
||||
stream_msg = self.schema_handler.parse_stream_entry(
|
||||
self.stream_name, message_id, fields
|
||||
)
|
||||
messages.append(stream_msg)
|
||||
return messages
|
||||
\end{minted}
|
||||
|
||||
\begin{minted}{python}
|
||||
def write_batch(self, readings: List[TelemetryReading]) -> bool:
|
||||
session = self.SessionLocal()
|
||||
try:
|
||||
db_objects = [
|
||||
Telemetry(
|
||||
time=reading.time,
|
||||
device_id=reading.device_id,
|
||||
metric=reading.metric,
|
||||
value=reading.value,
|
||||
unit=reading.unit,
|
||||
)
|
||||
for reading in readings
|
||||
]
|
||||
session.bulk_save_objects(db_objects)
|
||||
session.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
return False
|
||||
\end{minted}
|
||||
|
||||
По успешното запишување на податоците, секоја порака се означува како процесирана со механизам за потврда (acknowledgment) во Redis Streams.На овој начин се обезбедува at-least-once семантика на испорака, односно гаранција дека ниту една порака нема да биде загубена во случај на прекин на микросервисот
|
||||
|
||||
Со одвојувањето на запишувањето како посебен микросервис, системот добива подобра структура, намалена зависност помеѓу MQTT комуникацијата и базата на податоци. \texttt{db\_write} претставува клучна врска помеѓу асинхрониот пренос преку MQTT и Redis и долгорочното складирање во TimescaleDB.
|
||||
|
||||
\subsubsection{gpt\_service микросервис - интелигентна анализа на телемтриски податоци}
|
||||
|
||||
Микросервисот \texttt{gpt\_service} за задача има интелигентна обработка и анализа на телеметриските податоци со помош на големи јазични модели како \texttt{GPT, Claude} или пак \texttt{DeepSeek и Qwen}. За разлика од другите микросервиси кои се во некој дел од протокот на информации, пренос, складирање или нивна трансформација, овој сервис овозможува интерпретација, анализа и генерирање на корисни заклучоци и преораки за телеметриските податоци.
|
||||
|
||||
\texttt{gpt\_service} функциониура како независен FastAPI микросервис кој прима податоци од Микросервисот \texttt{gpt\_service} има задача да овозможи интелигентна обработка и анализа на телеметриските податоци со користење на големи јазични модели како \texttt{GPT}, \texttt{Claude}, \texttt{DeepSeek} и \texttt{Qwen}. За разлика од останатите микросервиси кои се вклучени во преносот, складирањето или трансформацијата на податоците, овој сервис овозможува нивна нтерпретација, односно автоматско извлекување заклучоци и препораки разбирливи за корисникот.
|
||||
|
||||
\texttt{gpt\_service} функционира како независен FastAPI микросервис кој прима податоци од Django backend-от преку REST API повици. Django претходно ги селектира релевантните телеметриски вредности од базата на податоци според уред, метрика и временски интервал, ги форматира во унифицирана структура и ги испраќа кон сервисот за анализа. На овој начин се избегнува директна комуникација помеѓу AI сервисот и базата на податоци, а архитектонската поделба на одговорности останува јасна и безбедна.
|
||||
|
||||
Анализата на податоците се извршува преку динамичко формирање на \textbf{промпт} кој ги содржи измерените вредности, временскиот опсег, како и контекстуалните информации за уредот (локација, тип на простор, активни метрики). Дополнително, за секоја метрика во системот се дефинирани оптимални, комфорни и критични вредности според релевантни стандарди за квалитет на внатрешна средина. Овие информации се вклучуваат во промптот со цел моделот да може да изврши прецизна проценка на состојбата.
|
||||
Врз основа на овој промпт, GPT моделот генерира:
|
||||
\begin{itemize}
|
||||
\item опис на трендовите на податоците
|
||||
\item детекција на можни аномалии
|
||||
\item проценка на условите во работната околина
|
||||
\item препораки за подобрување на комфорот и здравјето на корисникот
|
||||
\end{itemize}
|
||||
|
||||
Дополнително, сервисот овозможува и генерирање на дневни извештаи (daily briefings) кои ги комбинираат податоците од внатрешната средина, надворешните временски услови, здравствените податоци од корисникот и календарските обврски. На овој начин корисникот добива персонализирани препораки за подобрување на продуктивноста, здравјето и работната организација.
|
||||
|
||||
Добиениот резултат од анализата се враќа кон Django backend-от, од каде што се сервира на frontend апликацијата и се прикажува како интерактивен виџет на dashboard-от.
|
||||
|
||||
Со воведувањето на \texttt{gpt\_service} микросервисот, платформата добива дополнително ниво на интелигенција, со што класичното следење на сензорски податоци се надградува во систем кој активно асистира во донесувањето одлуки. Овој пристап овозможува не само пасивна визуелизација на податоците, туку и практична вредност преку автоматизирани интерпретации, предупредувања и персонализирани сугестии.
|
||||
|
||||
\subsection{Frontend имплементација}
|
||||
|
||||
Frontend апликацијата претставува главен кориснички интерфејс преку кој корисникот ја користи платформата, ги следи телеметриските податоци, пристапува до интелигентните анализи и управува со уредите и визуелните компоненти. Нејзината улога е да обезбеди интуитивен, интерактивен и прегледен приказ на сите информации што се обработуваат во backend системот, како и можност за лесна интеракција со комплексните IoT и AI функционалности.
|
||||
|
||||
Во продолжение е прикажана архитектурата на frontend апликацијата, начинот на комуникација со backend системот, визуелизацијата на податоците, интеграцијата на GPT анализите, како и поддршката за променлив распоред и работа на различни уреди.
|
||||
|
||||
\subsubsection{Архитектура на frontend апликацијата}
|
||||
|
||||
Frontend апликацијата е изработена со користење на \texttt{React 19} и \texttt{TypeScript}, додека за процесот на развој, локално тестирање и билд се користи \texttt{Vite}. Апликацијата е реализирана како Single Page Application (SPA), што овозможува брза навигација и динамичко прикажување на содржината без целосно освежување на страницата.
|
||||
|
||||
За стилизирање и визуелна конзистентност се користат \texttt{Tailwind CSS} и библиотеката со компоненти \texttt{DaisyUI}, кои овозможуваат utility-first пристап во дизајнот, брза изградба на корисничкиот интерфејс и поддршка за светол и темен режим на работа. Главната навигација е реализирана преку responsive drawer layout со странично мени, кое автоматски се адаптира за мобилни и десктоп уреди.
|
||||
|
||||
Проектната структура е организирана во логички целини, кои ги опфаќаат посебно страниците, реупотребливите UI компоненти, custom React hooks, API клиентите за комуникација со backend системот. Со ваквата поделба се овозможува подобра прегледност на кодот, полесно одржување и можност за понатамошно проширување на апликацијата.
|
||||
|
||||
Ваквиот архитектонски пристап обезбедува јасна поделба на одговорности, високи перформанси при работа со динамички податоци и добра скалабилност на корисничкиот интерфејс.
|
||||
|
||||
\subsubsection{Комуникација помеѓу Frontend и Backend}
|
||||
|
||||
Комуникацијата помеѓу frontend апликацијата и backend слојот во системот е реализиран преку REST модел, со користење на HTTP протокол и JSON формат за размена на податоци. Frontend апликаицјата е целосно одвоена од backend имплементацијата и комуницира со него исклучиво преку дефинирани API endpoints, со што се овозможува јасна логича поделба помеѓу визуелниот frontend слој и бизнис логиката во backend-от.
|
||||
|
||||
За реализација на HTTP комуникацијата во frontend делот се користи библиотеката \textbf{Axios}, која овозможува централизирана конфигурација на сите повици кон backend-от. Централизацијата HTTP клиентот овозможува лесно менување на адресата кон API-то преку околински променливи, како и едноставно и централизирано конфигурирање на безбедносни механизми и header-и. На овај начин се избегнува дуплирање на кодот и се зголемува одржливоста.
|
||||
\begin{minted}{typescript}
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
\end{minted}
|
||||
|
||||
За управување со состојбите и асинхроните податоци се користи TanStack React Query, кој овозможува автоматско кеширање, освежување и синхронизација на податоците добиени од backend-от. Овој пристап значително ја поедноставува логиката за преземање и автоматско ракување со состојбите на вчитување (loading), грешки (error), и успешни (success) одговори, без потреба на рачна имплементација на овие механизми.
|
||||
|
||||
React Query овозможува и автоматско периодично освежување на пдоатоците, што е доста битно кај ваквиот тип на податоци и оваа плафрома, бидејќи податоците се читаат во реално време. Во имплементацијата, поголемиото дел од телеметриските виџети се освежуваат на секои 60 секунди, што овозможува приказ на релативно свежи информации без преоптоварување на backend системот и базата на податоци. Дополнително, React Query овозможува рачно иницирање на повторно преземање на податоците од страна на корисникот, како на пример при иницирање на анализа.
|
||||
|
||||
\begin{minted}{typescript}
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['telemetry', deviceId, metric],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.query(params)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 60000,
|
||||
staleTime: 30000,
|
||||
})
|
||||
\end{minted}
|
||||
|
||||
Справувањето со грешки и прикажувањето на состојбите на вчитување е унифицирано низ целата апликација. При секое вчитување на податоци се прижаува анимација за вчитување, додека при појава на грешка се прикажува нотификација со опис на проблемот. Овој пристап значително го подобрува корисничкото искуство и овозможува полесно справување со пролемите.
|
||||
|
||||
\begin{minted}{tsx}
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['telemetry', deviceId],
|
||||
queryFn: () => telemetryApi.getSeries(deviceId, metric),
|
||||
refetchInterval: 60000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <span className="loading loading-spinner loading-lg"></span>
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className="alert alert-error">Failed to load telemetry</div>
|
||||
}
|
||||
return <div>{data.map(item => ...)}</div>
|
||||
\end{minted}
|
||||
|
||||
Со вака изведена комуникација помеѓу frontend и backend делот, платформата обезбедува:
|
||||
\begin{itemize}
|
||||
\item стабилен и сигурен пренос на податоци
|
||||
\item автоматско освежување на телеметриските информации
|
||||
\item скалабилна обработка на AI анализи
|
||||
\item конзистентен, интуитивен кориснички интерфејс
|
||||
\end{itemize}
|
||||
|
||||
\subsubsection{Динамичко рендерирање на виџети и визуелзација на податоци}
|
||||
|
||||
Со цел да се обезбеди флексибилна и проширлива визуелизација на различни типови податоци во рамките на dashboard-от, frontend делот од платформата користи динамички систем за прикажување на виџети, имплементиран преку registry и factory pattern. На овој начин се овозможува динамичко креирање и рендерирање на различни типови виџети врз основа на корисничката конфигурација. Наместо директно условно рендерирање со \texttt{if-else} или \texttt{switch} услови, секој тип на виџет се мапира кон соодветна React компонента преку централна registry структура.
|
||||
|
||||
\begin{minted}{typescript}
|
||||
export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'line-chart': LineChartWidget,
|
||||
'stat': StatWidget,
|
||||
'gauge': GaugeWidget,
|
||||
'ai-insight': AiInsightWidget,
|
||||
'air-quality': AirQualityWidget,
|
||||
'weather': WeatherWidget,
|
||||
'comfort-index': ComfortIndexWidget,
|
||||
'run-suitability': RunSuitabilityWidget,
|
||||
'health-stats': HealthStatsWidget,
|
||||
'calendar': CalendarWidget,
|
||||
'daily-briefing': DailyBriefingWidget,
|
||||
}
|
||||
\end{minted}
|
||||
|
||||
Овој пристап овозможува системот да биде модуларен, бидејќи додавање на нов тип на виџет бара само регистрација во registry-то, без промени во логиката за рендерирање.
|
||||
|
||||
Динамичкото креирање на виџетите се извршува преку компонентата WidgetContainer, која врши lookup во registry-то и ја прикажува соодветната React компонента.
|
||||
|
||||
\begin{minted}{tsx}
|
||||
function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) {
|
||||
const WidgetComponent = widgetRegistry[config.type]
|
||||
|
||||
if (!WidgetComponent) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<p className="text-error">Unknown widget type: {config.type}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <WidgetComponent config={config} />
|
||||
}
|
||||
\end{minted}
|
||||
|
||||
Секој виџет во системот прима унифициран објект од тип \texttt{WidgetConfig}, кој ги дефинира сите потребни параметри за функционирање на виџетот: уредите кои ги користи, метриките, временскиот опсег, визуелните опции и позицијата во распоредот. Овој модел овозможува управување со сите типови на виџети без разлика на нивната конкретна намена.
|
||||
|
||||
\begin{minted}{typescript}
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
type: WidgetType
|
||||
title: string
|
||||
deviceIds: string[]
|
||||
metricIds: string[]
|
||||
timeframe: {
|
||||
hours?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
visualization?: {
|
||||
colors?: string[]
|
||||
showLegend?: boolean
|
||||
showGrid?: boolean
|
||||
}
|
||||
position?: { x: number; y: number; w: number; h: number }
|
||||
}
|
||||
\end{minted}
|
||||
|
||||
Кај графиците со повеќе метрики (на пример температура + $CO_2$ + влажност), податоците
|
||||
од повеќе API повици се спојуваат во една временска структура преку \texttt{useMemo}, при што сите метрики се усогласуваат според временска ознака, при што се усогласуваат спроед временска ознака. Ова овозможува Recharts да приже повеќе линии на иста временска оска.
|
||||
|
||||
\begin{minted}{typescript}
|
||||
const chartData = useMemo(() => {
|
||||
const timeMap = new Map<number, Record<string, number | string>>()
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
const metric = metricIds[index]
|
||||
query.data?.forEach((point) => {
|
||||
const timestamp = new Date(point.time).getTime()
|
||||
|
||||
if (!timeMap.has(timestamp)) {
|
||||
timeMap.set(timestamp, {
|
||||
time: formatTime(new Date(timestamp)),
|
||||
timestamp
|
||||
})
|
||||
}
|
||||
timeMap.get(timestamp)![metric] = point.value
|
||||
})
|
||||
})
|
||||
|
||||
return Array.from(timeMap.values())
|
||||
.sort((a, b) => (a.timestamp as number) - (b.timestamp as number))
|
||||
}, [queries, metricIds])
|
||||
\end{minted}
|
||||
|
||||
Со користење на \texttt{useMemo} се елиминира непотребно рекалкуриање при секое ре-рендерирање и се подобрува перформансот при големи количини на податоци.
|
||||
|
||||
Визуелизацијата на податоците во графикони е реализирана преку библиотеката \textbf{Recharts}, каде секоја метрика динамички се прикажува како независна линија во во \texttt{LineChart}. Бројот на линии е директно зависен од бројот на активни метрики во конфигурацијата на виџетот.
|
||||
|
||||
\begin{minted}{tsx}
|
||||
const lines = useMemo(() =>
|
||||
metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
isAnimationActive={false}
|
||||
name={formatMetricName(metric)}
|
||||
/>
|
||||
)),
|
||||
[metricIds, colors]
|
||||
)
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{lines}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
\end{minted}
|
||||
|
||||
Анимиациите се исклучени за подобри перформанси при работа со поголем број на точки, а боите се доделуваат циклично преку параметар.
|
||||
|
||||
Распоредот на виџети во dashboard-от е имплементира преку \texttt{react-grid-layout}, кој овозможува слободно поместување менување на димензии и автоматско реорганизирање на елементите. Распоредот се гради динамички од \texttt{WidgetConfig.position} за секој виџет.
|
||||
|
||||
\begin{minted}{tsx}
|
||||
const GRID_COLUMNS = 5
|
||||
const ROW_HEIGHT = 90
|
||||
const GRID_MARGIN: [number, number] = [8, 6]
|
||||
|
||||
const layout = config.widgets.map((widget) => ({
|
||||
i: widget.id,
|
||||
x: widget.position?.x ?? 0,
|
||||
y: widget.position?.y ?? Infinity,
|
||||
w: widget.position?.w ?? 1,
|
||||
h: widget.position?.h ?? 1,
|
||||
minW: 1, minH: 1, maxW: GRID_COLUMNS,
|
||||
}))
|
||||
|
||||
<GridLayout
|
||||
layout={layout}
|
||||
cols={GRID_COLUMNS}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={GRID_MARGIN}
|
||||
/>
|
||||
\end{minted}
|
||||
|
||||
|
||||
За секоја промена на позицијата или димензиите на виџетите се пресликува назад во конфигурацијата преку handleLayoutChange. Неговата состојба се зачувува:
|
||||
\begin{itemize}
|
||||
\item локално во \texttt{localStorage} - моментално
|
||||
\item во backend преку dashboardLayoutApi - за долгорочна перзистенција
|
||||
\end{itemize}
|
||||
|
||||
\begin{minted}{typescript}
|
||||
const handleLayoutChange = (newLayout: GridLayout.Layout[]) => {
|
||||
newLayout.forEach((item) => {
|
||||
const widget = config.widgets.find((w) => w.id === item.i)
|
||||
if (widget) {
|
||||
updateWidget(item.i, {
|
||||
position: { x: item.x, y: item.y, w: item.w, h: item.h },
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
\end{minted}
|
||||
|
||||
Овој dual-write механизам овозможува системот да функционира и при губење на конекција, а истовремено да одржува синхронизирана состојба на распоредот меѓу сесиите.
|
||||
|
||||
Со оваа архитектура на виџет системот, платформата обезбедува целосно динамизен, проширлив и перформантен frontend, кој овозможува визуелизација на голем волумен на временски податоци, интерактино управување со распоредот и јасна интеграција со backend-от.
|
||||
8
thesis/Source files/06_results.tex
Normal file
8
thesis/Source files/06_results.tex
Normal file
@@ -0,0 +1,8 @@
|
||||
\newpage
|
||||
|
||||
\section{Резултати}
|
||||
|
||||
% TODO: Додади резултати од тестирање и евалуација
|
||||
% - Screenshots од dashboard
|
||||
% - Перформанси на системот
|
||||
% - Примери на AI анализа
|
||||
8
thesis/Source files/07_conclusion.tex
Normal file
8
thesis/Source files/07_conclusion.tex
Normal file
@@ -0,0 +1,8 @@
|
||||
\newpage
|
||||
|
||||
\section{Заклучок}
|
||||
|
||||
% TODO: Напиши заклучок
|
||||
% - Резиме на постигнатото
|
||||
% - Придонеси на трудот
|
||||
% - Идни насоки за развој
|
||||
41
thesis/Source files/bibliography.tex
Normal file
41
thesis/Source files/bibliography.tex
Normal file
@@ -0,0 +1,41 @@
|
||||
\newpage
|
||||
\phantomsection
|
||||
\addcontentsline{toc}{section}{Библиографија}
|
||||
|
||||
\begin{thebibliography}{99}
|
||||
|
||||
% TODO: Додади референци
|
||||
|
||||
\bibitem{mqtt}
|
||||
MQTT Version 5.0, OASIS Standard, 2019.
|
||||
\url{https://docs.oasis-open.org/mqtt/mqtt/v5.0/mqtt-v5.0.html}
|
||||
|
||||
\bibitem{timescaledb}
|
||||
TimescaleDB Documentation.
|
||||
\url{https://docs.timescale.com/}
|
||||
|
||||
\bibitem{fastapi}
|
||||
FastAPI Documentation.
|
||||
\url{https://fastapi.tiangolo.com/}
|
||||
|
||||
\bibitem{django}
|
||||
Django REST Framework Documentation.
|
||||
\url{https://www.django-rest-framework.org/}
|
||||
|
||||
\bibitem{redis}
|
||||
Redis Streams Documentation.
|
||||
\url{https://redis.io/docs/data-types/streams/}
|
||||
|
||||
\bibitem{mtls}
|
||||
Mutual TLS (mTLS) Authentication.
|
||||
\url{https://www.cloudflare.com/learning/access-management/what-is-mutual-tls/}
|
||||
|
||||
\bibitem{x509}
|
||||
RFC 5280 - Internet X.509 Public Key Infrastructure Certificate and Certificate Revocation List (CRL) Profile.
|
||||
\url{https://tools.ietf.org/html/rfc5280}
|
||||
|
||||
\bibitem{mosquitto}
|
||||
Eclipse Mosquitto MQTT Broker.
|
||||
\url{https://mosquitto.org/}
|
||||
|
||||
\end{thebibliography}
|
||||
69
thesis/Source files/setup.tex
Normal file
69
thesis/Source files/setup.tex
Normal file
@@ -0,0 +1,69 @@
|
||||
\usepackage[top=2.5cm, bottom=3.5cm, left=2.5cm, right=2.5cm]{geometry}
|
||||
\usepackage[utf8]{inputenc}
|
||||
\usepackage[T1]{fontenc}
|
||||
\usepackage[macedonian]{babel}
|
||||
\usepackage[parfill]{parskip}
|
||||
\usepackage{graphicx}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{listings}
|
||||
\usepackage{caption}
|
||||
\renewcommand{\lstlistingname}{Код}
|
||||
|
||||
\usepackage{xcolor}
|
||||
\usepackage{pgfplots}
|
||||
\pgfplotsset{compat=1.18}
|
||||
|
||||
\usepackage{setspace}
|
||||
\usepackage{lipsum}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage[hidelinks]{hyperref}
|
||||
\usepackage{longtable}
|
||||
\usepackage{array}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{minted}
|
||||
\setminted{
|
||||
style=friendly,
|
||||
fontsize=\normalsize,
|
||||
tabsize=2
|
||||
}
|
||||
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\fancyfoot[C]{\large \thepage}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
\setlength{\footskip}{15mm}
|
||||
|
||||
% Code listing style for Python
|
||||
\lstdefinestyle{python}{
|
||||
language=Python,
|
||||
basicstyle=\ttfamily\small,
|
||||
keywordstyle=\bfseries\color{blue},
|
||||
commentstyle=\itshape\color{gray},
|
||||
stringstyle=\color{orange},
|
||||
numbers=left,
|
||||
numberstyle=\tiny\color{gray},
|
||||
stepnumber=1,
|
||||
frame=single,
|
||||
breaklines=true,
|
||||
showstringspaces=false,
|
||||
captionpos=b,
|
||||
xleftmargin=0.05\textwidth,
|
||||
xrightmargin=0.05\textwidth
|
||||
}
|
||||
|
||||
\lstset{
|
||||
basicstyle=\ttfamily\small,
|
||||
keywordstyle=\bfseries\color{blue},
|
||||
commentstyle=\itshape\color{gray},
|
||||
stringstyle=\color{cyan},
|
||||
numbers=left,
|
||||
numberstyle=\tiny\color{gray},
|
||||
stepnumber=1,
|
||||
frame=single,
|
||||
breaklines=true,
|
||||
showstringspaces=false,
|
||||
captionpos=b,
|
||||
xleftmargin=0.05\textwidth,
|
||||
xrightmargin=0.05\textwidth
|
||||
}
|
||||
63
thesis/main.tex
Normal file
63
thesis/main.tex
Normal file
@@ -0,0 +1,63 @@
|
||||
\documentclass[a4paper]{article}
|
||||
|
||||
\input{Source files/setup}
|
||||
|
||||
|
||||
\newcommand{\UniversityName}
|
||||
{Универзитет „Св. Кирил и Методиј" во Скопје}
|
||||
\newcommand{\FacultyName}
|
||||
{Факултет за информатички науки и компјутерско инженерство}
|
||||
\newcommand{\ProjectType}
|
||||
{Дипломска работа}
|
||||
|
||||
\newcommand{\ProjectTitle}
|
||||
{Lyncis – IoT платформа за собирање и анализа на информации за работната околина}
|
||||
\newcommand{\ProjectMentor}
|
||||
{Проф. д-р Игор Мишковски}
|
||||
\newcommand{\ProjectCandidate}
|
||||
{Андреј Мицков}
|
||||
\newcommand{\ProjectCandidateIndex}
|
||||
{216014}
|
||||
\newcommand{\ProjectDate}
|
||||
{Декември 2025}
|
||||
|
||||
|
||||
\begin{document}
|
||||
|
||||
% Насловна страна
|
||||
\input{Source files/00_titlepage}
|
||||
|
||||
\setlength{\parindent}{15pt}
|
||||
\fontsize{12pt}{14pt}\selectfont
|
||||
|
||||
% Апстракт
|
||||
\input{Source files/01_abstract}
|
||||
|
||||
% Содржина
|
||||
\newpage
|
||||
\renewcommand{\contentsname}{\LARGE Содржина}
|
||||
\tableofcontents
|
||||
|
||||
% Вовед
|
||||
\input{Source files/02_introduction}
|
||||
|
||||
% Основи на IoT и применети технологии
|
||||
\input{Source files/03_theory}
|
||||
|
||||
% Дизајн на системот
|
||||
\input{Source files/04_design}
|
||||
|
||||
% Имплементација
|
||||
\input{Source files/05_implementation}
|
||||
|
||||
% Резултати
|
||||
\input{Source files/06_results}
|
||||
|
||||
% Заклучок
|
||||
\input{Source files/07_conclusion}
|
||||
|
||||
% Библиографија
|
||||
\input{Source files/bibliography}
|
||||
|
||||
|
||||
\end{document}
|
||||
216
uv.lock
generated
216
uv.lock
generated
@@ -114,6 +114,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -134,12 +146,16 @@ version = "0.1.0"
|
||||
source = { virtual = "db_migrations" }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "dotenv" },
|
||||
{ name = "psycopg2-binary" },
|
||||
{ name = "sqlalchemy" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.17.0" },
|
||||
{ name = "dotenv", specifier = ">=0.9.9" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.10" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.44" },
|
||||
]
|
||||
|
||||
@@ -215,6 +231,17 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenv"
|
||||
version = "0.9.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.121.0"
|
||||
@@ -239,6 +266,7 @@ dependencies = [
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -247,6 +275,7 @@ requires-dist = [
|
||||
{ name = "openai", specifier = ">=2.6.1" },
|
||||
{ name = "pydantic", specifier = ">=2.12.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -299,6 +328,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
@@ -340,6 +391,7 @@ dependencies = [
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "redis" },
|
||||
{ name = "requests" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -361,6 +413,7 @@ requires-dist = [
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "redis", specifier = ">=7.0.1" },
|
||||
{ name = "requests", specifier = ">=2.32.5" },
|
||||
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -621,6 +674,42 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.0.1"
|
||||
@@ -781,3 +870,130 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user