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})>"
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.
"""

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-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -13,11 +13,11 @@
"@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",
"qrcode.react": "^4.2.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-grid-layout": "^1.5.2",
@@ -2520,6 +2520,7 @@
"version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -2535,15 +2536,6 @@
"@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": {
"version": "8.46.3",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.3.tgz",
@@ -4921,6 +4913,15 @@
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -10,42 +10,19 @@
"preview": "vite preview"
},
"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-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"
"react-dom": "^19.1.1"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"autoprefixer": "^10.4.20",
"daisyui": "^5.4.2",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"tailwindcss": "^4.0.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"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
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}/`),
@@ -78,3 +95,37 @@ export const telemetryApi = {
export const dashboardApi = {
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 [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
const [timeframeHours, setTimeframeHours] = useState(24)
const [widgetWidth, setWidgetWidth] = useState(1) // Default to 1 column (small)
const [widgetHeight, setWidgetHeight] = useState(2) // Default to 2 rows (medium)
const [widgetWidth, setWidgetWidth] = useState(1)
const [widgetHeight, setWidgetHeight] = useState(2)
const [city, setCity] = useState('Skopje')
// Fetch devices
const { data: devicesData } = useQuery({
@@ -63,20 +64,31 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
}, [selectedDevices])
const handleSubmit = () => {
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
alert('Please select at least one device and one metric')
return
// Weather and air-quality widgets don't need device/metric validation
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
alert('Please select at least one device and one metric')
return
}
}
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
// Create title
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 = {
id: `widget-${Date.now()}`,
type: widgetType,
title: title || defaultTitle,
deviceIds: selectedDevices,
metricIds: selectedMetrics,
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
timeframe: {
hours: timeframeHours,
},
@@ -84,6 +96,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
showLegend: true,
showGrid: true,
height: widgetType === 'line-chart' ? 300 : undefined,
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
},
position: {
x: 0,
@@ -197,6 +210,32 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<div className="text-xs opacity-70">GPT analysis</div>
</div>
</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>
@@ -204,7 +243,17 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
<button className="btn btn-ghost" onClick={onClose}>
Cancel
</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
</button>
</div>
@@ -212,7 +261,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
)}
{/* Step 2: Data Source */}
{step === 2 && (
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
<div className="space-y-4">
<div className="form-control">
<label className="label">
@@ -304,35 +353,76 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
{/* Step 3: Configure */}
{step === 3 && (
<div className="space-y-4">
<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>
{/* City input for weather and air-quality widgets */}
{(widgetType === 'weather' || widgetType === 'air-quality') ? (
<>
<div className="form-control">
<label className="label">
<span className="label-text font-semibold">City</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder="Enter city name (e.g., Skopje)"
value={city}
onChange={(e) => setCity(e.target.value)}
/>
<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">
<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="form-control">
<label className="label">
<span className="label-text font-semibold">Widget Title (Optional)</span>
</label>
<input
type="text"
className="input input-bordered"
placeholder={widgetType === 'weather' ? `Weather - ${city}` : `Air Quality - ${city}`}
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
</>
) : (
// Original configuration for sensor-based widgets
<>
<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="form-control">

View File

@@ -1,3 +1,5 @@
import { useState } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import toast from 'react-hot-toast'
import type { DeviceRegistrationResponse } from '../types/api'
@@ -27,9 +29,63 @@ const copyToClipboard = (content: string, label: string) => {
export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) {
const resolvedDeviceId = credentials.device_id || deviceId || 'device'
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 (
<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) && (
<div className="rounded-lg bg-base-200 p-4 text-sm">
<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 GaugeWidget from './GaugeWidget'
import AiInsightWidget from './AiInsightWidget'
import AirQualityWidget from './AirQualityWidget'
import WeatherWidget from './WeatherWidget'
interface WidgetProps {
config: WidgetConfig
@@ -16,4 +18,6 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
'gauge': GaugeWidget,
'ai-insight': AiInsightWidget,
'bar-chart': LineChartWidget, // Placeholder - implement later
'air-quality': AirQualityWidget,
'weather': WeatherWidget,
}

View File

@@ -1,6 +1,6 @@
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 {
id: string
@@ -18,6 +18,7 @@ export interface WidgetConfig {
showLegend?: boolean
showGrid?: boolean
height?: number
city?: string
}
position?: {
x: number

View File

@@ -48,6 +48,7 @@ export interface DeviceRegistrationResponse {
certificate_pem?: string;
private_key_pem?: string;
expires_at?: string;
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
}
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'
SECRET_KEY='django_key'
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
REDIS_HOST="localhost"
MQTT_BROKER="localhost"
OPENAI_API_KEY="example"
WEATHER_API_KEY=your_weather_api_key_here
DEVICE_MANAGER_URL=http://localhost:8000

View File

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

View File

@@ -2,13 +2,13 @@
from django.urls import path, include
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.register(r'devices', DeviceViewSet, basename='device')
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
router.register(r'weather', WeatherViewSet, basename='weather')
urlpatterns = [
path('', include(router.urls)),

View File

@@ -1,5 +1,6 @@
"""DRF ViewSets for IoT Dashboard API."""
import requests
from datetime import timedelta
from django.utils import timezone
from rest_framework import viewsets, status
@@ -13,6 +14,7 @@ from iotDashboard.device_manager_client import (
DeviceManagerAPIError
)
from iotDashboard import gpt_service_client
from iotDashboard import weather_client
from .serializers import (
DeviceSerializer,
DeviceCreateSerializer,
@@ -58,6 +60,7 @@ class DeviceViewSet(viewsets.ModelViewSet):
'certificate_pem': response.certificate_pem,
'private_key_pem': response.private_key_pem,
'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)
except DeviceManagerAPIError as e:
@@ -130,6 +133,44 @@ class DeviceViewSet(viewsets.ModelViewSet):
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'])
def telemetry(self, request, pk=None):
"""Get telemetry data for a specific device."""
@@ -384,3 +425,89 @@ class DashboardViewSet(viewsets.ViewSet):
serializer = DashboardOverviewSerializer(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
private_key_pem: Optional[str] = None
expires_at: Optional[datetime] = None
onboarding_token: Optional[str] = None # One-time token for secure onboarding
credential_id: Optional[str] = None
api_key: Optional[str] = None
webhook_secret: Optional[str] = None
@@ -92,6 +93,7 @@ class DeviceManagerClient:
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,
onboarding_token=data.get("onboarding_token"),
credential_id=data.get("credential_id"),
api_key=data.get("api_key"),
webhook_secret=data.get("webhook_secret"),
@@ -157,6 +159,26 @@ class DeviceManagerClient:
response = self._request("GET", "/crl")
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:
try:
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):
"""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).
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)
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)
value = models.FloatField()
unit = models.CharField(max_length=50, null=True, blank=True)
@@ -167,25 +168,10 @@ class Telemetry(models.Model):
class Meta:
managed = False
db_table = "telemetry"
# Django doesn't support composite PKs, so we can't specify all three
# The actual table has (time, device_id, metric) as composite PK
indexes = [
models.Index(fields=["device_id", "time"]),
]
# Note: The actual database has composite PK (time, device_id, metric)
# Django limitation: can only mark one field as PK
unique_together = [["time", "device_id", "metric"]]
def __str__(self):
return f"{self.device_id} - {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
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"

View File

@@ -36,8 +36,7 @@ POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']
# Application definition
@@ -48,16 +47,12 @@ INSTALLED_APPS = [
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
# Third-party apps
"rest_framework",
"corsheaders",
# Local apps
"iotDashboard",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware", # CORS before CommonMiddleware
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -144,67 +139,13 @@ STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Django REST Framework settings
# Django REST Framework
# https://www.django-rest-framework.org/api-guide/settings/
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': [
'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
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
path("analyze_data/", views.analyze_data, name="analyze_data"),
# Legacy/utility endpoints
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()
# def index(request):
# """Redirect to chart page."""
# if request.user.is_authenticated:
# return redirect("/chart/")
# return HttpResponse("NOT AUTHENTICATED!!!")
def chart(request):
"""Main dashboard showing telemetry charts."""
try:
@@ -256,92 +264,4 @@ def devices_api(request):
def analyze_data(request):
"""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",
"djangorestframework>=3.16.1",
"djangorestframework-simplejwt>=5.5.1",
"httpx>=0.28.1",
"openai>=2.6.1",
"paho-mqtt>=2.1.0",
"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 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.database import get_db_context
from app.db_models import Device, DeviceCertificate
from app.db_models import Device, DeviceCertificate, DeviceOnboardingToken
from app.models import (
DeviceCertificateResponse,
DeviceRegistrationRequest,
DeviceRegistrationResponse,
DeviceResponse,
@@ -61,6 +64,19 @@ async def register_device(
)
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(
device_id=cert_response.device_id,
protocol=request.protocol,
@@ -69,6 +85,7 @@ async def register_device(
certificate_pem=cert_response.certificate_pem,
private_key_pem=cert_response.private_key_pem,
expires_at=cert_response.expires_at,
onboarding_token=onboarding_token,
)
else:
@@ -99,6 +116,63 @@ async def get_ca_certificate() -> str:
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}")
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)
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")
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
from cryptography import x509
new_cert = x509.load_pem_x509_certificate(new_cert_pem)
new_cert_id = format(new_cert.serial_number, "x")

View File

@@ -74,3 +74,26 @@ class DeviceCredential(Base):
def __repr__(self):
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
private_key_pem: str | None = None
expires_at: datetime.datetime | None = None
onboarding_token: str | None = None
credential_id: str | None = None
api_key: str | None = None
webhook_secret: str | None = None

2
uv.lock generated
View File

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