Merge pull request #8 from ferdzo/feature/device-manager

Device Manager Service
This commit is contained in:
2025-11-03 20:51:59 +01:00
committed by GitHub
51 changed files with 2193 additions and 1045 deletions

View File

@@ -1,66 +1,114 @@
The following concise instructions help AI coding agents become productive in this repository.
Purpose
- This repo is a small Django-based IoT dashboard that ingests sensor data via MQTT, stores metadata in Django models, temporarily queues messages in Redis (streams/hashes), and persistently stores timeseries in Postgres/Timescale via background tasks (Huey).
- This repo is a microservices-based IoT platform for device management, data ingestion, and telemetry storage. The system uses MQTT with mTLS authentication, Redis streams for message queuing, and PostgreSQL/TimescaleDB for persistent storage.
Big Picture
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
- Components:
- `iotDashboard/` — Django app (models, views, templates, tasks)
- `manage.py` — Django CLI
- `mqtt_service.py` — standalone MQTT client that subscribes to device topics and writes to Redis
- `tasks.py` — Huey periodic tasks that read Redis and write to Postgres
- Redis — used for device metadata (`mqtt_devices`), per-sensor streams and latest-value hashes
- Postgres/Timescale — final storage for `sensor_readings` table (raw SQL used in places)
- `services/device_manager/` — FastAPI service for device registration, X.509 certificate issuance, and lifecycle management
- `services/mqtt_ingestion/` — MQTT client that subscribes to device topics and writes to single Redis stream `mqtt:ingestion`
- `services/db_write/` — Consumer service that reads from Redis streams and writes to database using consumer groups
- `db_migrations/` — Alembic migrations for schema management (SQLAlchemy models)
- `infrastructure/` — Docker Compose setup (PostgreSQL, Redis, Mosquitto MQTT broker)
- `iotDashboard/` — Legacy Django app (being phased out)
Key Files To Read First
- `iotDashboard/settings.py` — central settings; environment variables expected: `SECRET_KEY`, `CONNECTION_STRING`, `MQTT_BROKER`, `MQTT_USER`, `MQTT_PASS`, `REDIS_HOST`.
- `iotDashboard/models.py``Device`, `Sensor`, `SensorType`; these shape how devices and sensors are represented.
- `mqtt_service.py` — where MQTT messages are received and written to Redis. Important for stream naming and payload format.
- `iotDashboard/tasks.py` — Huey tasks that consume Redis and insert into the DB. Shows ingestion logic and timescale interactions.
- `iotDashboard/views.py` and `templates/chart.html` — how the UI reads `mqtt_latest`/Timescale data and what format it expects.
- `db_migrations/models.py` — SQLAlchemy models: `Device`, `DeviceCertificate`, `Telemetry`. Canonical schema definition.
- `services/device_manager/app/app.py` — FastAPI endpoints for device registration, certificate management, revocation, renewal.
- `services/device_manager/app/cert_manager.py` — X.509 certificate generation, CA management, CRL generation.
- `services/mqtt_ingestion/src/mqtt_client.py` — MQTT subscriber that parses `devices/{device_id}/{metric}` topics.
- `services/mqtt_ingestion/src/redis_writer.py` — Writes to single stream `mqtt:ingestion` with device_id, metric, value, timestamp.
- `services/db_write/src/redis_reader.py` — Consumer group reader for `mqtt:ingestion` stream.
- `services/db_write/src/db_writer.py` — Batch writes to `telemetry` table using SQLAlchemy.
- `infrastructure/compose.yml` — Docker services: PostgreSQL/TimescaleDB, Redis, Mosquitto MQTT.
- `infrastructure/mosquitto/mosquitto.conf` — MQTT broker config with mTLS on port 8883, CRL checking enabled.
Important Conventions & Patterns
- Redis usage: repo stores device metadata under `mqtt_devices` (JSON), and the code uses Redis streams and hashes inconsistently. When changing stream behavior, update both `mqtt_service.py` and `tasks.py` to remain compatible.
- Topic/Stream canonicalization: adopt a single convention: MQTT topic `devices/{device_id}/{sensor}` and Redis stream `mqtt_stream:{device_id}:{sensor}`. Latest-value hash pattern: `mqtt_latest:{device_id}`.
- No `requirements.txt` in repo; use `python-dotenv` + `redis`, `paho-mqtt`, `huey`, `psycopg2-binary`, `requests`, `Django` (4.2) — add a `requirements.txt` before running.
- Avoid import-time side-effects: `tasks.py` currently opens Redis and calls `devices_to_redis()` at import time — refactor to lazy init or a management command.
- **Single stream architecture**: All MQTT data flows through one Redis stream `mqtt:ingestion`. Each message contains `device_id`, `metric`, `value`, `timestamp`.
- **MQTT topics**: Standard format `devices/{device_id}/{metric}`. Examples: `devices/abc123/temperature`, `devices/xyz789/humidity`.
- **Certificate IDs**: Use certificate serial number (hex format) as primary key in `device_certificates` table. Multiple certificates per device supported.
- **Package manager**: All services use `uv` for dependency management (`pyproject.toml` not `requirements.txt`).
- **Database migrations**: Use Alembic for schema changes. Run migrations from `db_migrations/` directory.
- **Configuration**: All services use `.env` files. Never hardcode hosts/credentials.
- **Import organization**: Services have `app/` or `src/` package structure. Import as `from app.module import ...` or `from src.module import ...`.
- **Consumer groups**: `db_write` uses Redis consumer groups for at-least-once delivery. Consumer name must be unique per instance.
Developer Workflows (commands & notes)
- Run Django dev server (use virtualenv and install deps):
- `pip install -r requirements.txt` (create this file if missing)
- `python manage.py migrate`
- `python manage.py runserver`
- Run MQTT service locally (requires Redis & MQTT broker):
- `python mqtt_service.py`
- Example publish: `mosquitto_pub -t "devices/esp32/test_temperature" -m "23.5"`
- Huey tasks:
- The project uses `huey.contrib.djhuey`; run workers with Django settings: `python manage.py run_huey` (ensure huey is installed and configured in `HUEY` setting).
- Inspect Redis during debugging:
- `redis-cli KEYS "mqtt*"`
- `redis-cli XREVRANGE mqtt_stream:mydevice:temperature + - COUNT 10`
- `redis-cli HGETALL mqtt_latest:mydevice`
- **Start infrastructure**: `cd infrastructure && docker compose up -d` (Postgres, Redis, Mosquitto)
- **Run database migrations**: `cd db_migrations && uv run alembic upgrade head`
- **Generate CA certificate**: `cd services/device_manager && ./generate_ca.sh` (first time only)
- **Run device_manager**: `cd services/device_manager && uv run uvicorn app.app:app --reload --port 8000`
- **Run mqtt_ingestion**: `cd services/mqtt_ingestion && uv run main.py`
- **Run db_write**: `cd services/db_write && uv run main.py`
- **Register device**: `curl -X POST http://localhost:8000/devices/register -H "Content-Type: application/json" -d '{"name":"test","location":"lab"}'`
- **Test MQTT with mTLS**: `mosquitto_pub --cafile ca.crt --cert device.crt --key device.key -h localhost -p 8883 -t "devices/abc123/temperature" -m "23.5"`
- **Inspect Redis stream**: `redis-cli XLEN mqtt:ingestion` and `redis-cli XRANGE mqtt:ingestion - + COUNT 10`
- **Check consumer group**: `redis-cli XINFO GROUPS mqtt:ingestion`
- **View CRL**: `openssl crl -in infrastructure/mosquitto/certs/ca.crl -text -noout`
Integration Points & Gotchas
- Environment variables: many hosts/credentials are taken from `.env` via `python-dotenv`. If missing, code sometimes falls back to defaults or will raise at runtime. Add `.env` or set env vars in the system.
- DB access: `tasks.py` sometimes uses `psycopg2.connect(settings.CONNECTION_STRING)` while views use Django connections. If you change DB config, update both patterns or consolidate to Django connections.
- Topic parsing: `mqtt_service.py` expects at least 3 topic parts (it reads `topic_parts[2]`) — be defensive when editing.
- Stream payloads: `xadd` must receive simple string fields (no nested dicts). When changing stream layout, update the reader in `tasks.py` accordingly.
- Logging: repo uses `print` widely. Prefer converting prints to Python `logging` for maintainability.
- **Environment variables**: All services load from `.env` files. No defaults - service will fail if required vars missing. Copy `.env.example` first.
- **Certificate paths**: `device_manager` writes CRL to `infrastructure/mosquitto/certs/ca.crl`. Mosquitto must restart after CRL updates.
- **Database schema**: Schema changes require Alembic migration. Never modify tables manually. Use `alembic revision --autogenerate`.
- **MQTT topic parsing**: `mqtt_ingestion` expects exactly `devices/{device_id}/{metric}` (3 parts). Invalid topics are logged and dropped.
- **Redis stream format**: `mqtt:ingestion` messages must have `device_id`, `metric`, `value`, `timestamp` fields (all strings).
- **Consumer groups**: `db_write` creates consumer group `db_writer` automatically. Don't delete it manually.
- **Certificate serial numbers**: Used as primary key in `device_certificates.id`. Extract with `format(cert.serial_number, 'x')`.
- **TimescaleDB hypertables**: `telemetry` table is a hypertable. Don't add constraints that break time partitioning.
- **File permissions**: Mosquitto directories may be owned by UID 1883. Fix with `sudo chown -R $USER:$USER infrastructure/mosquitto/`.
What AI agents should do first
- Do not change stream/topic names unless you update both `mqtt_service.py` and `tasks.py`.
- Remove import-time Redis initializations and `exit()` calls; move to lazily-created client getters or management commands.
- Centralize config in `settings.py` and import `from django.conf import settings` in scripts instead of hardcoded IPs.
- When making API/DB changes, prefer to update `views.py` and `tasks.py` together and add short integration tests using `pytest` and a Redis test double (or local docker-compose).
- **Read architecture first**: Check `README.md` for current architecture. System is microservices-based, not Django monolith.
- **Check database schema**: Always start with `db_migrations/models.py` to understand data model.
- **Don't change stream names**: Single stream `mqtt:ingestion` is used by mqtt_ingestion and db_write. Changing breaks both services.
- **Use proper imports**: Services use package structure. Import from `app.*` or `src.*`, not relative imports.
- **Create migrations**: Schema changes require `alembic revision --autogenerate`. Never modify models without migration.
- **Test with real infrastructure**: Use `docker compose up` for integration testing. Unit tests are insufficient for this architecture.
- **Check .env files**: Each service has `.env.example`. Copy and configure before running.
Examples (copyable snippets)
- XADD to create canonical stream entry:
- `redis_client.xadd(f"mqtt_stream:{device_id}:{sensor}", {"value": str(sensor_value), "time": datetime.utcnow().isoformat()})`
- Create/read consumer group (ingest):
- `redis_client.xgroup_create(stream, "ingest", id="0", mkstream=True)`
- `entries = redis_client.xreadgroup("ingest", consumer_name, {stream: ">"}, count=10, block=5000)`
- **Write to single stream** (mqtt_ingestion):
```python
redis_client.xadd("mqtt:ingestion", {
"device_id": device_id,
"metric": sensor_type,
"value": str(value),
"timestamp": datetime.utcnow().isoformat()
})
```
- **Read from stream with consumer group** (db_write):
```python
results = redis_client.xreadgroup(
groupname="db_writer",
consumername="worker-01",
streams={"mqtt:ingestion": ">"},
count=100,
block=5000
)
```
- **Extract certificate serial number**:
```python
from cryptography import x509
cert = x509.load_pem_x509_certificate(cert_pem)
cert_id = format(cert.serial_number, 'x')
```
- **Query active certificates**:
```python
device_cert = db.query(DeviceCertificate).filter(
DeviceCertificate.device_id == device_id,
DeviceCertificate.revoked_at.is_(None)
).first()
```
If you add or change docs
- Update `README.md` with a simple `docker-compose.yml` recipe for Redis/Postgres/Mosquitto and document environment variables. Update `env.sample` with `REDIS_HOST`, `CONNECTION_STRING`, `MQTT_BROKER`, `MQTT_USER`, `MQTT_PASS`.
- Update `README.md` for architecture changes
- Update `.github/copilot-instructions.md` for development workflow changes
- Update service-specific READMEs (`services/*/README.md`) for API or configuration changes
- Document environment variables in `.env.example` files
- Add migration notes to Alembic revision if schema change is complex
If anything in these instructions looks off or incomplete for your current refactor, tell me what you'd like to focus on and I'll iterate.

View File

@@ -1,2 +1,17 @@
# iotDashboard
iotDashboard - IOT dashboard with Django, TimescaleDB and Redis
# IoT Dashboard
Microservices-based IoT platform with device management, mTLS authentication, and time-series data storage.
**Architecture:** Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
## Services
- **device_manager** - Device registration & X.509 certificates (FastAPI)
- **mqtt_ingestion** - MQTT → Redis pipeline
- **db_write** - Redis → PostgreSQL writer
- **infrastructure** - Docker Compose (PostgreSQL, Redis, Mosquitto)
## License
MIT License

View File

@@ -0,0 +1,60 @@
"""add_certificate_id_and_indices
Revision ID: 4f152b34e800
Revises: f94393f57c35
Create Date: 2025-10-30 21:29:43.843375+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4f152b34e800'
down_revision: Union[str, Sequence[str], None] = 'f94393f57c35'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# Step 1: Add id column as nullable first
op.add_column('device_certificates', sa.Column('id', sa.Text(), nullable=True))
# Step 2: Generate IDs for existing records (use device_id as temporary ID)
op.execute("""
UPDATE device_certificates
SET id = device_id || '-' || EXTRACT(EPOCH FROM issued_at)::text
WHERE id IS NULL
""")
# Step 3: Drop old primary key constraint
op.drop_constraint('device_certificates_pkey', 'device_certificates', type_='primary')
# Step 4: Make id NOT NULL now that all rows have values
op.alter_column('device_certificates', 'id', nullable=False)
# Step 5: Create new primary key on id
op.create_primary_key('device_certificates_pkey', 'device_certificates', ['id'])
# Step 6: Create indices
op.create_index('idx_device_certificates_active', 'device_certificates', ['device_id', 'revoked_at'], unique=False)
op.create_index('idx_device_certificates_device_id', 'device_certificates', ['device_id'], unique=False)
def downgrade() -> None:
"""Downgrade schema."""
# Drop indices
op.drop_index('idx_device_certificates_device_id', table_name='device_certificates')
op.drop_index('idx_device_certificates_active', table_name='device_certificates')
# Drop new primary key
op.drop_constraint('device_certificates_pkey', 'device_certificates', type_='primary')
# Recreate old primary key on device_id
op.create_primary_key('device_certificates_pkey', 'device_certificates', ['device_id'])
# Drop id column
op.drop_column('device_certificates', 'id')

View File

@@ -0,0 +1,55 @@
"""add protocol and connection_config to devices
Revision ID: 4e405f1129b1
Revises: 4f152b34e800
Create Date: 2025-11-01 19:07:22.800918+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '4e405f1129b1'
down_revision: Union[str, Sequence[str], None] = '4f152b34e800'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('device_credentials',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('device_id', sa.Text(), nullable=False),
sa.Column('credential_type', sa.Text(), nullable=False),
sa.Column('credential_hash', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('idx_device_credentials_active', 'device_credentials', ['device_id', 'revoked_at'], unique=False)
op.create_index('idx_device_credentials_device_id', 'device_credentials', ['device_id'], unique=False)
# Add protocol column as nullable first, set default for existing rows, then make NOT NULL
op.add_column('devices', sa.Column('protocol', sa.Text(), nullable=True))
op.execute("UPDATE devices SET protocol = 'mqtt' WHERE protocol IS NULL")
op.alter_column('devices', 'protocol', nullable=False)
op.add_column('devices', sa.Column('connection_config', sa.JSON(), nullable=True))
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('devices', 'connection_config')
op.drop_column('devices', 'protocol')
op.drop_index('idx_device_credentials_device_id', table_name='device_credentials')
op.drop_index('idx_device_credentials_active', table_name='device_credentials')
op.drop_table('device_credentials')
# ### end Alembic commands ###

View File

@@ -8,7 +8,7 @@ To modify schema:
4. Run: alembic upgrade head
"""
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Text, DateTime
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Text, DateTime, JSON
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
@@ -23,29 +23,59 @@ class Device(Base):
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
location = Column(Text)
protocol = Column(Text, nullable=False, default="mqtt")
connection_config = Column(JSON)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<Device(id={self.id}, name={self.name})>"
return f"<Device(id={self.id}, name={self.name}, protocol={self.protocol})>"
class DeviceCertificate(Base):
"""X.509 certificates issued to devices for mTLS authentication."""
__tablename__ = "device_certificates"
id = Column(Text, primary_key=True)
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), primary_key=True
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
certificate_pem = Column(Text, nullable=False)
private_key_pem = Column(Text) # Optional: for backup/escrow
private_key_pem = Column(Text)
issued_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_device_certificates_device_id", "device_id"),
Index("idx_device_certificates_active", "device_id", "revoked_at"),
)
def __repr__(self):
return f"<DeviceCertificate(device_id={self.device_id}, expires={self.expires_at})>"
return f"<DeviceCertificate(id={self.id}, device_id={self.device_id}, expires={self.expires_at})>"
class DeviceCredential(Base):
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
__tablename__ = "device_credentials"
id = Column(Text, primary_key=True)
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
credential_type = Column(Text, nullable=False)
credential_hash = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True))
revoked_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_device_credentials_device_id", "device_id"),
Index("idx_device_credentials_active", "device_id", "revoked_at"),
)
def __repr__(self):
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
class Telemetry(Base):

3
infrastructure/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
mosquitto/certs/
mosquitto/data/
mosquitto/logs/

View File

@@ -15,7 +15,7 @@ services:
- "9001:9001"
- "8883:8883"
volumes:
- ./mosquitto/:/mosquitto/config/
- ./mosquitto/:/mosquitto/:Z
restart: unless-stopped
timescaledb:

View File

@@ -3,7 +3,7 @@ persistence true
persistence_location /mosquitto/data/
# Logging
log_dest file /mosquitto/log/mosquitto.log
#log_dest file /mosquitto/log/mosquitto.log
# Standard MQTT listener (for testing without certs)
listener 1883
@@ -15,11 +15,14 @@ allow_anonymous true
protocol mqtt
# Server certificates (mosquitto's identity)
certfile /mosquitto/config/server.crt
keyfile /mosquitto/config/server.key
certfile /mosquitto/certs/server.crt
keyfile /mosquitto/certs/server.key
# CA certificate to verify client certificates
cafile /mosquitto/config/ca.crt
cafile /mosquitto/certs/ca.crt
# CRL file
crlfile /mosquitto/certs/ca.crl
# Certificate-based authentication
require_certificate true

View File

@@ -0,0 +1,193 @@
"""API client for the device_manager microservice."""
import os
import requests
from typing import Optional, Dict, Any, List
from dataclasses import dataclass
from datetime import datetime
@dataclass
class DeviceRegistrationResponse:
device_id: str
protocol: str
certificate_id: Optional[str] = None
ca_certificate_pem: Optional[str] = None
certificate_pem: Optional[str] = None
private_key_pem: Optional[str] = None
expires_at: Optional[datetime] = None
credential_id: Optional[str] = None
api_key: Optional[str] = None
webhook_secret: Optional[str] = None
@dataclass
class DeviceInfo:
id: str
name: str
location: Optional[str]
protocol: str
connection_config: Optional[Dict[str, Any]]
created_at: datetime
class DeviceManagerAPIError(Exception):
def __init__(self, status_code: int, message: str, details: Optional[Dict] = None):
self.status_code = status_code
self.message = message
self.details = details or {}
super().__init__(f"API Error {status_code}: {message}")
class DeviceManagerClient:
def __init__(self, base_url: Optional[str] = None):
self.base_url = base_url or os.getenv("DEVICE_MANAGER_URL", "http://localhost:8000")
self.session = requests.Session()
self.session.headers.update({"Content-Type": "application/json"})
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
url = f"{self.base_url}{endpoint}"
try:
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
return response
except requests.exceptions.HTTPError as e:
try:
error_data = e.response.json()
message = error_data.get("detail", str(e))
except (ValueError, AttributeError):
message = str(e)
raise DeviceManagerAPIError(
status_code=e.response.status_code,
message=message,
details=error_data if "error_data" in locals() else {},
)
except requests.exceptions.RequestException as e:
raise DeviceManagerAPIError(
status_code=0, message=f"Connection error: {str(e)}"
)
def register_device(
self,
name: str,
location: Optional[str] = None,
protocol: str = "mqtt",
connection_config: Optional[Dict[str, Any]] = None
) -> DeviceRegistrationResponse:
payload = {"name": name, "protocol": protocol}
if location:
payload["location"] = location
if connection_config:
payload["connection_config"] = connection_config
response = self._request("POST", "/devices/register", json=payload)
data = response.json()
return DeviceRegistrationResponse(
device_id=data["device_id"],
protocol=data["protocol"],
certificate_id=data.get("certificate_id"),
ca_certificate_pem=data.get("ca_certificate_pem"),
certificate_pem=data.get("certificate_pem"),
private_key_pem=data.get("private_key_pem"),
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
credential_id=data.get("credential_id"),
api_key=data.get("api_key"),
webhook_secret=data.get("webhook_secret"),
)
def get_device(self, device_id: str) -> DeviceInfo:
response = self._request("GET", f"/devices/{device_id}")
data = response.json()
return DeviceInfo(
id=data["id"],
name=data["name"],
location=data.get("location"),
protocol=data["protocol"],
connection_config=data.get("connection_config"),
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
)
def list_devices(self) -> List[DeviceInfo]:
response = self._request("GET", "/devices/")
data = response.json()
return [
DeviceInfo(
id=device["id"],
name=device["name"],
location=device.get("location"),
protocol=device["protocol"],
connection_config=device.get("connection_config"),
created_at=datetime.fromisoformat(
device["created_at"].replace("Z", "+00:00")
),
)
for device in data
]
def revoke_certificate(self, device_id: str) -> Dict[str, Any]:
response = self._request("POST", f"/devices/{device_id}/revoke")
return response.json()
def renew_certificate(self, device_id: str) -> DeviceRegistrationResponse:
response = self._request("POST", f"/devices/{device_id}/renew")
data = response.json()
return DeviceRegistrationResponse(
device_id=data["device_id"],
protocol=data["protocol"],
certificate_id=data.get("certificate_id"),
ca_certificate_pem=data.get("ca_certificate_pem"),
certificate_pem=data.get("certificate_pem"),
private_key_pem=data.get("private_key_pem"),
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
credential_id=data.get("credential_id"),
api_key=data.get("api_key"),
webhook_secret=data.get("webhook_secret"),
)
def get_ca_certificate(self) -> str:
response = self._request("GET", "/ca_certificate")
return response.text
def get_crl(self) -> str:
response = self._request("GET", "/crl")
return response.text
def health_check(self) -> bool:
try:
response = self.session.get(f"{self.base_url}/docs", timeout=2)
return response.status_code == 200
except requests.exceptions.RequestException:
return False
default_client = DeviceManagerClient()
def register_device(
name: str,
location: Optional[str] = None,
protocol: str = "mqtt",
connection_config: Optional[Dict[str, Any]] = None
) -> DeviceRegistrationResponse:
return default_client.register_device(name, location, protocol, connection_config)
def get_device(device_id: str) -> DeviceInfo:
return default_client.get_device(device_id)
def list_devices() -> List[DeviceInfo]:
return default_client.list_devices()
def revoke_certificate(device_id: str) -> Dict[str, Any]:
return default_client.revoke_certificate(device_id)
def renew_certificate(device_id: str) -> Dict[str, Any]:
return default_client.renew_certificate(device_id)

View File

@@ -1,65 +1,40 @@
"""
Django forms for the IoT Dashboard.
Note: Device registration is handled through the device_manager API.
These forms are used for the legacy Django UI only.
"""
from django import forms
from iotDashboard.models import Device, Sensor, SensorType
from iotDashboard.models import Device
class DeviceForm(forms.ModelForm):
"""
Form for creating/editing devices.
Note: This is for the Django UI only. Actual device registration
happens through the device_manager microservice API.
"""
protocol = forms.ChoiceField(
choices=[
("mqtt", "MQTT"),
("http", "HTTP"),
("webhook", "Webhook"),
],
initial="mqtt",
help_text="Communication protocol for this device",
)
class Meta:
model = Device
fields = ["name", "ip", "protocol"] # Exclude sensors from the fields
def __init__(self, *args, **kwargs):
# No need to handle sensors in the form
super(DeviceForm, self).__init__(*args, **kwargs)
def save(self, commit=True):
# Save the device instance
device = super(DeviceForm, self).save(commit=False)
if commit:
device.save()
return device
class SensorWithTypeForm(forms.ModelForm):
# Add fields for SensorType directly in the form
type_name = forms.CharField(max_length=50, label="Sensor Type Name")
unit = forms.CharField(max_length=20, label="Unit", required=False)
protocol = forms.ChoiceField(
choices=[("mqtt", "MQTT"), ("http", "HTTP")], label="Protocol"
)
topic = forms.CharField(max_length=100, label="Topic", required=False)
endpoint = forms.CharField(max_length=100, label="Endpoint", required=False)
class Meta:
model = Sensor
fields = ["enabled"] # Exclude 'device' from the form fields
def __init__(self, *args, **kwargs):
self.device = kwargs.pop("device", None) # Get the device from kwargs
super(SensorWithTypeForm, self).__init__(*args, **kwargs)
def save(self, commit=True):
# Create or get the SensorType
try:
sensor_type = SensorType.objects.get(name=self.cleaned_data["type_name"])
except SensorType.DoesNotExist:
sensor_type = SensorType(
name=self.cleaned_data["type_name"],
unit=self.cleaned_data["unit"],
protocol=self.cleaned_data["protocol"],
topic=self.cleaned_data["topic"],
endpoint=self.cleaned_data["endpoint"],
)
if commit:
sensor_type.save()
# Create Sensor with the SensorType found or created
sensor = super(SensorWithTypeForm, self).save(commit=False)
sensor.type = sensor_type
sensor.device = self.device # Associate the sensor with the device
if commit:
sensor.save()
return sensor
fields = ["name", "location", "protocol"]
widgets = {
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device name"}),
"location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device location (optional)"}),
}
help_texts = {
"name": "Unique identifier for this device",
"location": "Physical location or description",
}

6
iotDashboard/main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from iotdashboard!")
if __name__ == "__main__":
main()

View File

@@ -1,42 +1,175 @@
"""
Django models that mirror the SQLAlchemy schema from db_migrations/models.py.
These models are read-only (managed=False) and query the microservices database.
For write operations, use the device_manager API client instead.
"""
from django.db import models
class SensorType(models.Model):
name = models.CharField(
max_length=50, unique=True
) # Sensor name, e.g., "CO2", "Noise", etc.
unit = models.CharField(
max_length=20
) # Unit of measurement, e.g., "ppm", "dB", "lux"
protocol = models.CharField(
max_length=20, choices=[("mqtt", "MQTT"), ("http", "HTTP")]
) # Protocol for communication
topic = models.CharField(
max_length=100, null=True, blank=True
) # Topic for MQTT communication
endpoint = models.CharField(
max_length=100, null=True, blank=True
) # Endpoint for HTTP communication
def __str__(self):
return f"{self.name} ({self.unit})"
class Device(models.Model):
name = models.CharField(max_length=50) # Device name
ip = models.CharField(max_length=20) # Device IP address
protocol = models.CharField(
max_length=20, choices=[("mqtt", "MQTT"), ("http", "HTTP")]
"""IoT devices registered in the system."""
id = models.CharField(max_length=8, primary_key=True)
name = models.CharField(max_length=255)
location = models.CharField(max_length=255, null=True, blank=True)
protocol = models.CharField(max_length=50, default="mqtt")
connection_config = models.JSONField(null=True, blank=True)
is_active = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
managed = False
db_table = "devices"
def __str__(self):
return f"{self.name} ({self.id}) [{self.protocol}]"
@property
def active_certificate(self):
"""Get the active (non-revoked) certificate for this device."""
return self.certificates.filter(revoked_at__isnull=True).first()
@property
def certificate_status(self):
"""Get human-readable certificate status for MQTT devices."""
if self.protocol != "mqtt":
return "N/A"
cert = self.active_certificate
if not cert:
return "No Certificate"
if cert.is_expired:
return "Expired"
if cert.is_expiring_soon:
return "Expiring Soon"
return "Valid"
class DeviceCertificate(models.Model):
"""X.509 certificates issued to devices for mTLS authentication."""
id = models.CharField(
max_length=255, primary_key=True
) # Certificate serial number (hex)
device = models.ForeignKey(
Device, on_delete=models.CASCADE, related_name="certificates", db_column="device_id"
)
certificate_pem = models.TextField()
private_key_pem = models.TextField(null=True, blank=True) # Optional backup
issued_at = models.DateTimeField()
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
managed = False # Don't create/modify this table
db_table = "device_certificates"
indexes = [
models.Index(fields=["device"]),
models.Index(fields=["device", "revoked_at"]),
]
def __str__(self):
return self.name
status = "Revoked" if self.revoked_at else "Active"
return f"Certificate {self.id[:8]}... for {self.device.name} ({status})"
@property
def is_revoked(self):
"""Check if certificate is revoked."""
return self.revoked_at is not None
@property
def is_expired(self):
"""Check if certificate is expired."""
from django.utils import timezone
return timezone.now() > self.expires_at
@property
def is_expiring_soon(self):
"""Check if certificate expires within 30 days."""
from django.utils import timezone
from datetime import timedelta
return (
not self.is_expired
and self.expires_at < timezone.now() + timedelta(days=30)
)
@property
def is_valid(self):
"""Check if certificate is valid (not revoked and not expired)."""
return not self.is_revoked and not self.is_expired
@property
def days_until_expiry(self):
"""Calculate days until certificate expires."""
from django.utils import timezone
if self.is_expired:
return 0
delta = self.expires_at - timezone.now()
return delta.days
class Sensor(models.Model):
device = models.ForeignKey(Device, related_name="sensors", on_delete=models.CASCADE)
type = models.ForeignKey(SensorType, on_delete=models.CASCADE)
enabled = models.BooleanField(default=True)
class DeviceCredential(models.Model):
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
id = models.CharField(max_length=255, primary_key=True)
device = models.ForeignKey(
Device, on_delete=models.CASCADE, related_name="credentials", db_column="device_id"
)
credential_type = models.CharField(max_length=50)
credential_hash = models.TextField()
created_at = models.DateTimeField()
expires_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
managed = False
db_table = "device_credentials"
indexes = [
models.Index(fields=["device"]),
models.Index(fields=["device", "revoked_at"]),
]
def __str__(self):
return f"{self.type.name} Sensor on {self.device.name}"
status = "Revoked" if self.revoked_at else "Active"
return f"{self.credential_type} for {self.device.name} ({status})"
@property
def is_revoked(self):
return self.revoked_at is not None
@property
def is_expired(self):
from django.utils import timezone
return self.expires_at and timezone.now() > self.expires_at
@property
def is_valid(self):
return not self.is_revoked and not self.is_expired
class Telemetry(models.Model):
"""Time-series telemetry data from devices."""
time = models.DateTimeField()
device = models.ForeignKey(
Device, on_delete=models.CASCADE, related_name="telemetry", db_column="device_id"
)
metric = models.CharField(max_length=255)
value = models.FloatField()
unit = models.CharField(max_length=50, null=True, blank=True)
class Meta:
managed = False
db_table = "telemetry"
unique_together = [["time", "device", "metric"]]
indexes = [
models.Index(fields=["device", "time"]),
]
def __str__(self):
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"

View File

@@ -0,0 +1,5 @@
[project]
name = "dashboard"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []

View File

@@ -13,7 +13,6 @@ https://docs.djangoproject.com/en/4.2/ref/settings/
from dotenv import load_dotenv
from pathlib import Path
import os
# from huey import SqliteHuey
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -27,7 +26,12 @@ load_dotenv()
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv("SECRET_KEY")
CONNECTION_STRING = os.getenv("CONNECTION_STRING")
POSTGRES_HOST = os.getenv("POSTGRES_HOST")
POSTGRES_PORT = os.getenv("POSTGRES_PORT")
POSTGRES_USER = os.getenv("POSTGRES_USER")
POSTGRES_DB = os.getenv("POSTGRES_DB")
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@@ -45,7 +49,6 @@ INSTALLED_APPS = [
"django.contrib.messages",
"django.contrib.staticfiles",
"iotDashboard",
# 'huey.contrib.djhuey',
]
MIDDLEWARE = [
@@ -84,17 +87,14 @@ WSGI_APPLICATION = "iotDashboard.wsgi.application"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
},
"data": {
"ENGINE": "django.db.backends.postgresql",
"NAME": "example",
"USER": "postgres",
"PASSWORD": os.getenv("PASSWORD"),
"HOST": "10.10.0.1",
"PORT": "5555",
"ENGINE": "django.db.backends.postgresql",
"NAME": POSTGRES_DB,
"USER": POSTGRES_USER,
"PASSWORD": POSTGRES_PASSWORD,
"HOST": POSTGRES_HOST,
"PORT": POSTGRES_PORT,
},
}
@@ -139,12 +139,3 @@ STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# HUEY = {
# 'huey_class': 'huey.SqliteHuey', # Or 'huey.RedisHuey' for Redis
# 'filename': 'demo.db', # SQLite file for task storage
# 'results': True,
# 'store_none': False,
# 'immediate': False,
# 'utc': True,
# }

View File

@@ -1,180 +0,0 @@
import json
import datetime
import os
import requests
import psycopg2
import redis
from django.conf import settings
from huey import crontab
from huey.contrib.djhuey import periodic_task
from .models import Device
from dotenv import load_dotenv
load_dotenv()
REDIS_HOST = os.getenv("REDIS_HOST", "localhost") # Default to localhost if not set
try:
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
print(redis_client)
redis_client.ping()
print("Connected!")
except Exception as ex:
print
"Error:", ex
exit("Failed to connect, terminating.")
def devices_to_redis():
"""Fetch devices and their sensors' topics from Django and store them in Redis."""
devices = Device.objects.all()
devices_list = []
for device in devices:
for sensor in device.sensors.all():
sensor_data = {
"device_name": device.name,
"sensor_name": sensor.type.name,
"topic": sensor.type.topic, # Assuming the topic is stored in SensorType
}
devices_list.append(sensor_data)
redis_client.set("mqtt_devices", json.dumps(devices_list))
print("Devices with sensors stored in Redis.")
def fetch_data_http(device, sensor):
"""Fetch data from an HTTP sensor."""
sensor_type_name = sensor.type.name.lower()
try:
response = requests.get(
f"http://{device.ip}/sensor/{sensor_type_name}", timeout=5
)
response.raise_for_status()
sensor_value = response.json().get("value")
if sensor_value is not None:
return {
"time": datetime.datetime.utcnow().isoformat(),
"device": device.name,
"sensor": sensor_type_name,
"sensor_value": sensor_value,
}
else:
print(f"No value returned from {device.name} for {sensor_type_name}")
except requests.RequestException as e:
print(f"HTTP request failed for {device.name}: {e}")
return None
def fetch_data_mqtt_stream(device, sensor):
"""Fetch data from Redis Stream for a specific MQTT device and sensor."""
sensor_name = sensor.type.name.lower()
stream_key = f"mqtt_stream:{device.name}:{sensor_name}"
try:
stream_data = redis_client.xread({stream_key: "0-0"}, block=1000, count=1)
if stream_data:
_, entries = stream_data[0]
for entry_id, entry_data in entries:
sensor_value = entry_data.get(b"value")
timestamp = entry_data.get(b"time")
if sensor_value and timestamp:
return {
"time": timestamp.decode("utf-8"),
"device": device.name,
"sensor_value": float(sensor_value.decode("utf-8")),
}
except Exception as e:
print(f"Error fetching data from stream {stream_key}: {e}")
return None
def is_recent_data(timestamp):
"""Check if data is within a 1-minute freshness window."""
data_time = datetime.datetime.fromisoformat(timestamp)
return data_time > datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
def insert_data(data, sensor_type):
"""Insert parsed data into the PostgreSQL database."""
if "sensor_value" not in data:
print(f"Missing 'sensor_value' in data: {data}. Skipping insertion.")
return
insert_data_dict = {
"time": data["time"],
"device": data["device"],
"metric": sensor_type.lower(),
"value": data["sensor_value"],
}
try:
with psycopg2.connect(settings.CONNECTION_STRING) as conn:
with conn.cursor() as cursor:
insert_query = """
INSERT INTO sensor_readings (time, device_name, metric, value)
VALUES (%s, %s, %s, %s);
"""
cursor.execute(
insert_query,
(
insert_data_dict["time"],
insert_data_dict["device"],
insert_data_dict["metric"],
insert_data_dict["value"],
),
)
conn.commit()
print(
f"Data inserted successfully for {insert_data_dict['device']}: {insert_data_dict}"
)
except Exception as e:
print(f"Failed to insert data: {e}")
@periodic_task(crontab(minute="*/1"))
def fetch_data_from_all_devices():
"""Fetch and insert data for all devices based on their protocol."""
devices = Device.objects.all()
for device in devices:
for sensor in device.sensors.all():
data = None
if device.protocol == "http":
data = fetch_data_http(device, sensor)
elif device.protocol == "mqtt":
data = fetch_data_mqtt_stream(device, sensor)
if data and is_recent_data(data["time"]):
insert_data(data, sensor.type.name)
else:
print(f"No recent or valid data for {device.name}. Skipping.")
@periodic_task(crontab(minute="*/5"))
def last_5_minutes():
"""Fetch the last 5 readings from TimescaleDB and store them in Redis."""
try:
with psycopg2.connect(settings.CONNECTION_STRING) as conn:
with conn.cursor() as cursor:
cursor.execute("""
SELECT time, device_name, metric, value
FROM sensor_readings
ORDER BY time DESC
LIMIT 5;
""")
results = cursor.fetchall()
data = [
{
"time": reading[0].isoformat(),
"device": reading[1],
"metric": reading[2],
"value": reading[3],
}
for reading in results
]
redis_client.set("last5", json.dumps(data))
print("Last 5 readings:", data)
except Exception as e:
print(f"Error fetching or storing the last 5 readings: {e}")
devices_to_redis()

View File

@@ -3,40 +3,39 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Add Sensor and Type</title>
<title>Renew Certificate</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IoT Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
</ul>
</div>
</div>
</nav>
<div class="container mt-5">
<h1 class="text-center mb-4">Add Sensor and Sensor Type</h1>
<form method="post">
<h1 class="text-center mb-4">Renew Certificate</h1>
<div class="alert alert-info" role="alert">
<strong>Info:</strong> This will generate a new certificate for device "{{ device.name }}" and revoke the old one.
</div>
<p class="text-center">Do you want to renew the certificate now?</p>
<form method="post" class="text-center">
{% csrf_token %}
{{ form.as_p }} <!-- Renders the inputs for all fields you added in your form -->
<input type="hidden" name="device_id" value="{{ device.id }}"> <!-- Hidden field for device ID -->
<button type="submit" class="btn btn-success">Save</button>
<button type="submit" class="btn btn-primary">Renew Certificate</button>
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
</form>
{% if form.errors %}
<div class="alert alert-danger">
<ul>
{% for field, errors in form.errors.items %}
<li>{{ field }}: {{ errors|join:', ' }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</body>
</html>

View File

@@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Revoke Certificate</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IoT Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
</ul>
</div>
</div>
</nav>
<div class="container mt-5">
<h1 class="text-center mb-4">Revoke Certificate</h1>
<div class="alert alert-warning" role="alert">
<strong>Warning!</strong> This action will revoke the certificate for device "{{ device.name }}".
The device will no longer be able to connect until a new certificate is issued.
</div>
<p class="text-center">Are you sure you want to revoke the certificate?</p>
<form method="post" class="text-center">
{% csrf_token %}
<button type="submit" class="btn btn-danger">Revoke Certificate</button>
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</body>
</html>

View File

@@ -93,19 +93,19 @@
<!-- Device Selector Dropdown -->
<div class="row mb-4">
<div class="col-md-4 offset-md-4">
<select id="deviceSelector" class="form-select" onchange="updateSensorOptions()">
<select id="deviceSelector" class="form-select" onchange="updateMetricOptions()">
<option value="">-- Select a Device --</option>
<script>
const devices = JSON.parse('{{ devices_json|escapejs }}');
const deviceSelector = document.getElementById('deviceSelector');
const sensorSelector = document.getElementById('sensorSelector');
const metricSelector = document.getElementById('metricSelector');
// Populate device selector
devices.forEach(device => {
const option = document.createElement('option');
option.value = device.name; // Device name used for identification
option.textContent = device.name;
option.value = device.id; // Use device ID
option.textContent = device.name + ' (' + device.protocol.toUpperCase() + ')';
deviceSelector.appendChild(option);
});
</script>
@@ -113,11 +113,11 @@
</div>
</div>
<!-- Sensor Selector Dropdown -->
<!-- Metric Selector Dropdown -->
<div class="row mb-4">
<div class="col-md-4 offset-md-4">
<select id="sensorSelector" class="form-select">
<option value="">-- Select a Sensor --</option>
<select id="metricSelector" class="form-select">
<option value="">-- Select a Metric --</option>
</select>
</div>
</div>
@@ -157,39 +157,39 @@
var ctx = document.getElementById('conditionsChart').getContext('2d');
var conditionsChart;
function updateSensorOptions() {
const selectedDeviceName = document.getElementById('deviceSelector').value;
function updateMetricOptions() {
const selectedDeviceId = document.getElementById('deviceSelector').value;
// Find the selected device in the devices array
const selectedDevice = devices.find(device => device.name === selectedDeviceName);
const selectedDevice = devices.find(device => device.id === selectedDeviceId);
// Clear the existing options in the sensorSelector
const sensorSelector = document.getElementById('sensorSelector');
sensorSelector.innerHTML = '<option value="">-- Select a Sensor --</option>';
// Clear the existing options in the metricSelector
const metricSelector = document.getElementById('metricSelector');
metricSelector.innerHTML = '<option value="">-- Select a Metric --</option>';
if (selectedDevice) {
// Populate the sensor selector with the sensors for the selected device
selectedDevice.sensors.forEach(sensor => {
if (selectedDevice && selectedDevice.metrics) {
// Populate the metric selector with the metrics for the selected device
selectedDevice.metrics.forEach(metric => {
const option = document.createElement('option');
option.value = sensor.id; // Set the sensor ID as the value
option.textContent = sensor.type; // Set the sensor type as the display text
sensorSelector.appendChild(option);
option.value = metric;
option.textContent = metric.charAt(0).toUpperCase() + metric.slice(1);
metricSelector.appendChild(option);
});
}
}
function fetchDeviceData() {
var device = document.getElementById('deviceSelector').value;
var sensor = document.getElementById('sensorSelector').value; // Get selected sensor
var deviceId = document.getElementById('deviceSelector').value;
var metric = document.getElementById('metricSelector').value;
var startDate = document.getElementById('startDate').value;
var endDate = document.getElementById('endDate').value;
if (!device || !sensor) {
alert("Please select both a device and a sensor.");
if (!deviceId || !metric) {
alert("Please select both a device and a metric.");
return;
}
fetch(`/fetch_device_data/?device=${device}&sensor=${sensor}&start_date=${startDate}&end_date=${endDate}`)
fetch(`/fetch_device_data/?device_id=${deviceId}&metric=${metric}&start_date=${startDate}&end_date=${endDate}`)
.then(response => {
if (!response.ok) {
throw new Error("Network response was not ok: " + response.statusText);
@@ -212,9 +212,9 @@ function fetchDeviceData() {
labels: data.times,
datasets: [
{
label: `${sensor} (°C or %)`,
label: metric.charAt(0).toUpperCase() + metric.slice(1),
data: data.values,
borderColor: sensor === 'Temperature' ? 'red' : 'blue',
borderColor: metric === 'temperature' ? 'red' : (metric === 'humidity' ? 'blue' : 'green'),
fill: false,
}
]

View File

@@ -29,7 +29,7 @@
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a>
<a class="nav-link" href="/admin">Login</a>
</li>
{% endif %}
</ul>

View File

@@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Credentials - {{ device_name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style>
.credential-box {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 5px;
padding: 15px;
margin-bottom: 20px;
font-family: monospace;
white-space: pre-wrap;
word-wrap: break-word;
max-height: 300px;
overflow-y: auto;
}
.copy-btn {
margin-top: 10px;
}
</style>
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IoT Dashboard</a>
<div class="collapse navbar-collapse">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
</ul>
</div>
</div>
</nav>
<div class="container mt-5">
<div class="alert alert-success" role="alert">
<h4 class="alert-heading">✅ Device Registered Successfully!</h4>
<p>Device <strong>{{ device_name }}</strong> has been registered.</p>
</div>
<div class="alert alert-warning" role="alert">
<strong>⚠️ Important:</strong> Save these credentials now! They will not be shown again.
You'll need them to configure your device.
</div>
<div class="card mb-4">
<div class="card-header">
<h5>Device Information</h5>
</div>
<div class="card-body">
<table class="table">
<tr>
<th>Device ID:</th>
<td><code>{{ response.device_id }}</code></td>
</tr>
<tr>
<th>Protocol:</th>
<td><span class="badge bg-info">{{ response.protocol|upper }}</span></td>
</tr>
{% if response.certificate_id %}
<tr>
<th>Certificate ID:</th>
<td><code>{{ response.certificate_id }}</code></td>
</tr>
<tr>
<th>Expires At:</th>
<td>{{ response.expires_at|date:"Y-m-d H:i:s" }}</td>
</tr>
{% endif %}
</table>
</div>
</div>
{% if response.protocol == 'mqtt' and response.certificate_pem %}
<div class="card mb-4">
<div class="card-header">
<h5>CA Certificate</h5>
</div>
<div class="card-body">
<div class="credential-box" id="ca-cert">{{ response.ca_certificate_pem }}</div>
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('ca-cert')">Copy CA Certificate</button>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5>Device Certificate</h5>
</div>
<div class="card-body">
<div class="credential-box" id="device-cert">{{ response.certificate_pem }}</div>
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('device-cert')">Copy Device Certificate</button>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5>Private Key</h5>
</div>
<div class="card-body">
<div class="credential-box" id="private-key">{{ response.private_key_pem }}</div>
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('private-key')">Copy Private Key</button>
</div>
</div>
<div class="alert alert-info">
<h6>MQTT Configuration:</h6>
<ul>
<li>Broker: <code>localhost:8883</code></li>
<li>Topic: <code>devices/{{ response.device_id }}/&lt;metric&gt;</code></li>
<li>Example: <code>devices/{{ response.device_id }}/temperature</code></li>
</ul>
</div>
{% endif %}
{% if response.protocol == 'http' and response.api_key %}
<div class="card mb-4">
<div class="card-header">
<h5>API Key</h5>
</div>
<div class="card-body">
<div class="credential-box" id="api-key">{{ response.api_key }}</div>
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('api-key')">Copy API Key</button>
</div>
</div>
{% endif %}
{% if response.protocol == 'webhook' and response.webhook_secret %}
<div class="card mb-4">
<div class="card-header">
<h5>Webhook Secret</h5>
</div>
<div class="card-body">
<div class="credential-box" id="webhook-secret">{{ response.webhook_secret }}</div>
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('webhook-secret')">Copy Webhook Secret</button>
</div>
</div>
{% endif %}
<div class="text-center mt-4">
<a href="{% url 'device_list' %}" class="btn btn-primary">Go to Device List</a>
</div>
</div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
const text = element.textContent;
navigator.clipboard.writeText(text).then(() => {
// Change button text temporarily
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '✓ Copied!';
btn.classList.remove('btn-secondary');
btn.classList.add('btn-success');
setTimeout(() => {
btn.textContent = originalText;
btn.classList.remove('btn-success');
btn.classList.add('btn-secondary');
}, 2000);
}).catch(err => {
alert('Failed to copy: ' + err);
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,101 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Device Details - {{ device.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IoT Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
</ul>
</div>
</div>
</nav>
<div class="container mt-5">
<h1 class="text-center mb-4">Device Details</h1>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<div class="card">
<div class="card-header">
<h3>{{ device.name }}</h3>
</div>
<div class="card-body">
<table class="table">
<tr>
<th>Device ID:</th>
<td><code>{{ device.id }}</code></td>
</tr>
<tr>
<th>Location:</th>
<td>{{ device.location|default:"—" }}</td>
</tr>
<tr>
<th>Protocol:</th>
<td><span class="badge bg-info">{{ device.protocol|upper }}</span></td>
</tr>
<tr>
<th>Created:</th>
<td>{{ device.created_at|date:"Y-m-d H:i:s" }}</td>
</tr>
</table>
{% if device.protocol == 'mqtt' and certificate %}
<h4 class="mt-4">Certificate Information</h4>
<table class="table">
<tr>
<th>Certificate ID:</th>
<td><code>{{ certificate.id }}</code></td>
</tr>
<tr>
<th>Issued At:</th>
<td>{{ certificate.issued_at|date:"Y-m-d H:i:s" }}</td>
</tr>
<tr>
<th>Expires At:</th>
<td>{{ certificate.expires_at|date:"Y-m-d H:i:s" }}</td>
</tr>
<tr>
<th>Status:</th>
<td>
{% if certificate.revoked_at %}
<span class="badge bg-danger">Revoked</span>
{% else %}
<span class="badge bg-success">Active</span>
{% endif %}
</td>
</tr>
</table>
{% endif %}
<div class="mt-4">
<a href="{% url 'device_list' %}" class="btn btn-secondary">Back to List</a>
{% if device.protocol == 'mqtt' %}
<a href="{% url 'renew_certificate' device.id %}" class="btn btn-warning">Renew Certificate</a>
<a href="{% url 'revoke_certificate' device.id %}" class="btn btn-danger">Revoke Certificate</a>
{% endif %}
<a href="{% url 'delete_device' device.id %}" class="btn btn-danger">Delete Device</a>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -23,40 +23,42 @@
</nav>
<div class="container mt-5">
<h1 class="text-center mb-4">{% if form.instance.pk %}Edit{% else %}Add{% endif %} Device</h1>
<h1 class="text-center mb-4">Add New Device</h1>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<form method="post">
{% csrf_token %}
<div class="mb-3">
<!-- Display the form fields -->
{{ form.name.label_tag }} {{ form.name }}<br>
{{ form.ip.label_tag }} {{ form.ip }}<br>
{{ form.protocol.label_tag }} {{ form.protocol }}<br>
<!-- If there are errors, display them -->
{% if form.errors %}
<div class="alert alert-danger">
<ul>
{% for field, errors in form.errors.items %}
<li>{{ field }}: {{ errors|join:", " }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<label for="name" class="form-label">Device Name *</label>
<input type="text" class="form-control" id="name" name="name" value="{{ name }}" required>
</div>
<div class="mb-3">
<label for="location" class="form-label">Location</label>
<input type="text" class="form-control" id="location" name="location" value="{{ location }}">
</div>
<div class="mb-3">
<label for="protocol" class="form-label">Protocol *</label>
<select class="form-select" id="protocol" name="protocol" required>
<option value="mqtt" {% if protocol == 'mqtt' %}selected{% endif %}>MQTT (with mTLS)</option>
<option value="http" {% if protocol == 'http' %}selected{% endif %}>HTTP (coming soon)</option>
<option value="webhook" {% if protocol == 'webhook' %}selected{% endif %}>Webhook (coming soon)</option>
</select>
<div class="form-text">MQTT devices will receive X.509 certificates for secure communication.</div>
</div>
<!-- Submit and Cancel buttons -->
<button type="submit" class="btn btn-success">Save</button>
<button type="submit" class="btn btn-success">Register Device</button>
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
</form>
<!-- Links for adding/editing sensors for this specific device -->
<div class="mt-4">
{% if form.instance.pk %}
<a href="{% url 'add_sensor' form.instance.pk %}" class="btn btn-primary">Add Sensor</a>
<a href="{% url 'sensor_list' form.instance.pk %}" class="btn btn-info">Edit Sensors</a>
{% endif %}
</div>
</div>
</body>
</html>

View File

@@ -23,7 +23,7 @@
{% if user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
<li class="nav-item"><a class="nav-link" href="/admin">Login</a></li>
{% endif %}
</ul>
</div>
@@ -33,41 +33,61 @@
<!-- Device Management -->
<div class="container mt-5">
<h1 class="text-center mb-4">Manage Devices</h1>
{% if messages %}
{% for message in messages %}
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
<a href="{% url 'add_device' %}" class="btn btn-primary mb-3">Add Device</a>
<table class="table table-striped">
<thead>
<tr>
<th>Name</th>
<th>IP Address</th>
<th>Location</th>
<th>Protocol</th>
<th>Sensor Types</th> <!-- Adjusted to Sensor Types -->
<th>Certificate Status</th>
<th>Certificate Expiry</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for device in devices %}
{% for device_data in devices %}
<tr>
<td>{{ device.name }}</td>
<td>{{ device.ip }}</td>
<td>{{ device.protocol }}</td>
<!-- Collect and display sensor types -->
<td>{{ device_data.device.name }}</td>
<td>{{ device_data.device.location|default:"—" }}</td>
<td><span class="badge bg-info">{{ device_data.device.protocol|upper }}</span></td>
<td>
{% for sensor in device.sensors.all %}
{{ sensor.type.name }}{% if not forloop.last %}, {% endif %}
{% empty %}
No sensors
{% endfor %}
{% if device_data.device.protocol == 'mqtt' %}
<span class="badge bg-success">{{ device_data.certificate_status }}</span>
{% else %}
<span class="badge bg-secondary">N/A</span>
{% endif %}
</td>
<!-- You may further expand other sensor-related data -->
<td>
<a href="{% url 'edit_device' device.pk %}" class="btn btn-warning btn-sm">Edit</a>
<a href="{% url 'delete_device' device.pk %}" class="btn btn-danger btn-sm">Delete</a>
{% if device_data.active_certificate %}
{{ device_data.active_certificate.expires_at|date:"Y-m-d H:i" }}
{% else %}
{% endif %}
</td>
<td>
<a href="{% url 'view_device' device_data.device.id %}" class="btn btn-info btn-sm">View</a>
<a href="{% url 'delete_device' device_data.device.id %}" class="btn btn-danger btn-sm">Delete</a>
{% if device_data.device.protocol == 'mqtt' %}
<a href="{% url 'renew_certificate' device_data.device.id %}" class="btn btn-warning btn-sm">Renew Cert</a>
<a href="{% url 'revoke_certificate' device_data.device.id %}" class="btn btn-secondary btn-sm">Revoke</a>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center">No devices found.</td>
<td colspan="6" class="text-center">No devices found.</td>
</tr>
{% endfor %}
</tbody>

View File

@@ -1,48 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Confirm Delete Sensor</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IoT Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Delete Confirmation -->
<div class="container mt-5">
<h1 class="text-center mb-4">Confirm Delete Sensor</h1>
<div class="alert alert-warning">
<strong>Warning!</strong> Are you sure you want to delete the sensor "{{ sensor.type.name }}"? This action cannot be undone.
</div>
<form method="POST">
{% csrf_token %}
<div class="text-center">
<button type="submit" class="btn btn-danger">Delete</button>
<a href="{% url 'sensor_list' sensor.device.pk %}" class="btn btn-secondary">Cancel</a>
</div>
</form>
</div>
</body>
</html>

View File

@@ -1,64 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Manage Sensors for {{ device.name }}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<!-- Navbar -->
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IoT Dashboard</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
</ul>
<ul class="navbar-nav">
{% if user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{% url 'logout' %}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{% url 'login' %}">Login</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>
<!-- Sensor Management -->
<div class="container mt-5">
<h1>Sensors for {{ device.name }}</h1>
<table class="table">
<thead>
<tr>
<th>Sensor Type</th>
<th>Enabled</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for sensor in sensors %}
<tr>
<td>{{ sensor.type.name }}</td>
<td>{{ sensor.enabled }}</td>
<td>
<a href="{% url 'edit_sensor' sensor.pk %}" class="btn btn-warning btn-sm">Edit</a>
<a href="{% url 'delete_sensor' sensor.pk %}" class="btn btn-danger btn-sm">Delete</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No sensors found for this device.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</body>
</html>

View File

@@ -21,18 +21,25 @@ from iotDashboard import views
urlpatterns = [
path("admin/", admin.site.urls),
path("devices_api/", views.devices_api),
# Main dashboard
path("", views.chart, name="index"),
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
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/edit/<int:pk>/", views.edit_device, name="edit_device"),
path("devices/delete/<int:pk>/", views.delete_device, name="delete_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"),
path("sensors/<int:device_id>/", views.sensor_list, name="sensor_list"),
path("sensor/add/", views.add_sensor_with_type, name="add_sensor_with_type"),
# path('devices/<int:device_id>/sensors/', views.sensor_list, name='sensor_list'),
path("device/<int:device_id>/add_sensor/", views.add_sensor, name="add_sensor"),
path("sensor/edit/<int:pk>/", views.edit_sensor, name="edit_sensor"),
path("sensor/delete/<int:pk>/", views.delete_sensor, name="delete_sensor"),
]

View File

@@ -1,235 +1,263 @@
import redis
import json
from django.db import connections
from django.http import JsonResponse, HttpResponse
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.shortcuts import render, redirect
from django.contrib import messages
from .forms import DeviceForm, SensorWithTypeForm
from iotDashboard.models import Device, Sensor
from iotDashboard.models import Device, Telemetry
from iotDashboard.device_manager_client import DeviceManagerClient, DeviceManagerAPIError
redis_client = redis.StrictRedis(host="10.10.0.1", port=6379, db=0)
device_manager = DeviceManagerClient()
def fetch_gpt_data():
return (
redis_client.get("gpt")
.decode("utf-8")
.strip('b"')
.replace('\\"', '"')
.replace("\\n", "")
.replace("\\", "")
.replace("\\u00b0", "°")
)
# def index(request):
# """Redirect to chart page."""
# if request.user.is_authenticated:
# return redirect("/chart/")
# return HttpResponse("NOT AUTHENTICATED!!!")
def chart(request):
# Fetch devices and their related sensors
devices = Device.objects.prefetch_related(
"sensors__type"
).all() # Prefetch related sensors and their types
# Create a list of devices and associated sensors
devices_json = [
{
"name": device.name,
"sensors": [
{"id": sensor.id, "type": sensor.type.name}
for sensor in device.sensors.all()
],
}
for device in devices
]
"""Main dashboard showing telemetry charts."""
try:
gpt_data = fetch_gpt_data()
gpt = json.loads(gpt_data)
except (redis.RedisError, json.JSONDecodeError) as e:
gpt = {"summary": "Error fetching data", "recommendations": {}}
print(f"Error fetching or parsing GPT data: {e}")
context = {
"devices_json": json.dumps(devices_json), # Convert to a JSON string
"gpt": gpt,
}
return render(request, "chart.html", context)
devices = Device.objects.all()
devices_data = []
for device in devices:
# Get unique metrics for this device from telemetry
metrics = (
Telemetry.objects
.filter(device_id=device.id)
.values_list('metric', flat=True)
.distinct()
)
devices_data.append({
"id": device.id,
"name": device.name,
"protocol": device.protocol,
"metrics": list(metrics),
})
context = {
"devices_json": json.dumps(devices_data),
}
return render(request, "chart.html", context)
except Exception as e:
messages.error(request, f"Error loading dashboard: {str(e)}")
return render(request, "chart.html", {"devices_json": "[]"})
def fetch_device_data(request):
device_name = request.GET.get("device", "Livingroom")
sensor_name = request.GET.get("sensor") # This will be the actual sensor name
"""Fetch telemetry data for chart visualization."""
from datetime import datetime, timedelta
from django.utils import timezone
device_id = request.GET.get("device_id")
metric = request.GET.get("metric")
start_date = request.GET.get("start_date")
end_date = request.GET.get("end_date")
# Log the parameters to ensure they are correct
sensor_name = Sensor.objects.get(id=sensor_name).type.name
if not device_id:
return JsonResponse({"error": "device_id is required"}, status=400)
print("Device Name:", device_name)
print("Sensor Name:", sensor_name) # Log sensor name
print("Start Date:", start_date)
print("End Date:", end_date)
# Get the specific device by name
device = get_object_or_404(Device, name=device_name)
# Initialize lists to store times and values
times = []
values = []
# Prepare SQL query and parameters for the device
query = """
SELECT time, metric, value
FROM sensor_readings
WHERE device_name = %s
"""
params = [device.name]
# If a specific sensor is specified, filter by that sensor name (converted to lowercase)
if sensor_name:
query += " AND metric = LOWER(%s)" # Convert to lowercase for comparison
params.append(sensor_name.lower()) # Convert sensor name to lowercase
# Add time filtering to the query
if start_date:
query += " AND time >= %s::timestamptz"
params.append(start_date)
if end_date:
query += " AND time <= %s::timestamptz"
params.append(end_date)
# Log the final query and params
print("Final Query:", query)
print("Params Before Execution:", params)
# Fetch data from the database
with connections["data"].cursor() as cursor:
cursor.execute(query, params)
rows = cursor.fetchall()
# Log the number of rows returned
print("Number of Rows Returned:", len(rows))
# Process the results and extract times and values
for row in rows:
time, metric, value = row
formatted_time = time.strftime("%Y-%m-%d %H:%M:%S")
times.append(formatted_time)
values.append(value)
# If no data is found, return empty arrays
if not times and not values:
print("No data found for the specified device and sensor.")
return JsonResponse({"times": [], "values": []})
# Return the response in the expected format
return JsonResponse({"times": times, "values": values})
def index(request):
if request.user.is_authenticated:
return redirect("/chart/")
return HttpResponse("NOT AUTHENTICATED!!!")
try:
# Build query using Django ORM
queryset = Telemetry.objects.filter(device_id=device_id)
# Filter by metric if provided
if metric:
queryset = queryset.filter(metric=metric)
# Parse and filter by date range (default to last 24 hours)
if start_date:
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
queryset = queryset.filter(time__gte=start_dt)
else:
# Default: last 24 hours
queryset = queryset.filter(time__gte=timezone.now() - timedelta(hours=24))
if end_date:
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
queryset = queryset.filter(time__lte=end_dt)
# Order by time and get values
results = queryset.order_by('time').values_list('time', 'value')
times = []
values = []
for time, value in results:
times.append(time.strftime("%Y-%m-%d %H:%M:%S"))
values.append(float(value))
return JsonResponse({"times": times, "values": values})
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
def device_list(request):
devices = Device.objects.all()
return render(request, "device_list.html", {"devices": devices})
"""List all devices with their certificate status."""
try:
devices = Device.objects.all()
# Enrich devices with certificate information
devices_with_certs = []
for device in devices:
device_data = {
"device": device,
"certificate_status": device.certificate_status if device.protocol == "mqtt" else "N/A",
"active_certificate": device.active_certificate if device.protocol == "mqtt" else None,
}
devices_with_certs.append(device_data)
return render(request, "device_list.html", {"devices": devices_with_certs})
except Exception as e:
messages.error(request, f"Error loading devices: {str(e)}")
return render(request, "device_list.html", {"devices": []})
def add_device(request):
"""Register a new device via device_manager API."""
if request.method == "POST":
form = DeviceForm(request.POST)
if form.is_valid():
form.save()
return redirect("device_list")
else:
form = DeviceForm()
return render(request, "device_form.html", {"form": form})
name = request.POST.get("name")
location = request.POST.get("location")
protocol = request.POST.get("protocol", "mqtt")
if not name:
messages.error(request, "Device name is required")
return render(request, "device_form.html")
try:
response = device_manager.register_device(
name=name,
location=location,
protocol=protocol
)
# Show credentials page (one-time view)
return render(request, "device_credentials.html", {
"device_name": name,
"response": response,
})
except DeviceManagerAPIError as e:
messages.error(request, f"Failed to register device: {e.message}")
return render(request, "device_form.html", {
"name": name,
"location": location,
"protocol": protocol,
})
return render(request, "device_form.html")
def edit_device(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == "POST":
form = DeviceForm(request.POST, instance=device)
if form.is_valid():
form.save()
return redirect("device_list")
else:
form = DeviceForm(instance=device)
return render(request, "device_form.html", {"form": form})
def delete_device(request, pk):
device = get_object_or_404(Device, pk=pk)
if request.method == "POST":
device.delete()
def view_device(request, device_id):
"""View device details and certificate information."""
try:
device = Device.objects.get(id=device_id)
# Get certificate if MQTT device
certificate = None
if device.protocol == "mqtt":
certificate = device.active_certificate
context = {
"device": device,
"certificate": certificate,
}
return render(request, "device_detail.html", context)
except Device.DoesNotExist:
messages.error(request, f"Device {device_id} not found")
return redirect("device_list")
except Exception as e:
messages.error(request, f"Error loading device: {str(e)}")
return redirect("device_list")
return render(request, "device_confirm_delete.html", {"device": device})
def add_sensor_with_type(request):
if request.method == "POST":
form = SensorWithTypeForm(request.POST)
if form.is_valid():
form.save() # This will save both Sensor and SensorType as needed
return redirect("device_list") # Adjust this to your specific URL name
else:
form = SensorWithTypeForm()
def delete_device(request, device_id):
"""Delete a device."""
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")
return redirect("device_list")
return render(request, "device_confirm_delete.html", {"device": device})
except Device.DoesNotExist:
messages.error(request, f"Device {device_id} not found")
return redirect("device_list")
context = {"form": form}
return render(request, "sensor_form.html", context)
def revoke_certificate(request, device_id):
"""Revoke a device's certificate via device_manager API."""
try:
device = Device.objects.get(id=device_id)
if device.protocol != "mqtt":
messages.error(request, "Only MQTT devices have certificates to revoke")
return redirect("device_list")
if request.method == "POST":
try:
device_manager.revoke_certificate(device_id)
messages.success(request, f"Certificate for device '{device.name}' revoked successfully")
except DeviceManagerAPIError as e:
messages.error(request, f"Failed to revoke certificate: {e.message}")
return redirect("device_list")
return render(request, "certificate_revoke_confirm.html", {"device": device})
except Device.DoesNotExist:
messages.error(request, f"Device {device_id} not found")
return redirect("device_list")
def renew_certificate(request, device_id):
"""Renew a device's certificate via device_manager API."""
try:
device = Device.objects.get(id=device_id)
if device.protocol != "mqtt":
messages.error(request, "Only MQTT devices have certificates to renew")
return redirect("device_list")
if request.method == "POST":
try:
response = device_manager.renew_certificate(device_id)
# Show the new credentials (one-time view)
return render(request, "device_credentials.html", {
"device_name": device.name,
"response": response,
"is_renewal": True,
})
except DeviceManagerAPIError as e:
messages.error(request, f"Failed to renew certificate: {e.message}")
return redirect("device_list")
return render(request, "certificate_renew_confirm.html", {"device": device})
except Device.DoesNotExist:
messages.error(request, f"Device {device_id} not found")
return redirect("device_list")
def logout_view(request):
"""Redirect to admin logout."""
return redirect("/admin")
def devices_api(request):
devices = list(Device.objects.all().values("name", "sensors__type__name"))
"""JSON API endpoint for devices."""
devices = list(Device.objects.all().values("id", "name", "protocol", "location"))
return JsonResponse(devices, safe=False)
def sensor_list(request, device_id):
device = get_object_or_404(Device, id=device_id)
sensors = device.sensors.all() # Get sensors for this specific device
return render(request, "sensor_list.html", {"device": device, "sensors": sensors})
def edit_sensor(request, pk):
sensor = get_object_or_404(Sensor, pk=pk)
if request.method == "POST":
form = SensorWithTypeForm(request.POST, instance=sensor)
if form.is_valid():
form.save()
return redirect("sensor_list", device_id=sensor.device.pk)
else:
form = SensorWithTypeForm(instance=sensor)
return render(request, "sensor_form.html", {"form": form})
def delete_sensor(request, pk):
sensor = get_object_or_404(Sensor, pk=pk)
if request.method == "POST":
device_id = sensor.device.pk
sensor.delete()
return redirect("sensor_list", device_id=device_id)
return render(request, "sensor_confirm_delete.html", {"sensor": sensor})
def add_sensor(request, device_id):
device = get_object_or_404(Device, pk=device_id)
if request.method == "POST":
form = SensorWithTypeForm(request.POST)
if form.is_valid():
sensor = form.save(commit=False)
sensor.device = device # Associate the sensor with the device
sensor.save()
return redirect(
"device_list"
) # Redirect to device list or appropriate view
else:
form = SensorWithTypeForm()
return render(request, "sensor_form.html", {"form": form, "device": device})

View File

@@ -23,4 +23,6 @@ dev = [
[tool.uv.workspace]
members = [
"db_migrations",
"iotDashboard",
"services/gpt_service",
]

View File

@@ -0,0 +1,32 @@
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 ./src/ ./src/
COPY main.py ./
RUN uv sync --frozen --no-dev
FROM python:3.13-alpine
WORKDIR /app
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/*.py /app/
RUN adduser -D -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
CMD ["python", "main.py"]

View File

@@ -6,6 +6,7 @@ from sqlalchemy.pool import QueuePool
from src.config import config
from src.schema import TelemetryReading
from src.models import Telemetry
class DatabaseWriter:
@@ -37,9 +38,8 @@ class DatabaseWriter:
session = self.SessionLocal()
try:
# Convert to database objects using the correct field mapping
db_objects = [
TelemetryReading(
Telemetry(
time=reading.time,
device_id=reading.device_id,
metric=reading.metric,
@@ -49,7 +49,6 @@ class DatabaseWriter:
for reading in readings
]
# Bulk insert
session.bulk_save_objects(db_objects)
session.commit()
@@ -57,7 +56,7 @@ class DatabaseWriter:
return True
except Exception as e:
self.logger.error(f"Failed to write batch: {e}")
self.logger.error(f"Failed to write batch: {e}", exc_info=True)
session.rollback()
return False
finally:

View File

@@ -0,0 +1,30 @@
"""
SQLAlchemy models for db_write service.
These models mirror the schema in db_migrations/models.py.
Keep them in sync when schema changes occur.
"""
from sqlalchemy import Column, Float, Text, DateTime
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
class Telemetry(Base):
"""
Time-series telemetry data from devices.
This model is used by the db_write service to insert data.
"""
__tablename__ = "telemetry"
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
device_id = Column(Text, primary_key=True, nullable=False)
metric = Column(Text, primary_key=True, nullable=False)
value = Column(Float, nullable=False)
unit = Column(Text)
def __repr__(self):
return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"

View File

@@ -0,0 +1,9 @@
.env
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
*.crl
*.crt
*.pem

View File

@@ -0,0 +1,11 @@
FROM ghcr.io/astral-sh/uv:python3.13-alpine
COPY ./pyproject.toml ./
COPY ./uv.lock ./
RUN uv sync
COPY ./app/ ./app/
ENTRYPOINT [ "uv", "run", "uvicorn", "app.app:app", "--host", "0.0.0.0", "--port", "8000" ]

View File

@@ -0,0 +1,293 @@
import datetime
import logging
from fastapi import FastAPI, HTTPException
from app.cert_manager import CertificateManager
from app.database import get_db_context
from app.db_models import Device, DeviceCertificate
from app.models import (
DeviceRegistrationRequest,
DeviceRegistrationResponse,
DeviceResponse,
)
logger = logging.getLogger(__name__)
app = FastAPI()
cert_manager = CertificateManager()
@app.get("/")
async def hello():
return {"Hello": "World"}
@app.post("/devices/register")
async def register_device(
request: DeviceRegistrationRequest,
) -> DeviceRegistrationResponse:
"""
Register a new device.
- MQTT devices: issues X.509 certificate for mTLS
- HTTP/webhook devices: generates API key or HMAC secret
"""
try:
if request.protocol == "mqtt":
cert_response = cert_manager.register_device(
name=request.name,
location=request.location,
)
with get_db_context() as db:
device = Device(
id=cert_response.device_id,
name=request.name,
location=request.location,
protocol=request.protocol,
connection_config=request.connection_config,
created_at=datetime.datetime.now(datetime.UTC),
)
db.add(device)
device_cert = DeviceCertificate(
id=cert_response.certificate_id,
device_id=cert_response.device_id,
certificate_pem=cert_response.certificate_pem,
private_key_pem=cert_response.private_key_pem,
issued_at=datetime.datetime.now(datetime.UTC),
expires_at=cert_response.expires_at,
)
db.add(device_cert)
return DeviceRegistrationResponse(
device_id=cert_response.device_id,
protocol=request.protocol,
certificate_id=cert_response.certificate_id,
ca_certificate_pem=cert_response.ca_certificate_pem,
certificate_pem=cert_response.certificate_pem,
private_key_pem=cert_response.private_key_pem,
expires_at=cert_response.expires_at,
)
else:
raise HTTPException(
status_code=400,
detail=f"Protocol '{request.protocol}' not yet implemented. Only 'mqtt' is supported.",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to register device {request.name}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail="Failed to register device. Please try again."
) from e
@app.get("/ca_certificate")
async def get_ca_certificate() -> str:
"""
Retrieve the CA certificate in PEM format.
"""
try:
ca_cert_pem = cert_manager.get_ca_certificate_pem()
return ca_cert_pem
except Exception as e:
logger.error(f"Failed to retrieve CA certificate: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve CA certificate.") from e
@app.get("/devices/{device_id}")
async def get_device(device_id: str) -> DeviceResponse:
"""
Retrieve device information by ID.
"""
try:
with get_db_context() as db:
device = db.query(Device).filter(Device.id == device_id).first()
if not device:
raise HTTPException(status_code=404, detail="Device not found")
return DeviceResponse(
id=device.id,
name=device.name,
location=device.location,
protocol=device.protocol,
connection_config=device.connection_config,
created_at=device.created_at,
)
except Exception as e:
logger.error(f"Failed to retrieve device {device_id}: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve device information.") from e
@app.get("/devices/")
async def list_devices() -> list[DeviceResponse]:
"""
List all registered devices.
"""
try:
with get_db_context() as db:
devices = db.query(Device).all()
return [
DeviceResponse(
id=device.id,
name=device.name,
location=device.location,
protocol=device.protocol,
connection_config=device.connection_config,
created_at=device.created_at,
)
for device in devices
]
except Exception as e:
logger.error(f"Failed to list devices: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to list devices.") from e
@app.post("/devices/{device_id}/revoke")
async def revoke_device_certificate(device_id: str):
"""
Revoke a device's certificate by:
1. Marking it as revoked in the database
2. Adding it to the Certificate Revocation List (CRL)
"""
try:
with get_db_context() as db:
# Get the active (non-revoked) certificate for the device
device_cert = (
db.query(DeviceCertificate)
.filter(
DeviceCertificate.device_id == device_id,
DeviceCertificate.revoked_at.is_(None)
)
.first()
)
if not device_cert:
raise HTTPException(
status_code=404,
detail="No active certificate found for this device"
)
cert_manager.revoke_certificate(device_cert.certificate_pem)
device_cert.revoked_at = datetime.datetime.now(datetime.UTC)
db.commit()
logger.info(f"Successfully revoked certificate for device {device_id}")
return {
"device_id": device_id,
"revoked_at": device_cert.revoked_at.isoformat(),
"message": "Certificate revoked successfully",
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to revoke device {device_id}: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to revoke device certificate.") from e
@app.get("/crl")
async def get_crl():
"""
Get the Certificate Revocation List (CRL) in PEM format.
Mosquitto and other MQTT clients can check this to validate certificates.
"""
try:
crl_pem = cert_manager.get_crl_pem()
if not crl_pem:
return {"message": "No certificates have been revoked yet"}
return {"crl_pem": crl_pem}
except Exception as e:
logger.error(f"Failed to retrieve CRL: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve CRL.") from e
@app.post("/devices/{device_id}/renew")
async def renew_certificate(device_id: str):
"""
Renew a device's certificate by issuing a new one and revoking the old one.
This endpoint:
1. Retrieves the current certificate from DB
2. Generates a new certificate with new keys
3. Revokes the old certificate (adds to CRL)
4. Updates the database with the new certificate
5. Returns the new credentials
"""
try:
with get_db_context() as db:
# Get current certificate
device_cert = (
db.query(DeviceCertificate)
.filter(
DeviceCertificate.device_id == device_id,
)
.first()
)
if not device_cert:
raise HTTPException(
status_code=404, detail="No active certificate found for device"
)
# Check if certificate is about to expire (optional warning)
days_until_expiry = (device_cert.expires_at - datetime.datetime.now(datetime.UTC)).days
if days_until_expiry > 30:
logger.warning(
f"Certificate for device {device_id} renewed early "
f"({days_until_expiry} days remaining)"
)
# Revoke old certificate and add to CRL
cert_manager.revoke_certificate(device_cert.certificate_pem)
device_cert.revoked_at = datetime.datetime.now(datetime.UTC)
# Generate new certificate with new keys
new_cert_pem, new_key_pem = cert_manager.renew_certificate(
current_cert_pem=device_cert.certificate_pem, validity_days=365, key_size=4096
)
# Extract certificate ID (serial number) from the new certificate
from cryptography import x509
new_cert = x509.load_pem_x509_certificate(new_cert_pem)
new_cert_id = format(new_cert.serial_number, "x")
# Create new certificate record in DB
now = datetime.datetime.now(datetime.UTC)
new_device_cert = DeviceCertificate(
id=new_cert_id,
device_id=device_id,
certificate_pem=new_cert_pem.decode("utf-8"),
private_key_pem=new_key_pem.decode("utf-8"),
issued_at=now,
expires_at=now + datetime.timedelta(days=365),
)
db.add(new_device_cert)
db.commit()
logger.info(f"Successfully renewed certificate for device {device_id}")
device = db.query(Device).filter(Device.id == device_id).first()
return DeviceRegistrationResponse(
device_id=device_id,
protocol=device.protocol if device else "mqtt",
certificate_id=new_cert_id,
ca_certificate_pem=cert_manager.get_ca_certificate_pem(),
certificate_pem=new_device_cert.certificate_pem,
private_key_pem=new_device_cert.private_key_pem,
expires_at=new_device_cert.expires_at,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to renew certificate for device {device_id}: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to renew device certificate.") from e

View File

@@ -0,0 +1,250 @@
import datetime
from pathlib import Path
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from nanoid import generate
from app.config import config
from app.models import DeviceCertificateResponse, DeviceCredentials
lowercase_numbers = "abcdefghijklmnopqrstuvwxyz0123456789"
class CertificateManager:
"""Manages device certificate generation and handling"""
def __init__(self):
self.ca_cert: x509.Certificate = self.load_ca_certificate(config.CA_CERT_PATH)
self.ca_key: rsa.RSAPrivateKey = self.load_ca_private_key(config.CA_KEY_PATH)
self.ca_cert_pem: bytes = self.ca_cert.public_bytes(serialization.Encoding.PEM)
def generate_device_id(self) -> str:
"""Generate a unique device ID using nanoid."""
return generate(alphabet=lowercase_numbers, size=config.DEVICE_ID_LENGTH)
def load_ca_certificate(self, ca_cert_path: str) -> x509.Certificate:
"""Load a CA certificate from file."""
with open(ca_cert_path, "rb") as f:
ca_data = f.read()
ca_cert = x509.load_pem_x509_certificate(ca_data)
return ca_cert
def load_ca_private_key(self, ca_key_path: str, password: bytes = None) -> rsa.RSAPrivateKey:
"""Load a CA private key from file."""
from cryptography.hazmat.primitives import serialization
with open(ca_key_path, "rb") as f:
key_data = f.read()
ca_key = serialization.load_pem_private_key(key_data, password=password)
return ca_key
def generate_device_key(self, key_size: int = 4096) -> rsa.RSAPrivateKey:
"""Generate an RSA private key for a device."""
return rsa.generate_private_key(public_exponent=65537, key_size=key_size)
def generate_device_certificate(
self,
device_id: str,
ca_cert: x509.Certificate,
ca_key: rsa.RSAPrivateKey,
device_key: rsa.RSAPrivateKey,
validity_days: int = 365,
key_size: int = 4096,
) -> tuple[bytes, bytes]:
"""Generate an X.509 certificate for a device signed by the CA."""
subject = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, device_id),
]
)
issuer = ca_cert.subject
now = datetime.datetime.now(datetime.UTC)
device_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(device_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_days))
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.sign(private_key=ca_key, algorithm=hashes.SHA256())
)
cert_pem = device_cert.public_bytes(serialization.Encoding.PEM)
key_pem = device_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
return cert_pem, key_pem
def create_device_credentials(
self, device_id: str, validity_days: int = 365, key_size: int = 4096
) -> DeviceCredentials:
"""Create device credentials: private key and signed certificate.
Returns:
DeviceCredentials model with certificate_id, device_id, certificate_pem, private_key_pem, expires_at
"""
device_key = self.generate_device_key(key_size=key_size)
cert_pem, key_pem = self.generate_device_certificate(
device_id=device_id,
ca_cert=self.ca_cert,
ca_key=self.ca_key,
device_key=device_key,
validity_days=validity_days,
key_size=key_size,
)
# Extract serial number from certificate to use as ID
cert = x509.load_pem_x509_certificate(cert_pem)
cert_id = format(cert.serial_number, 'x')
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=validity_days)
return DeviceCredentials(
certificate_id=cert_id,
device_id=device_id,
certificate_pem=cert_pem,
private_key_pem=key_pem,
expires_at=expires_at,
)
def register_device(self, name: str, location: str | None = None) -> DeviceCertificateResponse:
"""Register a new device and generate its credentials.
Returns:
DeviceCertificateResponse
"""
device_id = self.generate_device_id()
credentials = self.create_device_credentials(device_id=device_id)
return DeviceCertificateResponse(
certificate_id=credentials.certificate_id,
device_id=credentials.device_id,
ca_certificate_pem=self.ca_cert_pem.decode("utf-8"),
certificate_pem=credentials.certificate_pem.decode("utf-8"),
private_key_pem=credentials.private_key_pem.decode("utf-8"),
expires_at=credentials.expires_at,
)
def get_ca_certificate_pem(self) -> str:
"""Get the CA certificate in PEM format as a string."""
return self.ca_cert_pem.decode("utf-8")
def revoke_certificate(
self, certificate_pem: str, reason: x509.ReasonFlags = x509.ReasonFlags.unspecified
) -> None:
"""
Revoke a device certificate by adding it to the CRL.
Args:
certificate_pem: PEM-encoded certificate to revoke
reason: Revocation reason (default: unspecified)
"""
# Load the certificate to get serial number
cert = x509.load_pem_x509_certificate(certificate_pem.encode())
# Load existing CRL or create new one
crl_path = Path(config.CRL_PATH)
revoked_certs = []
if crl_path.exists():
with open(crl_path, "rb") as f:
existing_crl = x509.load_pem_x509_crl(f.read())
# Copy existing revoked certificates
revoked_certs = list(existing_crl)
# Add the new revoked certificate
revoked_cert = (
x509.RevokedCertificateBuilder()
.serial_number(cert.serial_number)
.revocation_date(datetime.datetime.now(datetime.UTC))
.add_extension(
x509.CRLReason(reason),
critical=False,
)
.build()
)
revoked_certs.append(revoked_cert)
# Build new CRL with all revoked certificates
crl_builder = x509.CertificateRevocationListBuilder()
crl_builder = crl_builder.issuer_name(self.ca_cert.subject)
crl_builder = crl_builder.last_update(datetime.datetime.now(datetime.UTC))
crl_builder = crl_builder.next_update(
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=30)
)
for revoked in revoked_certs:
crl_builder = crl_builder.add_revoked_certificate(revoked)
# Sign the CRL with CA key
crl = crl_builder.sign(private_key=self.ca_key, algorithm=hashes.SHA256())
# Write CRL to file
crl_path.parent.mkdir(parents=True, exist_ok=True)
with open(crl_path, "wb") as f:
f.write(crl.public_bytes(serialization.Encoding.PEM))
def get_crl_pem(self) -> str | None:
"""Get the current CRL in PEM format."""
crl_path = Path(config.CRL_PATH)
if not crl_path.exists():
return None
with open(crl_path, "rb") as f:
return f.read().decode("utf-8")
def renew_certificate(
self,
current_cert_pem: str,
validity_days: int = 365,
key_size: int = 4096,
) -> tuple[bytes, bytes]:
"""Renew a device certificate before expiration.
Args:
current_cert_pem: PEM-encoded current certificate
validity_days: Validity period for new certificate
key_size: Key size for new device key
Returns:
tuple of (new_cert_pem, new_key_pem)
"""
# Load current certificate
current_cert = x509.load_pem_x509_certificate(current_cert_pem.encode())
device_id = current_cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value
# Generate new device key
new_device_key = self.generate_device_key(key_size=key_size)
# Generate new device certificate
new_cert_pem, new_key_pem = self.generate_device_certificate(
device_id=device_id,
ca_cert=self.ca_cert,
ca_key=self.ca_key,
device_key=new_device_key,
validity_days=validity_days,
key_size=key_size,
)
return new_cert_pem, new_key_pem

View File

@@ -15,6 +15,7 @@ class Config:
CERTS_DIR = SERVICE_DIR / "certs"
CA_CERT_PATH = os.getenv("CA_CERT_PATH", str(CERTS_DIR / "ca.crt"))
CA_KEY_PATH = os.getenv("CA_KEY_PATH", str(CERTS_DIR / "ca.key"))
CRL_PATH = os.getenv("CRL_PATH", str(CERTS_DIR / "ca.crl"))
# Certificate settings
CERT_VALIDITY_DAYS = int(os.getenv("CERT_VALIDITY_DAYS", "365"))

View File

@@ -9,7 +9,7 @@ from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from config import config
from app.config import config
# Create engine with connection pooling
engine = create_engine(

View File

@@ -0,0 +1,76 @@
"""
SQLAlchemy ORM models for device manager service.
These models mirror the database schema defined in db_migrations.
Kept separate to make the service independent.
"""
from sqlalchemy import JSON, Boolean, Column, DateTime, ForeignKey, Index, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
Base = declarative_base()
class Device(Base):
"""IoT devices registered in the system."""
__tablename__ = "devices"
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
location = Column(Text)
protocol = Column(Text, nullable=False, default="mqtt")
connection_config = Column(JSON)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<Device(id={self.id}, name={self.name}, protocol={self.protocol})>"
class DeviceCertificate(Base):
"""X.509 certificates issued to devices for mTLS authentication."""
__tablename__ = "device_certificates"
id = Column(Text, primary_key=True)
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
certificate_pem = Column(Text, nullable=False)
private_key_pem = Column(Text)
issued_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_device_certificates_device_id", "device_id"),
Index("idx_device_certificates_active", "device_id", "revoked_at"),
)
def __repr__(self):
return f"<DeviceCertificate(id={self.id}, device_id={self.device_id}, expires={self.expires_at})>"
class DeviceCredential(Base):
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
__tablename__ = "device_credentials"
id = Column(Text, primary_key=True)
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
credential_type = Column(Text, nullable=False)
credential_hash = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True))
revoked_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_device_credentials_device_id", "device_id"),
Index("idx_device_credentials_active", "device_id", "revoked_at"),
)
def __repr__(self):
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"

View File

@@ -0,0 +1,49 @@
import datetime
from typing import Any
from pydantic import BaseModel
class DeviceRegistrationRequest(BaseModel):
name: str
location: str | None = None
protocol: str = "mqtt"
connection_config: dict[str, Any] | None = None
class DeviceRegistrationResponse(BaseModel):
device_id: str
protocol: str
certificate_id: str | None = None
ca_certificate_pem: str | None = None
certificate_pem: str | None = None
private_key_pem: str | None = None
expires_at: datetime.datetime | None = None
credential_id: str | None = None
api_key: str | None = None
webhook_secret: str | None = None
class DeviceResponse(BaseModel):
id: str
name: str
location: str | None = None
protocol: str
connection_config: dict[str, Any] | None = None
created_at: datetime.datetime
class DeviceCertificateResponse(BaseModel):
certificate_id: str
device_id: str
ca_certificate_pem: str
certificate_pem: str
private_key_pem: str
expires_at: datetime.datetime
class DeviceCredentials(BaseModel):
certificate_id: str
device_id: str
certificate_pem: bytes
private_key_pem: bytes
expires_at: datetime.datetime

View File

@@ -1,142 +0,0 @@
import datetime
from cryptography import x509
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from nanoid import generate
from config import config
from models import DeviceRegistrationResponse
lowercase_numbers = "abcdefghijklmnopqrstuvwxyz0123456789"
class CertificateManager:
"""Manages device certificate generation and handling"""
def __init__(self):
self.ca_cert: x509.Certificate = self.load_ca_certificate(config.CA_CERT_PATH)
self.ca_key: rsa.RSAPrivateKey = self.load_ca_private_key(config.CA_KEY_PATH)
self.ca_cert_pem: bytes = self.ca_cert.public_bytes(serialization.Encoding.PEM)
def generate_device_id(self) -> str:
"""Generate a unique device ID using nanoid."""
return generate(alphabet=lowercase_numbers, size=config.DEVICE_ID_LENGTH)
def load_ca_certificate(self, ca_cert_path: str) -> x509.Certificate:
"""Load a CA certificate from file."""
with open(ca_cert_path, "rb") as f:
ca_data = f.read()
ca_cert = x509.load_pem_x509_certificate(ca_data)
return ca_cert
def load_ca_private_key(self, ca_key_path: str, password: bytes = None) -> rsa.RSAPrivateKey:
"""Load a CA private key from file."""
from cryptography.hazmat.primitives import serialization
with open(ca_key_path, "rb") as f:
key_data = f.read()
ca_key = serialization.load_pem_private_key(key_data, password=password)
return ca_key
def generate_device_key(self, key_size: int = 4096) -> rsa.RSAPrivateKey:
"""Generate an RSA private key for a device."""
return rsa.generate_private_key(public_exponent=65537, key_size=key_size)
def generate_device_certificate(
self,
device_id: str,
ca_cert: x509.Certificate,
ca_key: rsa.RSAPrivateKey,
device_key: rsa.RSAPrivateKey,
validity_days: int = 365,
key_size: int = 4096,
) -> tuple[bytes, bytes]:
"""Generate an X.509 certificate for a device signed by the CA."""
# Build device certificate
subject = x509.Name(
[
x509.NameAttribute(NameOID.COMMON_NAME, device_id),
]
)
issuer = ca_cert.subject
now = datetime.datetime.now(datetime.UTC)
device_cert = (
x509.CertificateBuilder()
.subject_name(subject)
.issuer_name(issuer)
.public_key(device_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=validity_days))
.add_extension(
x509.BasicConstraints(ca=False, path_length=None),
critical=True,
)
.sign(private_key=ca_key, algorithm=hashes.SHA256())
)
# Serialize certificate and key to PEM format
cert_pem = device_cert.public_bytes(serialization.Encoding.PEM)
key_pem = device_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.NoEncryption(),
)
return cert_pem, key_pem
def create_device_credentials(
self, device_id: str, validity_days: int = 365, key_size: int = 4096
) -> dict:
"""Create device credentials: private key and signed certificate.
Returns:
dict with device_id, certificate_pem, private_key_pem, ca_certificate_pem, expires_at
"""
device_key = self.generate_device_key(key_size=key_size)
cert_pem, key_pem = self.generate_device_certificate(
device_id=device_id,
ca_cert=self.ca_cert,
ca_key=self.ca_key,
device_key=device_key,
validity_days=validity_days,
key_size=key_size,
)
expires_at = datetime.datetime.now(datetime.UTC) + datetime.timedelta(
days=validity_days
)
return {
"device_id": device_id,
"certificate_pem": cert_pem,
"private_key_pem": key_pem,
"ca_certificate_pem": self.ca_cert_pem,
"expires_at": expires_at,
}
def register_device(
self, name: str, location: str | None = None
) -> DeviceRegistrationResponse:
"""Register a new device and generate its credentials.
Returns:
DeviceRegistrationResponse
"""
device_id = self.generate_device_id()
credentials = self.create_device_credentials(device_id=device_id)
return DeviceRegistrationResponse(
device_id=credentials["device_id"],
ca_certificate_pem=credentials["ca_certificate_pem"].decode("utf-8"),
certificate_pem=credentials["certificate_pem"].decode("utf-8"),
private_key_pem=credentials["private_key_pem"].decode("utf-8"),
expires_at=credentials["expires_at"],
)
def get_ca_certificate_pem(self) -> str:
"""Get the CA certificate in PEM format as a string."""
return self.ca_cert_pem.decode("utf-8")

View File

@@ -1,44 +0,0 @@
"""
SQLAlchemy ORM models for device manager service.
These models mirror the database schema defined in db_migrations.
Kept separate to make the service independent.
"""
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
Base = declarative_base()
class Device(Base):
"""IoT devices registered in the system."""
__tablename__ = "devices"
id = Column(Text, primary_key=True)
name = Column(Text, nullable=False)
location = Column(Text)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<Device(id={self.id}, name={self.name})>"
class DeviceCertificate(Base):
"""X.509 certificates issued to devices for mTLS authentication."""
__tablename__ = "device_certificates"
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), primary_key=True
)
certificate_pem = Column(Text, nullable=False)
private_key_pem = Column(Text)
issued_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked_at = Column(DateTime(timezone=True))
def __repr__(self):
return f"<DeviceCertificate(device_id={self.device_id}, expires={self.expires_at})>"

View File

@@ -1,77 +0,0 @@
import datetime
import logging
from fastapi import FastAPI, HTTPException
from cert_manager import CertificateManager
from database import get_db_context
from db_models import Device, DeviceCertificate # SQLAlchemy ORM models
from models import DeviceRegistrationRequest, DeviceRegistrationResponse # Pydantic API models
logger = logging.getLogger(__name__)
app = FastAPI()
cert_manager = CertificateManager()
@app.get("/")
async def hello():
return {"Hello": "World"}
@app.post("/devices/register")
async def register_device(
request: DeviceRegistrationRequest,
) -> DeviceRegistrationResponse:
"""
Register a new device and issue an X.509 certificate.
"""
try:
response = cert_manager.register_device(
name=request.name,
location=request.location,
)
with get_db_context() as db:
device = Device(
id=response.device_id,
name=request.name,
location=request.location,
created_at=datetime.datetime.now(datetime.UTC),
)
db.add(device)
device_cert = DeviceCertificate(
device_id=response.device_id,
certificate_pem=response.certificate_pem,
private_key_pem=response.private_key_pem,
issued_at=datetime.datetime.now(datetime.UTC),
expires_at=response.expires_at,
)
db.add(device_cert)
except Exception as e:
logger.error(
f"Failed to register device {request.name}: {str(e)}", exc_info=True
)
raise HTTPException(
status_code=500, detail="Failed to register device. Please try again."
) from e
return response
@app.get("/ca_certificate")
async def get_ca_certificate() -> str:
"""
Retrieve the CA certificate in PEM format.
"""
try:
ca_cert_pem = cert_manager.get_ca_certificate_pem()
return ca_cert_pem
except Exception as e:
logger.error(f"Failed to retrieve CA certificate: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail="Failed to retrieve CA certificate."
) from e

View File

@@ -1,18 +0,0 @@
import datetime
from pydantic import BaseModel
class DeviceRegistrationRequest(BaseModel):
"""Request model for registering a new device."""
name: str
location: str | None = None
class DeviceRegistrationResponse(BaseModel):
"""Response model after registering a new device."""
device_id: str
ca_certificate_pem: str
certificate_pem: str
private_key_pem: str
expires_at: datetime.datetime

View File

@@ -1,7 +1,7 @@
[project]
name = "device-manager"
version = "0.1.0"
description = "Add your description here"
description = "Device Manager"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [

View File

@@ -0,0 +1,6 @@
API_KEY=your_api_key_here
PROVIDER_NAME=openai
MODEL_NAME=gpt-4.1
HOST_URL= http://localhost:8000
LOG_LEVEL=INFO

View File

View File

@@ -0,0 +1,10 @@
import os
from dotenv import load_dotenv
load_dotenv()
API_KEY = os.getenv("API_KEY")
PROVIDER_NAME = os.getenv("PROVIDER_NAME", "openai")
MODEL_NAME = os.getenv("MODEL_NAME", "gpt-4")
HOST_URL = os.getenv("HOST_URL")
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")

View File

@@ -0,0 +1,23 @@
from openai import OpenAI
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
import logging
class GPTService:
def __init__(self):
self.api_key = API_KEY
self.model_name = MODEL_NAME
self.provider_name = PROVIDER_NAME
self.host_url = HOST_URL
logging.basicConfig(level=getattr(logging, LOG_LEVEL.upper(), logging.INFO))
self.logger = logging.getLogger(__name__)
if self.provider_name == "openai":
self.client = OpenAI(api_key=self.api_key)
self.logger.info(f"Initialized OpenAI GPTService with model {self.model_name}")
else:
self.logger.error(f"Unsupported provider: {self.provider_name}")
raise ValueError(f"Unsupported provider: {self.provider_name}")

View File

@@ -0,0 +1,10 @@
from gpt_service import GPTService
def main():
GPTService()
print("Hello from gpt-service!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,10 @@
[project]
name = "gpt-service"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"openai>=2.6.1",
"python-dotenv>=1.2.1",
]

22
uv.lock generated
View File

@@ -4,7 +4,9 @@ requires-python = ">=3.13"
[manifest]
members = [
"dashboard",
"db-migrations",
"gpt-service",
"iotdashboard",
]
@@ -112,6 +114,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "dashboard"
version = "0.1.0"
source = { virtual = "iotDashboard" }
[[package]]
name = "db-migrations"
version = "0.1.0"
@@ -150,6 +157,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
]
[[package]]
name = "gpt-service"
version = "0.1.0"
source = { virtual = "services/gpt_service" }
dependencies = [
{ name = "openai" },
{ name = "python-dotenv" },
]
[package.metadata]
requires-dist = [
{ name = "openai", specifier = ">=2.6.1" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
]
[[package]]
name = "greenlet"
version = "3.2.4"