mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
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:
@@ -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 ###
|
||||||
@@ -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']
|
||||||
|
)
|
||||||
@@ -78,6 +78,29 @@ 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 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):
|
class Telemetry(Base):
|
||||||
"""
|
"""
|
||||||
Time-series telemetry data from devices.
|
Time-series telemetry data from devices.
|
||||||
|
|||||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal 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
1
frontend/.gitignore
vendored
@@ -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/*
|
||||||
|
|||||||
21
frontend/package-lock.json
generated
21
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 |
@@ -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 } }),
|
||||||
|
};
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
// Weather and air-quality widgets don't need device/metric validation
|
||||||
|
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
|
||||||
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
||||||
alert('Please select at least one device and one metric')
|
alert('Please select at least one device and one metric')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||||
const defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
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,6 +353,45 @@ 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">
|
||||||
|
{/* 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">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">
|
<div className="form-control">
|
||||||
<label className="label">
|
<label className="label">
|
||||||
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||||
@@ -333,6 +421,8 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
<option value={720}>Last 30 days</option>
|
<option value={720}>Last 30 days</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
138
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
138
frontend/src/components/widgets/AirQualityWidget.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
110
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
110
frontend/src/components/widgets/WeatherWidget.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
44
gpt.py
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|||||||
226
iotDashboard/weather_client.py
Normal file
226
iotDashboard/weather_client.py
Normal 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)
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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})>"
|
||||||
|
|||||||
@@ -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
2
uv.lock
generated
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user