Introduced air quality and weather, onboarding for mobile devices with qr code and otp. Cascade on delete of device with telemtry.

This commit is contained in:
2025-11-14 00:45:10 +01:00
parent ed105fccd3
commit 7f2aec96dd
34 changed files with 1186 additions and 426 deletions

View File

@@ -0,0 +1,45 @@
"""Add device onboarding tokens for secure QR code onboarding
Revision ID: 0f2632e459d3
Revises: 4e405f1129b1
Create Date: 2025-11-13 22:08:31.765427+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0f2632e459d3'
down_revision: Union[str, Sequence[str], None] = '4e405f1129b1'
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_onboarding_tokens',
sa.Column('token', sa.Text(), nullable=False),
sa.Column('device_id', sa.Text(), nullable=False),
sa.Column('certificate_id', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('token')
)
op.create_index('idx_onboarding_tokens_device_id', 'device_onboarding_tokens', ['device_id'], unique=False)
op.create_index('idx_onboarding_tokens_expires', 'device_onboarding_tokens', ['expires_at'], unique=False)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('idx_onboarding_tokens_expires', table_name='device_onboarding_tokens')
op.drop_index('idx_onboarding_tokens_device_id', table_name='device_onboarding_tokens')
op.drop_table('device_onboarding_tokens')
# ### end Alembic commands ###

View File

@@ -0,0 +1,48 @@
"""add_cascade_delete_to_telemetry
Revision ID: 4b84a36e13f5
Revises: 0f2632e459d3
Create Date: 2025-11-13 23:18:36.029045+00:00
"""
from typing import Sequence, Union
from alembic import op
# revision identifiers, used by Alembic.
revision: str = '4b84a36e13f5'
down_revision: Union[str, Sequence[str], None] = '0f2632e459d3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema: Add ON DELETE CASCADE to telemetry foreign key."""
# Drop existing foreign key constraint
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
# Re-create foreign key with ON DELETE CASCADE
op.create_foreign_key(
'telemetry_device_id_fkey',
'telemetry',
'devices',
['device_id'],
['id'],
ondelete='CASCADE'
)
def downgrade() -> None:
"""Downgrade schema: Remove CASCADE from telemetry foreign key."""
# Drop foreign key with CASCADE
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
# Re-create foreign key without CASCADE (original state)
op.create_foreign_key(
'telemetry_device_id_fkey',
'telemetry',
'devices',
['device_id'],
['id']
)

View File

@@ -78,7 +78,30 @@ class DeviceCredential(Base):
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>" return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
class Telemetry(Base): class DeviceOnboardingToken(Base):
"""One-time tokens for secure device onboarding via QR code."""
__tablename__ = "device_onboarding_tokens"
token = Column(Text, primary_key=True)
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
certificate_id = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
used_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_onboarding_tokens_device_id", "device_id"),
Index("idx_onboarding_tokens_expires", "expires_at"),
)
def __repr__(self):
return f"<DeviceOnboardingToken(device_id={self.device_id}, used={self.used_at is not None})>"
class Telemetry(Base):
""" """
Time-series telemetry data from devices. Time-series telemetry data from devices.
""" """

8
frontend/.env.example Normal file
View File

@@ -0,0 +1,8 @@
# API Configuration
VITE_API_URL=/api
# Django proxy URL (used for QR code API endpoint)
VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api
# MQTT Configuration
VITE_MQTT_BROKER=10.10.1.114
VITE_MQTT_PORT=8883

1
frontend/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*

View File

@@ -13,11 +13,11 @@
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toast": "^1.2.15", "@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.62.8", "@tanstack/react-query": "^5.62.8",
"@types/react-grid-layout": "^1.3.5",
"axios": "^1.7.9", "axios": "^1.7.9",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.462.0", "lucide-react": "^0.462.0",
"qrcode.react": "^4.2.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
"react-grid-layout": "^1.5.2", "react-grid-layout": "^1.5.2",
@@ -2520,6 +2520,7 @@
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@@ -2535,15 +2536,6 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/react-grid-layout": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/react-grid-layout/-/react-grid-layout-1.3.5.tgz",
"integrity": "sha512-WH/po1gcEcoR6y857yAnPGug+ZhkF4PaTUxgAbwfeSH/QOgVSakKHBXoPGad/sEznmkiaK3pqHk+etdWisoeBQ==",
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.46.3", "version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
@@ -4921,6 +4913,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -10,42 +10,19 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-toast": "^1.2.15",
"@tanstack/react-query": "^5.62.8",
"@types/react-grid-layout": "^1.3.5",
"axios": "^1.7.9",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.462.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1"
"react-grid-layout": "^1.5.2",
"react-hook-form": "^7.54.2",
"react-hot-toast": "^2.6.0",
"react-router": "^7.1.0",
"react-router-dom": "^7.9.5",
"recharts": "^2.15.0",
"tailwind-merge": "^2.5.5",
"zod": "^3.24.1",
"zustand": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.6.0", "@types/node": "^24.6.0",
"@types/react": "^19.1.16", "@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9", "@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.20",
"daisyui": "^5.4.2",
"eslint": "^9.36.0", "eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22", "eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0", "globals": "^16.4.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.45.0", "typescript-eslint": "^8.45.0",
"vite": "^7.1.7" "vite": "^7.1.7"

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -17,7 +17,24 @@ interface PaginatedResponse<T> {
// Device API // Device API
export const devicesApi = { export const devicesApi = {
getAll: () => apiClient.get<PaginatedResponse<Device>>('/devices/'), getAll: async () => {
const response = await apiClient.get<Device[] | PaginatedResponse<Device>>('/devices/');
// Handle both paginated and non-paginated responses
if (Array.isArray(response.data)) {
// Non-paginated response - wrap it
return {
...response,
data: {
count: response.data.length,
next: null,
previous: null,
results: response.data,
},
};
}
// Already paginated
return response as typeof response & { data: PaginatedResponse<Device> };
},
getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`), getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`),
@@ -78,3 +95,37 @@ export const telemetryApi = {
export const dashboardApi = { export const dashboardApi = {
getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'), getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'),
}; };
// Weather API
export const weatherApi = {
getCurrent: (params: { city?: string; lat?: number; lon?: number }) =>
apiClient.get<{
location: string;
temperature: number;
apparent_temperature: number;
humidity: number;
weather_description: string;
weather_code: number;
precipitation: number;
rain: number;
cloud_cover: number;
wind_speed: number;
wind_direction: number;
time: string;
timezone: string;
}>('/weather/current/', { params }),
getAirQuality: (city: string) =>
apiClient.get<{
city: string;
measurements: Record<string, {
average: number;
min: number;
max: number;
count: number;
}>;
status: string;
timestamp: string;
sensor_count: number;
}>('/weather/air_quality/', { params: { city } }),
};

View File

@@ -17,8 +17,9 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
const [selectedDevices, setSelectedDevices] = useState<string[]>([]) const [selectedDevices, setSelectedDevices] = useState<string[]>([])
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([]) const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
const [timeframeHours, setTimeframeHours] = useState(24) const [timeframeHours, setTimeframeHours] = useState(24)
const [widgetWidth, setWidgetWidth] = useState(1) // Default to 1 column (small) const [widgetWidth, setWidgetWidth] = useState(1)
const [widgetHeight, setWidgetHeight] = useState(2) // Default to 2 rows (medium) const [widgetHeight, setWidgetHeight] = useState(2)
const [city, setCity] = useState('Skopje')
// Fetch devices // Fetch devices
const { data: devicesData } = useQuery({ const { data: devicesData } = useQuery({
@@ -63,20 +64,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
}, [selectedDevices]) }, [selectedDevices])
const handleSubmit = () => { const handleSubmit = () => {
if (selectedDevices.length === 0 || selectedMetrics.length === 0) { // Weather and air-quality widgets don't need device/metric validation
alert('Please select at least one device and one metric') if (widgetType !== 'weather' && widgetType !== 'air-quality') {
return if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
alert('Please select at least one device and one metric')
return
}
} }
const selectedDevice = devices.find(d => d.id === selectedDevices[0]) // Create title
const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics) let defaultTitle = ''
if (widgetType === 'weather') {
defaultTitle = `Weather - ${city}`
} else if (widgetType === 'air-quality') {
defaultTitle = `Air Quality - ${city}`
} else {
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
}
const newWidget: WidgetConfig = { const newWidget: WidgetConfig = {
id: `widget-${Date.now()}`, id: `widget-${Date.now()}`,
type: widgetType, type: widgetType,
title: title || defaultTitle, title: title || defaultTitle,
deviceIds: selectedDevices, deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
metricIds: selectedMetrics, metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
timeframe: { timeframe: {
hours: timeframeHours, hours: timeframeHours,
}, },
@@ -84,6 +96,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
showLegend: true, showLegend: true,
showGrid: true, showGrid: true,
height: widgetType === 'line-chart' ? 300 : undefined, height: widgetType === 'line-chart' ? 300 : undefined,
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
}, },
position: { position: {
x: 0, x: 0,
@@ -197,6 +210,32 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<div className="text-xs opacity-70">GPT analysis</div> <div className="text-xs opacity-70">GPT analysis</div>
</div> </div>
</button> </button>
<button
className={`btn ${widgetType === 'weather' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('weather')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<div className="text-left">
<div className="font-semibold">Weather</div>
<div className="text-xs opacity-70">Open-Meteo</div>
</div>
</button>
<button
className={`btn ${widgetType === 'air-quality' ? 'btn-primary' : 'btn-outline'} justify-start`}
onClick={() => setWidgetType('air-quality')}
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
<div className="text-left">
<div className="font-semibold">Air Quality</div>
<div className="text-xs opacity-70">Pulse.eco</div>
</div>
</button>
</div> </div>
</div> </div>
@@ -204,7 +243,17 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<button className="btn btn-ghost" onClick={onClose}> <button className="btn btn-ghost" onClick={onClose}>
Cancel Cancel
</button> </button>
<button className="btn btn-primary" onClick={() => setStep(2)}> <button
className="btn btn-primary"
onClick={() => {
// Skip data source step for weather and air quality widgets
if (widgetType === 'weather' || widgetType === 'air-quality') {
setStep(3)
} else {
setStep(2)
}
}}
>
Next Next
</button> </button>
</div> </div>
@@ -212,7 +261,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
)} )}
{/* Step 2: Data Source */} {/* Step 2: Data Source */}
{step === 2 && ( {step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
@@ -304,35 +353,76 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
{/* Step 3: Configure */} {/* Step 3: Configure */}
{step === 3 && ( {step === 3 && (
<div className="space-y-4"> <div className="space-y-4">
<div className="form-control"> {/* City input for weather and air-quality widgets */}
<label className="label"> {(widgetType === 'weather' || widgetType === 'air-quality') ? (
<span className="label-text font-semibold">Widget Title (Optional)</span> <>
</label> <div className="form-control">
<input <label className="label">
type="text" <span className="label-text font-semibold">City</span>
className="input input-bordered" </label>
placeholder="Auto-generated if empty" <input
value={title} type="text"
onChange={(e) => setTitle(e.target.value)} className="input input-bordered"
/> placeholder="Enter city name (e.g., Skopje)"
</div> value={city}
onChange={(e) => setCity(e.target.value)}
/>
<label className="label">
<span className="label-text-alt">
{widgetType === 'air-quality'
? 'Available cities: Skopje, Bitola, Veles, Tetovo, etc.'
: 'Enter any city name for weather data'}
</span>
</label>
</div>
<div className="form-control"> <div className="form-control">
<label className="label"> <label className="label">
<span className="label-text font-semibold">Time Range</span> <span className="label-text font-semibold">Widget Title (Optional)</span>
</label> </label>
<select <input
className="select select-bordered" type="text"
value={timeframeHours} className="input input-bordered"
onChange={(e) => setTimeframeHours(Number(e.target.value))} placeholder={widgetType === 'weather' ? `Weather - ${city}` : `Air Quality - ${city}`}
> value={title}
<option value={1}>Last 1 hour</option> onChange={(e) => setTitle(e.target.value)}
<option value={6}>Last 6 hours</option> />
<option value={24}>Last 24 hours</option> </div>
<option value={168}>Last 7 days</option> </>
<option value={720}>Last 30 days</option> ) : (
</select> // Original configuration for sensor-based widgets
</div> <>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Widget Title (Optional)</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="Auto-generated if empty"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">Time Range</span>
</label>
<select
className="select select-bordered"
value={timeframeHours}
onChange={(e) => setTimeframeHours(Number(e.target.value))}
>
<option value={1}>Last 1 hour</option>
<option value={6}>Last 6 hours</option>
<option value={24}>Last 24 hours</option>
<option value={168}>Last 7 days</option>
<option value={720}>Last 30 days</option>
</select>
</div>
</>
)}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="form-control"> <div className="form-control">

View File

@@ -1,3 +1,5 @@
import { useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import type { DeviceRegistrationResponse } from '../types/api' import type { DeviceRegistrationResponse } from '../types/api'
@@ -27,9 +29,63 @@ const copyToClipboard = (content: string, label: string) => {
export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) { export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) {
const resolvedDeviceId = credentials.device_id || deviceId || 'device' const resolvedDeviceId = credentials.device_id || deviceId || 'device'
const expiresAt = credentials.expires_at ? new Date(credentials.expires_at).toLocaleString() : null const expiresAt = credentials.expires_at ? new Date(credentials.expires_at).toLocaleString() : null
const [showQR, setShowQR] = useState(false)
// Read configuration from environment variables
const deviceManagerUrl = import.meta.env.VITE_DEVICE_MANAGER_URL || 'http://localhost:8000'
const mqttBroker = import.meta.env.VITE_MQTT_BROKER || 'localhost'
const mqttPort = import.meta.env.VITE_MQTT_PORT || '8883'
const qrData = credentials.onboarding_token ? JSON.stringify({
type: 'iot_device_onboarding',
device_id: resolvedDeviceId,
token: credentials.onboarding_token,
api_url: deviceManagerUrl,
broker: mqttBroker,
port: parseInt(mqttPort, 10),
}) : null
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{/* Secure QR Code for Mobile Onboarding */}
{qrData && (
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
<div className="flex items-start gap-3">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
<div className="flex-1">
<h3 className="font-semibold mb-1">Secure Mobile Onboarding</h3>
<p className="text-sm opacity-80 mb-2">
Scan this QR code with your mobile app to securely fetch certificates. Token expires in <strong>15 minutes</strong> and can only be used <strong>once</strong>.
</p>
<div className="alert alert-warning alert-sm mb-3">
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-xs">This QR code will not be shown again. Scan it now!</span>
</div>
<button
className="btn btn-sm btn-success"
onClick={() => setShowQR(!showQR)}
>
{showQR ? 'Hide QR Code' : 'Show QR Code'}
</button>
{showQR && (
<div className="mt-4 flex justify-center p-6 bg-white rounded-lg border-2 border-success">
<QRCodeSVG
value={qrData}
size={280}
level="H"
includeMargin={true}
/>
</div>
)}
</div>
</div>
</div>
)}
{(credentials.certificate_id || expiresAt) && ( {(credentials.certificate_id || expiresAt) && (
<div className="rounded-lg bg-base-200 p-4 text-sm"> <div className="rounded-lg bg-base-200 p-4 text-sm">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">

View File

@@ -0,0 +1,138 @@
import { useQuery } from '@tanstack/react-query'
import type { WidgetConfig } from '../../hooks'
import { weatherApi } from '../../api'
interface AirQualityWidgetProps {
config: WidgetConfig
}
export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
// Get city from config or use default (Pulse.eco city)
const city = (config.visualization as Record<string, unknown>)?.city as string || 'skopje'
const { data: airQuality, isLoading, error } = useQuery({
queryKey: ['air-quality', city],
queryFn: async () => {
const response = await weatherApi.getAirQuality(city)
return response.data
},
refetchInterval: 300000, // Refresh every 5 minutes
staleTime: 240000, // Consider fresh for 4 minutes
})
if (isLoading) {
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
</div>
)
}
if (error) {
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
<p className="text-error text-sm text-center">
Failed to load air quality data for {city}
</p>
<p className="text-xs text-base-content/60 mt-2">
Try: skopje, bitola, tetovo
</p>
</div>
</div>
</div>
)
}
if (!airQuality) return null
// Get AQI color based on status
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'good':
return 'success'
case 'moderate':
return 'warning'
case 'unhealthy for sensitive groups':
case 'unhealthy':
return 'error'
case 'very unhealthy':
case 'hazardous':
return 'error'
default:
return 'base-content/40'
}
}
const statusColor = getStatusColor(airQuality.status)
const pm10 = airQuality.measurements.pm10
const pm25 = airQuality.measurements.pm25
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
{/* Air quality icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
className={`h-16 w-16 text-${statusColor} mb-2`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
/>
</svg>
{/* PM Values */}
<div className="grid grid-cols-2 gap-4 w-full mb-3">
{pm10 && (
<div className="text-center">
<div className="text-2xl font-bold">{pm10.average.toFixed(1)}</div>
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
</div>
)}
{pm25 && (
<div className="text-center">
<div className="text-2xl font-bold">{pm25.average.toFixed(1)}</div>
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
</div>
)}
</div>
{/* AQI Status badge */}
<div className={`badge badge-${statusColor} badge-lg`}>
{airQuality.status}
</div>
{/* Additional pollutants */}
<div className="grid grid-cols-2 gap-2 mt-3 w-full text-xs">
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
if (pollutant === 'pm10' || pollutant === 'pm25') return null
return (
<div key={pollutant} className="flex justify-between">
<span className="opacity-60">{pollutant.toUpperCase()}:</span>
<span className="font-semibold">{data.average.toFixed(1)}</span>
</div>
)
})}
</div>
{/* City and sensor count */}
<div className="text-xs text-base-content/40 mt-3">
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} {airQuality.sensor_count} sensors
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,110 @@
import { useQuery } from '@tanstack/react-query'
import type { WidgetConfig } from '../../hooks'
import { weatherApi } from '../../api'
interface WeatherWidgetProps {
config: WidgetConfig
}
export default function WeatherWidget({ config }: WeatherWidgetProps) {
// Get city from config or use default
const city = (config.visualization as Record<string, unknown>)?.city as string || 'Skopje'
const { data: weather, isLoading, error } = useQuery({
queryKey: ['weather', city],
queryFn: async () => {
const response = await weatherApi.getCurrent({ city })
return response.data
},
refetchInterval: 300000, // Refresh every 5 minutes
staleTime: 240000, // Consider fresh for 4 minutes
})
if (isLoading) {
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body flex items-center justify-center">
<span className="loading loading-spinner loading-lg"></span>
</div>
</div>
)
}
if (error) {
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
<p className="text-error">Failed to load weather data</p>
</div>
</div>
</div>
)
}
if (!weather) return null
// Weather code to icon mapping
const getWeatherIcon = (code: number) => {
if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear
if (code === 2) return '⛅' // Partly cloudy
if (code === 3) return '☁️' // Overcast
if (code >= 45 && code <= 48) return '🌫️' // Fog
if (code >= 51 && code <= 55) return '🌦️' // Drizzle
if (code >= 61 && code <= 65) return '🌧️' // Rain
if (code >= 71 && code <= 77) return '🌨️' // Snow
if (code >= 80 && code <= 82) return '🌧️' // Rain showers
if (code >= 85 && code <= 86) return '🌨️' // Snow showers
if (code >= 95) return '⛈️' // Thunderstorm
return '🌡️'
}
return (
<div className="card bg-base-100 shadow-lg h-full">
<div className="card-body">
<h2 className="card-title text-sm">{config.title}</h2>
<div className="flex flex-col items-center justify-center flex-1">
{/* Weather Icon */}
<div className="text-6xl mb-2">{getWeatherIcon(weather.weather_code)}</div>
{/* Temperature */}
<div className="text-4xl font-bold">{weather.temperature.toFixed(1)}°C</div>
<div className="text-sm text-base-content/60">
Feels like {weather.apparent_temperature.toFixed(1)}°C
</div>
{/* Weather Description */}
<div className="badge badge-primary badge-lg mt-2">
{weather.weather_description}
</div>
{/* Additional Info */}
<div className="grid grid-cols-2 gap-4 mt-4 w-full text-sm">
<div className="flex items-center gap-2">
<span className="opacity-60">💧</span>
<span>{weather.humidity}%</span>
</div>
<div className="flex items-center gap-2">
<span className="opacity-60">💨</span>
<span>{weather.wind_speed.toFixed(1)} km/h</span>
</div>
<div className="flex items-center gap-2">
<span className="opacity-60"></span>
<span>{weather.cloud_cover}%</span>
</div>
{weather.precipitation > 0 && (
<div className="flex items-center gap-2">
<span className="opacity-60">🌧</span>
<span>{weather.precipitation} mm</span>
</div>
)}
</div>
{/* Location */}
<div className="text-xs text-base-content/40 mt-3">{weather.location}</div>
</div>
</div>
</div>
)
}

View File

@@ -5,6 +5,8 @@ import LineChartWidget from './LineChartWidget'
import StatWidget from './StatWidget' import StatWidget from './StatWidget'
import GaugeWidget from './GaugeWidget' import GaugeWidget from './GaugeWidget'
import AiInsightWidget from './AiInsightWidget' import AiInsightWidget from './AiInsightWidget'
import AirQualityWidget from './AirQualityWidget'
import WeatherWidget from './WeatherWidget'
interface WidgetProps { interface WidgetProps {
config: WidgetConfig config: WidgetConfig
@@ -16,4 +18,6 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
'gauge': GaugeWidget, 'gauge': GaugeWidget,
'ai-insight': AiInsightWidget, 'ai-insight': AiInsightWidget,
'bar-chart': LineChartWidget, // Placeholder - implement later 'bar-chart': LineChartWidget, // Placeholder - implement later
'air-quality': AirQualityWidget,
'weather': WeatherWidget,
} }

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather'
export interface WidgetConfig { export interface WidgetConfig {
id: string id: string
@@ -18,6 +18,7 @@ export interface WidgetConfig {
showLegend?: boolean showLegend?: boolean
showGrid?: boolean showGrid?: boolean
height?: number height?: number
city?: string
} }
position?: { position?: {
x: number x: number

View File

@@ -48,6 +48,7 @@ export interface DeviceRegistrationResponse {
certificate_pem?: string; certificate_pem?: string;
private_key_pem?: string; private_key_pem?: string;
expires_at?: string; expires_at?: string;
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
} }
export interface DashboardOverview { export interface DashboardOverview {

44
gpt.py
View File

@@ -1,44 +0,0 @@
import json
import os
import redis
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv()
client = OpenAI()
REDIS_HOST = os.getenv("REDIS_HOST")
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
data = redis_client.get("last5").decode("utf-8")
def analysis(environment_data):
completion = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are an assistant that analyzes environmental data for an office working space and provides "
"concise numerical insights.",
},
{
"role": "user",
"content": f"Analyze the following environmental data. The goal is maintaining optimal working "
f"conditions in the office and peak working brain. Focus on any outliers or necessary adjustments. The data is as following: {environment_data}."
f"The output should be only the recommendations in numerical form with postitive and negative "
f"numbers and also provide small summary in a sentence or two of the current conditions and "
f"easily computable in json format. Be consistent with the + and - signs and the summary",
},
],
response_format={"type": "json_object"},
)
output = completion.choices[0].message.content
return output
output = analysis(data)
redis_client.set("gpt", json.dumps(output))
print(output)

View File

@@ -1,6 +1,7 @@
PASSWORD='postgres' PASSWORD='postgres'
SECRET_KEY='django_key' SECRET_KEY='django_key'
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example' CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
REDIS_HOST="localhost"
MQTT_BROKER="localhost" WEATHER_API_KEY=your_weather_api_key_here
OPENAI_API_KEY="example" DEVICE_MANAGER_URL=http://localhost:8000

View File

@@ -52,11 +52,9 @@ class DeviceCreateSerializer(serializers.Serializer):
class TelemetrySerializer(serializers.ModelSerializer): class TelemetrySerializer(serializers.ModelSerializer):
"""Serializer for telemetry data.""" """Serializer for telemetry data."""
device_name = serializers.ReadOnlyField()
class Meta: class Meta:
model = Telemetry model = Telemetry
fields = ['time', 'device_id', 'device_name', 'metric', 'value', 'unit'] fields = ['time', 'device_id', 'metric', 'value', 'unit']
class DeviceMetricsSerializer(serializers.Serializer): class DeviceMetricsSerializer(serializers.Serializer):

View File

@@ -2,13 +2,13 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from .views import DeviceViewSet, TelemetryViewSet, DashboardViewSet from .views import DeviceViewSet, TelemetryViewSet, DashboardViewSet, WeatherViewSet
# Create router and register viewsets
router = DefaultRouter() router = DefaultRouter()
router.register(r'devices', DeviceViewSet, basename='device') router.register(r'devices', DeviceViewSet, basename='device')
router.register(r'telemetry', TelemetryViewSet, basename='telemetry') router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
router.register(r'dashboard', DashboardViewSet, basename='dashboard') router.register(r'dashboard', DashboardViewSet, basename='dashboard')
router.register(r'weather', WeatherViewSet, basename='weather')
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path('', include(router.urls)),

View File

@@ -1,5 +1,6 @@
"""DRF ViewSets for IoT Dashboard API.""" """DRF ViewSets for IoT Dashboard API."""
import requests
from datetime import timedelta from datetime import timedelta
from django.utils import timezone from django.utils import timezone
from rest_framework import viewsets, status from rest_framework import viewsets, status
@@ -13,6 +14,7 @@ from iotDashboard.device_manager_client import (
DeviceManagerAPIError DeviceManagerAPIError
) )
from iotDashboard import gpt_service_client from iotDashboard import gpt_service_client
from iotDashboard import weather_client
from .serializers import ( from .serializers import (
DeviceSerializer, DeviceSerializer,
DeviceCreateSerializer, DeviceCreateSerializer,
@@ -58,6 +60,7 @@ class DeviceViewSet(viewsets.ModelViewSet):
'certificate_pem': response.certificate_pem, 'certificate_pem': response.certificate_pem,
'private_key_pem': response.private_key_pem, 'private_key_pem': response.private_key_pem,
'expires_at': response.expires_at.isoformat() if response.expires_at else None, 'expires_at': response.expires_at.isoformat() if response.expires_at else None,
'onboarding_token': response.onboarding_token, # One-time token for QR code
}, status=status.HTTP_201_CREATED) }, status=status.HTTP_201_CREATED)
except DeviceManagerAPIError as e: except DeviceManagerAPIError as e:
@@ -130,6 +133,44 @@ class DeviceViewSet(viewsets.ModelViewSet):
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
) )
@action(detail=True, methods=['get'])
def credentials(self, request, pk=None):
"""
Fetch device credentials using one-time onboarding token.
Used by mobile apps after scanning QR code.
Query params:
- token: One-time onboarding token from QR code
"""
device_id = pk
token = request.query_params.get('token')
if not token:
return Response(
{'error': 'token parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
response = device_manager.get_device_credentials(device_id, token)
# Return credentials
return Response({
'device_id': response.device_id,
'protocol': response.protocol,
'certificate_id': response.certificate_id,
'ca_certificate_pem': response.ca_certificate_pem,
'certificate_pem': response.certificate_pem,
'private_key_pem': response.private_key_pem,
'expires_at': response.expires_at.isoformat() if response.expires_at else None,
})
except DeviceManagerAPIError as e:
return Response(
{'error': e.message, 'details': e.details},
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['get']) @action(detail=True, methods=['get'])
def telemetry(self, request, pk=None): def telemetry(self, request, pk=None):
"""Get telemetry data for a specific device.""" """Get telemetry data for a specific device."""
@@ -384,3 +425,89 @@ class DashboardViewSet(viewsets.ViewSet):
serializer = DashboardOverviewSerializer(data) serializer = DashboardOverviewSerializer(data)
return Response(serializer.data) return Response(serializer.data)
class WeatherViewSet(viewsets.ViewSet):
"""ViewSet for weather and air quality data."""
@action(detail=False, methods=['get'])
def current(self, request):
"""
Get current weather data by city name or coordinates.
Query params:
- city: City name (e.g., "Skopje")
OR
- lat: Latitude
- lon: Longitude
"""
city = request.query_params.get('city')
lat = request.query_params.get('lat')
lon = request.query_params.get('lon')
try:
if city:
# Fetch by city name
weather_data = weather_client.get_weather_by_city(city)
elif lat and lon:
# Fetch by coordinates
latitude = float(lat)
longitude = float(lon)
raw_weather = weather_client.fetch_current_weather(latitude, longitude)
weather_data = weather_client.parse_weather_data(raw_weather)
else:
return Response(
{'error': 'Either city or (lat, lon) parameters are required'},
status=status.HTTP_400_BAD_REQUEST
)
return Response(weather_data)
except ValueError as e:
return Response(
{'error': str(e)},
status=status.HTTP_404_NOT_FOUND
)
except requests.RequestException as e:
return Response(
{'error': 'Failed to fetch weather data', 'details': str(e)},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
@action(detail=False, methods=['get'])
def air_quality(self, request):
"""
Get current air quality data for a city (Pulse.eco API).
Query params:
- city: City name (e.g., "skopje", "bitola", "tetovo")
"""
city = request.query_params.get('city')
if not city:
return Response(
{'error': 'city parameter is required'},
status=status.HTTP_400_BAD_REQUEST
)
try:
raw_data = weather_client.get_air_quality(city)
parsed_data = weather_client.parse_air_quality_data(raw_data, city)
return Response(parsed_data)
except requests.HTTPError as e:
if e.response.status_code == 404:
return Response(
{'error': f'City "{city}" not found or not supported by Pulse.eco'},
status=status.HTTP_404_NOT_FOUND
)
return Response(
{'error': 'Failed to fetch air quality data', 'details': str(e)},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)
except requests.RequestException as e:
return Response(
{'error': 'Failed to fetch air quality data', 'details': str(e)},
status=status.HTTP_503_SERVICE_UNAVAILABLE
)

View File

@@ -16,6 +16,7 @@ class DeviceRegistrationResponse:
certificate_pem: Optional[str] = None certificate_pem: Optional[str] = None
private_key_pem: Optional[str] = None private_key_pem: Optional[str] = None
expires_at: Optional[datetime] = None expires_at: Optional[datetime] = None
onboarding_token: Optional[str] = None # One-time token for secure onboarding
credential_id: Optional[str] = None credential_id: Optional[str] = None
api_key: Optional[str] = None api_key: Optional[str] = None
webhook_secret: Optional[str] = None webhook_secret: Optional[str] = None
@@ -92,6 +93,7 @@ class DeviceManagerClient:
certificate_pem=data.get("certificate_pem"), certificate_pem=data.get("certificate_pem"),
private_key_pem=data.get("private_key_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, expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
onboarding_token=data.get("onboarding_token"),
credential_id=data.get("credential_id"), credential_id=data.get("credential_id"),
api_key=data.get("api_key"), api_key=data.get("api_key"),
webhook_secret=data.get("webhook_secret"), webhook_secret=data.get("webhook_secret"),
@@ -157,6 +159,26 @@ class DeviceManagerClient:
response = self._request("GET", "/crl") response = self._request("GET", "/crl")
return response.text return response.text
def get_device_credentials(self, device_id: str, token: str) -> DeviceRegistrationResponse:
"""Fetch device credentials using one-time onboarding token."""
response = self._request("GET", f"/devices/{device_id}/credentials", params={"token": token})
data = response.json()
# DeviceCertificateResponse from FastAPI doesn't include protocol
# We'll use "mqtt" as default since credentials endpoint is only for MQTT devices
return DeviceRegistrationResponse(
device_id=data["device_id"],
protocol="mqtt", # Credentials endpoint is only for MQTT devices
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=None,
api_key=None,
webhook_secret=None,
)
def health_check(self) -> bool: def health_check(self) -> bool:
try: try:
response = self.session.get(f"{self.base_url}/docs", timeout=2) response = self.session.get(f"{self.base_url}/docs", timeout=2)

View File

@@ -1,114 +0,0 @@
"""Client for GPT Service API."""
import httpx
from typing import List, Dict, Any, Optional, Literal
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
GPT_SERVICE_URL = "http://localhost:8001"
@dataclass
class AnalysisResponse:
"""Response from GPT service analysis."""
analysis: str
prompt_type: str
data_points_analyzed: int
class GPTServiceError(Exception):
"""Exception raised for GPT service API errors."""
def __init__(self, message: str, status_code: int = None, details: Any = None):
self.message = message
self.status_code = status_code
self.details = details
super().__init__(self.message)
async def analyze_telemetry(
telemetry_data: List[Dict[str, Any]],
device_info: Optional[Dict[str, Any]] = None,
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary",
custom_prompt: Optional[str] = None
) -> AnalysisResponse:
"""
Analyze telemetry data using GPT service.
Args:
telemetry_data: List of dicts with device_id, metric, value, timestamp
device_info: Optional device metadata for context
prompt_type: Type of analysis (anomaly_detection, trend_summary, custom)
custom_prompt: Custom prompt for 'custom' type
Returns:
AnalysisResponse with analysis, prompt_type, and data_points_analyzed
Raises:
GPTServiceError: If the API request fails
"""
payload = {
"telemetry_data": telemetry_data,
"device_info": device_info or {},
"prompt_type": prompt_type,
"custom_prompt": custom_prompt
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
f"{GPT_SERVICE_URL}/analyze",
json=payload
)
if response.status_code == 200:
data = response.json()
return AnalysisResponse(
analysis=data['analysis'],
prompt_type=data['prompt_type'],
data_points_analyzed=data['data_points_analyzed']
)
else:
error_data = response.json() if response.text else {}
raise GPTServiceError(
message=error_data.get('detail', 'GPT service request failed'),
status_code=response.status_code,
details=error_data
)
except httpx.TimeoutException:
raise GPTServiceError(
message="GPT service request timed out",
status_code=504
)
except httpx.ConnectError:
raise GPTServiceError(
message="Could not connect to GPT service. Is it running on port 8001?",
status_code=503
)
except Exception as e:
if isinstance(e, GPTServiceError):
raise
logger.error(f"Unexpected error calling GPT service: {str(e)}")
raise GPTServiceError(
message=f"Unexpected error: {str(e)}",
status_code=500
)
async def health_check() -> bool:
"""
Check if GPT service is healthy.
Returns:
True if service is healthy, False otherwise
"""
try:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"{GPT_SERVICE_URL}/health")
return response.status_code == 200
except Exception as e:
logger.warning(f"GPT service health check failed: {str(e)}")
return False

View File

@@ -152,14 +152,15 @@ class DeviceCredential(models.Model):
class Telemetry(models.Model): class Telemetry(models.Model):
"""Time-series telemetry data from devices. """Time-series telemetry data from devices (TimescaleDB hypertable).
Note: This table has a composite primary key (time, device_id, metric). Note: This table has a composite primary key (time, device_id, metric).
We mark time as primary_key to prevent Django from adding an 'id' field. Since Django doesn't support composite PKs well, we mark time as the PK
but queries should filter by (time, device_id, metric) together.
""" """
time = models.DateTimeField(primary_key=True) time = models.DateTimeField(primary_key=True)
device_id = models.CharField(max_length=8, db_column="device_id") device_id = models.CharField(max_length=255, db_column="device_id")
metric = models.CharField(max_length=255) metric = models.CharField(max_length=255)
value = models.FloatField() value = models.FloatField()
unit = models.CharField(max_length=50, null=True, blank=True) unit = models.CharField(max_length=50, null=True, blank=True)
@@ -167,25 +168,10 @@ class Telemetry(models.Model):
class Meta: class Meta:
managed = False managed = False
db_table = "telemetry" db_table = "telemetry"
# Django doesn't support composite PKs, so we can't specify all three # Note: The actual database has composite PK (time, device_id, metric)
# The actual table has (time, device_id, metric) as composite PK # Django limitation: can only mark one field as PK
indexes = [ unique_together = [["time", "device_id", "metric"]]
models.Index(fields=["device_id", "time"]),
]
def __str__(self): def __str__(self):
return f"{self.device_id} - {self.metric}: {self.value} at {self.time}" return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"
@property
def device(self):
"""Lazy load device if needed."""
if not hasattr(self, '_device_cache'):
self._device_cache = Device.objects.filter(id=self.device_id).first()
return self._device_cache
@property
def device_name(self):
"""Get device name without full object load."""
device = self.device
return device.name if device else self.device_id

View File

@@ -36,8 +36,7 @@ POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [] ALLOWED_HOSTS = ['*']
# Application definition # Application definition
@@ -48,16 +47,12 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
# Third-party apps
"rest_framework", "rest_framework",
"corsheaders",
# Local apps
"iotDashboard", "iotDashboard",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware", # CORS before CommonMiddleware
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
@@ -144,67 +139,13 @@ STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Django REST Framework
# Django REST Framework settings # https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 100,
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
# 'rest_framework_simplejwt.authentication.JWTAuthentication', # Enable for JWT
],
'DEFAULT_PERMISSION_CLASSES': [
# 'rest_framework.permissions.IsAuthenticated', # Enable for production
'rest_framework.permissions.AllowAny', # Development only
],
'DEFAULT_RENDERER_CLASSES': [ 'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.JSONRenderer',
'rest_framework.renderers.BrowsableAPIRenderer', # Nice for development ],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
], ],
} }
# CORS settings for React frontend
CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", # Vite default port
"http://127.0.0.1:5173",
"http://localhost:3000", # Alternative React port
"http://127.0.0.1:3000",
]
CORS_ALLOW_CREDENTIALS = True # Allow cookies for session auth
# Additional CORS settings for proper header handling
CORS_ALLOW_METHODS = [
'DELETE',
'GET',
'OPTIONS',
'PATCH',
'POST',
'PUT',
]
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
# Allow all origins for development (comment out for production)
# CORS_ALLOW_ALL_ORIGINS = True
# CSRF settings for React
CSRF_TRUSTED_ORIGINS = [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:3000",
"http://127.0.0.1:3000",
]
# Device Manager API URL
DEVICE_MANAGER_URL = os.getenv("DEVICE_MANAGER_URL", "http://localhost:8000")

View File

@@ -41,7 +41,6 @@ urlpatterns = [
# Telemetry data API # Telemetry data API
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"), path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
path("analyze_data/", views.analyze_data, name="analyze_data"),
# Legacy/utility endpoints # Legacy/utility endpoints
path("devices_api/", views.devices_api, name="devices_api"), path("devices_api/", views.devices_api, name="devices_api"),

View File

@@ -8,6 +8,14 @@ from iotDashboard.device_manager_client import DeviceManagerClient, DeviceManage
device_manager = DeviceManagerClient() device_manager = DeviceManagerClient()
# def index(request):
# """Redirect to chart page."""
# if request.user.is_authenticated:
# return redirect("/chart/")
# return HttpResponse("NOT AUTHENTICATED!!!")
def chart(request): def chart(request):
"""Main dashboard showing telemetry charts.""" """Main dashboard showing telemetry charts."""
try: try:
@@ -256,92 +264,4 @@ def devices_api(request):
def analyze_data(request): def analyze_data(request):
"""Calling the GPT Service to analyze the data.""" """Calling the GPT Service to analyze the data."""
from asgiref.sync import async_to_sync
from iotDashboard import gpt_service_client
from datetime import timedelta
from django.utils import timezone
if request.method != "POST":
return JsonResponse({"error": "POST method required"}, status=405)
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
# Parse parameters
device_id = data.get('device_id')
metric = data.get('metric')
hours = int(data.get('hours', 24))
limit = int(data.get('limit', 100))
prompt_type = data.get('prompt_type', 'trend_summary')
custom_prompt = data.get('custom_prompt')
# Validate device_id
if not device_id:
return JsonResponse({"error": "device_id is required"}, status=400)
try:
device = Device.objects.get(id=device_id)
except Device.DoesNotExist:
return JsonResponse({"error": f"Device {device_id} not found"}, status=404)
# Query telemetry data
queryset = Telemetry.objects.filter(
device_id=device_id,
time__gte=timezone.now() - timedelta(hours=hours)
)
if metric:
queryset = queryset.filter(metric=metric)
telemetry = queryset.order_by('-time')[:limit]
if not telemetry:
return JsonResponse(
{"error": "No telemetry data found for specified parameters"},
status=404
)
# Format data for GPT service
telemetry_data = [
{
'device_id': str(t.device_id),
'metric': t.metric,
'value': float(t.value),
'timestamp': t.time.isoformat()
}
for t in telemetry
]
# Device context
device_info = {
'name': device.name,
'location': device.location,
'protocol': device.protocol,
}
# Call GPT service
try:
result = async_to_sync(gpt_service_client.analyze_telemetry)(
telemetry_data=telemetry_data,
device_info=device_info,
prompt_type=prompt_type,
custom_prompt=custom_prompt
)
return JsonResponse({
'analysis': result.analysis,
'prompt_type': result.prompt_type,
'data_points_analyzed': result.data_points_analyzed
})
except gpt_service_client.GPTServiceError as e:
return JsonResponse(
{
'error': e.message,
'details': e.details,
'gpt_service_available': False
},
status=e.status_code or 503
)

View File

@@ -0,0 +1,226 @@
"""
Weather and Air Quality API clients.
Weather data: Open-Meteo (https://open-meteo.com)
Air Quality data: Pulse.eco (https://pulse.eco)
"""
import requests
from typing import Optional
def fetch_current_weather(latitude: float, longitude: float) -> dict:
"""
Fetch current weather data using Open-Meteo API (no API key required).
Args:
latitude: Latitude coordinate
longitude: Longitude coordinate
Returns:
dict: Current weather data
"""
base_url = "https://api.open-meteo.com/v1/forecast"
params = {
"latitude": latitude,
"longitude": longitude,
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m",
"timezone": "auto",
}
response = requests.get(base_url, params=params, timeout=10)
response.raise_for_status()
return response.json()
def parse_weather_data(weather_data: dict, location_name: Optional[str] = None) -> dict:
"""
Parse relevant weather information from Open-Meteo API response.
Args:
weather_data: Raw API response from Open-Meteo
location_name: Optional human-readable location name
Returns:
dict: Parsed weather data with standardized fields
"""
current = weather_data.get("current", {})
# Weather code to description mapping (WMO Weather interpretation codes)
weather_codes = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Foggy",
48: "Depositing rime fog",
51: "Light drizzle",
53: "Moderate drizzle",
55: "Dense drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
77: "Snow grains",
80: "Slight rain showers",
81: "Moderate rain showers",
82: "Violent rain showers",
85: "Slight snow showers",
86: "Heavy snow showers",
95: "Thunderstorm",
96: "Thunderstorm with slight hail",
99: "Thunderstorm with heavy hail",
}
weather_code = current.get("weather_code", 0)
parsed_data = {
"location": location_name or f"({weather_data.get('latitude')}, {weather_data.get('longitude')})",
"temperature": current.get("temperature_2m"),
"apparent_temperature": current.get("apparent_temperature"),
"humidity": current.get("relative_humidity_2m"),
"weather_description": weather_codes.get(weather_code, "Unknown"),
"weather_code": weather_code,
"precipitation": current.get("precipitation"),
"rain": current.get("rain"),
"cloud_cover": current.get("cloud_cover"),
"wind_speed": current.get("wind_speed_10m"),
"wind_direction": current.get("wind_direction_10m"),
"time": current.get("time"),
"timezone": weather_data.get("timezone"),
}
return parsed_data
def get_air_quality(city: str) -> dict:
"""
Fetch current air quality data from Pulse.eco API.
Pulse.eco provides air quality data for cities in North Macedonia and other regions.
No API key required for public data.
Args:
city: City name (e.g., 'skopje', 'bitola', 'tetovo')
Returns:
dict: Current air quality measurements
Raises:
requests.HTTPError: If city not found or API error
"""
base_url = f"https://{city.lower()}.pulse.eco/rest/current"
response = requests.get(base_url, timeout=10)
response.raise_for_status()
return response.json()
def parse_air_quality_data(air_quality_data: list, city: str) -> dict:
"""
Parse air quality data from Pulse.eco API response.
Args:
air_quality_data: List of sensor measurements from Pulse.eco
city: City name
Returns:
dict: Aggregated air quality data with averages per pollutant
"""
if not air_quality_data:
return {
"city": city,
"measurements": {},
"status": "No data available"
}
# Aggregate measurements by type
pollutants = {}
for measurement in air_quality_data:
pollutant_type = measurement.get("type")
value = measurement.get("value")
if pollutant_type and value is not None:
# Convert value to float (API might return string)
try:
value_float = float(value)
if pollutant_type not in pollutants:
pollutants[pollutant_type] = []
pollutants[pollutant_type].append(value_float)
except (ValueError, TypeError):
# Skip invalid values
continue
# Calculate averages
averages = {}
for pollutant, values in pollutants.items():
averages[pollutant] = {
"average": sum(values) / len(values),
"min": min(values),
"max": max(values),
"count": len(values),
}
# Determine overall AQI status based on PM10 (most common metric)
pm10_avg = averages.get("pm10", {}).get("average")
if pm10_avg is not None:
if pm10_avg <= 20:
status = "Good"
elif pm10_avg <= 40:
status = "Moderate"
elif pm10_avg <= 50:
status = "Unhealthy for Sensitive Groups"
elif pm10_avg <= 100:
status = "Unhealthy"
elif pm10_avg <= 150:
status = "Very Unhealthy"
else:
status = "Hazardous"
else:
status = "Unknown"
return {
"city": city,
"measurements": averages,
"status": status,
"timestamp": air_quality_data[0].get("stamp") if air_quality_data else None,
"sensor_count": len(air_quality_data),
}
def get_weather_by_city(city: str) -> dict:
"""
Fetch weather data by city name (geocodes city first).
Uses Open-Meteo geocoding API to convert city name to coordinates,
then fetches weather data.
Args:
city: City name (e.g., "Skopje", "Berlin")
Returns:
dict: Parsed weather data
"""
# Geocode city name to coordinates
geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
geocode_params = {
"name": city,
"count": 1,
"language": "en",
"format": "json",
}
geocode_response = requests.get(geocode_url, params=geocode_params, timeout=10)
geocode_response.raise_for_status()
geocode_data = geocode_response.json()
if not geocode_data.get("results"):
raise ValueError(f"City '{city}' not found")
location = geocode_data["results"][0]
latitude = location["latitude"]
longitude = location["longitude"]
location_name = location.get("name", city)
# Fetch weather data
weather_data = fetch_current_weather(latitude, longitude)
return parse_weather_data(weather_data, location_name)

View File

@@ -11,7 +11,6 @@ dependencies = [
"django-rest>=0.8.7", "django-rest>=0.8.7",
"djangorestframework>=3.16.1", "djangorestframework>=3.16.1",
"djangorestframework-simplejwt>=5.5.1", "djangorestframework-simplejwt>=5.5.1",
"httpx>=0.28.1",
"openai>=2.6.1", "openai>=2.6.1",
"paho-mqtt>=2.1.0", "paho-mqtt>=2.1.0",
"psycopg2-binary>=2.9.11", "psycopg2-binary>=2.9.11",

View File

@@ -0,0 +1,24 @@
# Device Manager Environment Configuration
# Database connection
DATABASE_URL=postgresql://iot_user:your_password@localhost:5432/iot_dashboard
# Certificate paths
CA_CERT_PATH=certs/ca.crt
CA_KEY_PATH=certs/ca.key
CRL_PATH=certs/ca.crl
# Certificate settings
CERT_VALIDITY_DAYS=365
CERT_KEY_SIZE=4096
SERVER_IP=10.10.1.114
# Device ID settings
DEVICE_ID_LENGTH=8
# Service settings
DEVICE_MANAGER_HOST=0.0.0.0
DEVICE_MANAGER_PORT=8000
LOG_LEVEL=INFO

View File

@@ -1,12 +1,15 @@
import datetime import datetime
import logging import logging
import secrets
from fastapi import FastAPI, HTTPException from cryptography import x509
from fastapi import FastAPI, HTTPException, Query
from app.cert_manager import CertificateManager from app.cert_manager import CertificateManager
from app.database import get_db_context from app.database import get_db_context
from app.db_models import Device, DeviceCertificate from app.db_models import Device, DeviceCertificate, DeviceOnboardingToken
from app.models import ( from app.models import (
DeviceCertificateResponse,
DeviceRegistrationRequest, DeviceRegistrationRequest,
DeviceRegistrationResponse, DeviceRegistrationResponse,
DeviceResponse, DeviceResponse,
@@ -61,6 +64,19 @@ async def register_device(
) )
db.add(device_cert) db.add(device_cert)
onboarding_token = secrets.token_urlsafe(32)
token_expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=15)
db_token = DeviceOnboardingToken(
token=onboarding_token,
device_id=cert_response.device_id,
certificate_id=cert_response.certificate_id,
created_at=datetime.datetime.now(datetime.UTC),
expires_at=token_expires,
)
db.add(db_token)
db.commit()
return DeviceRegistrationResponse( return DeviceRegistrationResponse(
device_id=cert_response.device_id, device_id=cert_response.device_id,
protocol=request.protocol, protocol=request.protocol,
@@ -69,6 +85,7 @@ async def register_device(
certificate_pem=cert_response.certificate_pem, certificate_pem=cert_response.certificate_pem,
private_key_pem=cert_response.private_key_pem, private_key_pem=cert_response.private_key_pem,
expires_at=cert_response.expires_at, expires_at=cert_response.expires_at,
onboarding_token=onboarding_token,
) )
else: else:
@@ -99,6 +116,63 @@ async def get_ca_certificate() -> str:
raise HTTPException(status_code=500, detail="Failed to retrieve CA certificate.") from e raise HTTPException(status_code=500, detail="Failed to retrieve CA certificate.") from e
@app.get("/devices/{device_id}/credentials")
async def get_device_credentials(device_id: str, token: str = Query(...)) -> DeviceCertificateResponse:
"""
Securely fetch device credentials using one-time token from QR code.
Token is valid for 15 minutes and can only be used once.
"""
try:
with get_db_context() as db:
db_token = db.query(DeviceOnboardingToken).filter(
DeviceOnboardingToken.token == token,
DeviceOnboardingToken.device_id == device_id
).first()
if not db_token:
raise HTTPException(status_code=404, detail="Invalid or expired onboarding token")
if db_token.used_at is not None:
raise HTTPException(status_code=403, detail="Token has already been used")
# Check if token expired
if datetime.datetime.now(datetime.UTC) > db_token.expires_at:
raise HTTPException(status_code=403, detail="Token has expired")
device_cert = db.query(DeviceCertificate).filter(
DeviceCertificate.id == db_token.certificate_id,
DeviceCertificate.device_id == device_id,
DeviceCertificate.revoked_at.is_(None)
).first()
if not device_cert:
raise HTTPException(status_code=404, detail="Certificate not found or revoked")
db_token.used_at = datetime.datetime.now(datetime.UTC)
db.commit()
ca_cert_pem = cert_manager.get_ca_certificate_pem()
logger.info(f"Device {device_id} fetched credentials using onboarding token")
return DeviceCertificateResponse(
certificate_id=device_cert.id,
device_id=device_cert.device_id,
ca_certificate_pem=ca_cert_pem,
certificate_pem=device_cert.certificate_pem,
private_key_pem=device_cert.private_key_pem,
expires_at=device_cert.expires_at,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to fetch credentials for device {device_id}: {str(e)}", exc_info=True)
raise HTTPException(
status_code=500, detail="Failed to fetch device credentials"
) from e
@app.get("/devices/{device_id}") @app.get("/devices/{device_id}")
async def get_device(device_id: str) -> DeviceResponse: async def get_device(device_id: str) -> DeviceResponse:
""" """
@@ -208,6 +282,34 @@ async def get_crl():
logger.error(f"Failed to retrieve CRL: {str(e)}", exc_info=True) logger.error(f"Failed to retrieve CRL: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to retrieve CRL.") from e raise HTTPException(status_code=500, detail="Failed to retrieve CRL.") from e
@app.post("/devices/{device_id}/delete")
async def delete_device(device_id: str):
"""
Delete a device and its associated certificates from the database.
"""
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")
# Delete associated certificates
db.query(DeviceCertificate).filter(DeviceCertificate.device_id == device_id).delete()
# Delete the device
db.delete(device)
db.commit()
logger.info(f"Successfully deleted device {device_id} and its certificates")
return {"message": f"Device {device_id} and its certificates have been deleted."}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete device {device_id}: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="Failed to delete device.") from e
@app.post("/devices/{device_id}/renew") @app.post("/devices/{device_id}/renew")
async def renew_certificate(device_id: str): async def renew_certificate(device_id: str):
@@ -254,8 +356,6 @@ async def renew_certificate(device_id: str):
) )
# Extract certificate ID (serial number) from the new certificate # 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 = x509.load_pem_x509_certificate(new_cert_pem)
new_cert_id = format(new_cert.serial_number, "x") new_cert_id = format(new_cert.serial_number, "x")

View File

@@ -74,3 +74,26 @@ class DeviceCredential(Base):
def __repr__(self): def __repr__(self):
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>" return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
class DeviceOnboardingToken(Base):
"""One-time tokens for secure device onboarding via QR code."""
__tablename__ = "device_onboarding_tokens"
token = Column(Text, primary_key=True)
device_id = Column(
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
)
certificate_id = Column(Text, nullable=False)
created_at = Column(DateTime(timezone=True), nullable=False)
expires_at = Column(DateTime(timezone=True), nullable=False)
used_at = Column(DateTime(timezone=True))
__table_args__ = (
Index("idx_onboarding_tokens_device_id", "device_id"),
Index("idx_onboarding_tokens_expires", "expires_at"),
)
def __repr__(self):
return f"<DeviceOnboardingToken(device_id={self.device_id}, used={self.used_at is not None})>"

View File

@@ -19,6 +19,7 @@ class DeviceRegistrationResponse(BaseModel):
certificate_pem: str | None = None certificate_pem: str | None = None
private_key_pem: str | None = None private_key_pem: str | None = None
expires_at: datetime.datetime | None = None expires_at: datetime.datetime | None = None
onboarding_token: str | None = None
credential_id: str | None = None credential_id: str | None = None
api_key: str | None = None api_key: str | None = None
webhook_secret: str | None = None webhook_secret: str | None = None

2
uv.lock generated
View File

@@ -334,7 +334,6 @@ dependencies = [
{ name = "django-rest" }, { name = "django-rest" },
{ name = "djangorestframework" }, { name = "djangorestframework" },
{ name = "djangorestframework-simplejwt" }, { name = "djangorestframework-simplejwt" },
{ name = "httpx" },
{ name = "openai" }, { name = "openai" },
{ name = "paho-mqtt" }, { name = "paho-mqtt" },
{ name = "psycopg2-binary" }, { name = "psycopg2-binary" },
@@ -356,7 +355,6 @@ requires-dist = [
{ name = "django-rest", specifier = ">=0.8.7" }, { name = "django-rest", specifier = ">=0.8.7" },
{ name = "djangorestframework", specifier = ">=3.16.1" }, { name = "djangorestframework", specifier = ">=3.16.1" },
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" }, { name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "openai", specifier = ">=2.6.1" }, { name = "openai", specifier = ">=2.6.1" },
{ name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "paho-mqtt", specifier = ">=2.1.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.11" }, { name = "psycopg2-binary", specifier = ">=2.9.11" },