mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 01:06:24 +00:00
Added Comfort Index widget, updated stylings on other widgets, GPT Service with problems.
This commit is contained in:
@@ -58,6 +58,23 @@ export const devicesApi = {
|
|||||||
apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>(
|
apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>(
|
||||||
`/devices/${id}/metrics/`
|
`/devices/${id}/metrics/`
|
||||||
),
|
),
|
||||||
|
|
||||||
|
getComfortIndex: (id: string) =>
|
||||||
|
apiClient.get<{
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
overall_score: number;
|
||||||
|
rating: string;
|
||||||
|
components: {
|
||||||
|
temperature: number;
|
||||||
|
humidity: number;
|
||||||
|
air_quality: number;
|
||||||
|
acoustic: number;
|
||||||
|
light: number;
|
||||||
|
};
|
||||||
|
suggestions: string[];
|
||||||
|
raw_readings: Record<string, number>;
|
||||||
|
}>(`/devices/${id}/comfort_index/`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Telemetry API
|
// Telemetry API
|
||||||
|
|||||||
@@ -236,6 +236,19 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
|
|||||||
<div className="text-xs opacity-70">Pulse.eco</div>
|
<div className="text-xs opacity-70">Pulse.eco</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'comfort-index' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('comfort-index')}
|
||||||
|
>
|
||||||
|
<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="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Comfort Index</div>
|
||||||
|
<div className="text-xs opacity-70">Multi-factor</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import type { WidgetConfig } from '../../hooks'
|
import type { WidgetConfig } from '../../hooks'
|
||||||
import { weatherApi } from '../../api'
|
import { weatherApi } from '../../api'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
interface AirQualityWidgetProps {
|
interface AirQualityWidgetProps {
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
@@ -22,7 +23,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 shadow-lg h-full">
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
<div className="card-body flex items-center justify-center">
|
<div className="card-body flex items-center justify-center">
|
||||||
<span className="loading loading-spinner loading-lg"></span>
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,9 +33,9 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 shadow-lg h-full">
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title text-sm">{config.title}</h2>
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
<div className="flex flex-col items-center justify-center flex-1">
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
<p className="text-error text-sm text-center">
|
<p className="text-error text-sm text-center">
|
||||||
Failed to load air quality data for {city}
|
Failed to load air quality data for {city}
|
||||||
@@ -73,14 +74,14 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
|||||||
const pm25 = airQuality.measurements.pm25
|
const pm25 = airQuality.measurements.pm25
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 shadow-lg h-full">
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title text-sm">{config.title}</h2>
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
<div className="flex flex-col items-center justify-center flex-1">
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
{/* Air quality icon */}
|
{/* Air quality icon */}
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className={`h-16 w-16 text-${statusColor} mb-2`}
|
className={`h-12 w-12 text-${statusColor} mb-1`}
|
||||||
fill="none"
|
fill="none"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -94,28 +95,28 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
|||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* PM Values */}
|
{/* PM Values */}
|
||||||
<div className="grid grid-cols-2 gap-4 w-full mb-3">
|
<div className="grid grid-cols-2 gap-2 w-full mb-2">
|
||||||
{pm10 && (
|
{pm10 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold">{pm10.average.toFixed(1)}</div>
|
<div className="text-xl font-bold">{pm10.average.toFixed(1)}</div>
|
||||||
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
|
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pm25 && (
|
{pm25 && (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold">{pm25.average.toFixed(1)}</div>
|
<div className="text-xl font-bold">{pm25.average.toFixed(1)}</div>
|
||||||
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
|
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* AQI Status badge */}
|
{/* AQI Status badge */}
|
||||||
<div className={`badge badge-${statusColor} badge-lg`}>
|
<div className={`badge badge-${statusColor} truncate max-w-full`}>
|
||||||
{airQuality.status}
|
{airQuality.status}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional pollutants */}
|
{/* Additional pollutants */}
|
||||||
<div className="grid grid-cols-2 gap-2 mt-3 w-full text-xs">
|
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||||
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
|
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
|
||||||
if (pollutant === 'pm10' || pollutant === 'pm25') return null
|
if (pollutant === 'pm10' || pollutant === 'pm25') return null
|
||||||
return (
|
return (
|
||||||
@@ -128,8 +129,10 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* City and sensor count */}
|
{/* City and sensor count */}
|
||||||
<div className="text-xs text-base-content/40 mt-3">
|
<div className="text-xs text-base-content/40 mt-2 px-2 w-full overflow-hidden">
|
||||||
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
<div className="truncate text-center">
|
||||||
|
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
153
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
153
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../../api'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
interface ComfortIndexWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps) {
|
||||||
|
const deviceId = config.deviceIds[0]
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['comfort-index', deviceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getComfortIndex(deviceId)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // Refresh every minute
|
||||||
|
enabled: !!deviceId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Failed to load comfort index</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRatingColor = (rating: string) => {
|
||||||
|
switch (rating) {
|
||||||
|
case 'Excellent': return 'text-success'
|
||||||
|
case 'Good': return 'text-info'
|
||||||
|
case 'Fair': return 'text-warning'
|
||||||
|
case 'Poor': return 'text-error'
|
||||||
|
case 'Very Poor': return 'text-error'
|
||||||
|
default: return 'text-base-content'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 90) return 'text-success'
|
||||||
|
if (score >= 75) return 'text-info'
|
||||||
|
if (score >= 60) return 'text-warning'
|
||||||
|
if (score >= 40) return 'text-error'
|
||||||
|
return 'text-error'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body p-3 gap-2">
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="card-title text-sm mb-1">{config.title}</h2>
|
||||||
|
|
||||||
|
{/* Overall Score */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-4xl font-bold ${getScoreColor(data.overall_score)}`}>
|
||||||
|
{data.overall_score}
|
||||||
|
</div>
|
||||||
|
<div className={`text-lg font-semibold ${getRatingColor(data.rating)} mt-0.5`}>
|
||||||
|
{data.rating}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/60">Comfort Index</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Component Scores */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">🌡️</span>
|
||||||
|
<span className="truncate">Temperature</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
|
||||||
|
{data.components.temperature}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">💧</span>
|
||||||
|
<span className="truncate">Humidity</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
|
||||||
|
{data.components.humidity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">🌬️</span>
|
||||||
|
<span className="truncate">Air Quality</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
|
||||||
|
{data.components.air_quality}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">🔊</span>
|
||||||
|
<span className="truncate">Acoustic</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
|
||||||
|
{data.components.acoustic}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">💡</span>
|
||||||
|
<span className="truncate">Lighting</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
||||||
|
{data.components.light}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{data.suggestions.length > 0 && (
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="text-xs font-semibold mb-1">💡 Suggestions</div>
|
||||||
|
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||||
|
{data.suggestions.map((suggestion, i) => (
|
||||||
|
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -223,7 +223,9 @@ export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
|||||||
dataKey={metric}
|
dataKey={metric}
|
||||||
stroke={colors[index % colors.length]}
|
stroke={colors[index % colors.length]}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
dot={false}
|
dot={{ r: 2, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={true}
|
||||||
name={formatMetricName(metric)}
|
name={formatMetricName(metric)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 shadow-lg h-full">
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
<div className="card-body flex items-center justify-center">
|
<div className="card-body flex items-center justify-center">
|
||||||
<span className="loading loading-spinner loading-lg"></span>
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -32,9 +32,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 shadow-lg h-full">
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title text-sm">{config.title}</h2>
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
<div className="flex flex-col items-center justify-center flex-1">
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
<p className="text-error">Failed to load weather data</p>
|
<p className="text-error">Failed to load weather data</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -61,26 +61,26 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 shadow-lg h-full">
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
<div className="card-body">
|
<div className="card-body">
|
||||||
<h2 className="card-title text-sm">{config.title}</h2>
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
<div className="flex flex-col items-center justify-center flex-1">
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
{/* Weather Icon */}
|
{/* Weather Icon */}
|
||||||
<div className="text-6xl mb-2">{getWeatherIcon(weather.weather_code)}</div>
|
<div className="text-5xl mb-1">{getWeatherIcon(weather.weather_code)}</div>
|
||||||
|
|
||||||
{/* Temperature */}
|
{/* Temperature */}
|
||||||
<div className="text-4xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||||
<div className="text-sm text-base-content/60">
|
<div className="text-xs text-base-content/60">
|
||||||
Feels like {weather.apparent_temperature.toFixed(1)}°C
|
Feels like {weather.apparent_temperature.toFixed(1)}°C
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Weather Description */}
|
{/* Weather Description */}
|
||||||
<div className="badge badge-primary badge-lg mt-2">
|
<div className="badge badge-primary mt-1 truncate max-w-full">
|
||||||
{weather.weather_description}
|
{weather.weather_description}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Additional Info */}
|
{/* Additional Info */}
|
||||||
<div className="grid grid-cols-2 gap-4 mt-4 w-full text-sm">
|
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="opacity-60">💧</span>
|
<span className="opacity-60">💧</span>
|
||||||
<span>{weather.humidity}%</span>
|
<span>{weather.humidity}%</span>
|
||||||
@@ -102,7 +102,9 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Location */}
|
{/* Location */}
|
||||||
<div className="text-xs text-base-content/40 mt-3">{weather.location}</div>
|
<div className="text-xs text-base-content/40 mt-3 px-2 w-full overflow-hidden">
|
||||||
|
<div className="truncate text-center">{weather.location}</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import GaugeWidget from './GaugeWidget'
|
|||||||
import AiInsightWidget from './AiInsightWidget'
|
import AiInsightWidget from './AiInsightWidget'
|
||||||
import AirQualityWidget from './AirQualityWidget'
|
import AirQualityWidget from './AirQualityWidget'
|
||||||
import WeatherWidget from './WeatherWidget'
|
import WeatherWidget from './WeatherWidget'
|
||||||
|
import ComfortIndexWidget from './ComfortIndexWidget'
|
||||||
|
|
||||||
interface WidgetProps {
|
interface WidgetProps {
|
||||||
config: WidgetConfig
|
config: WidgetConfig
|
||||||
@@ -17,7 +18,8 @@ export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
|||||||
'stat': StatWidget,
|
'stat': StatWidget,
|
||||||
'gauge': GaugeWidget,
|
'gauge': GaugeWidget,
|
||||||
'ai-insight': AiInsightWidget,
|
'ai-insight': AiInsightWidget,
|
||||||
'bar-chart': LineChartWidget, // Placeholder - implement later
|
'bar-chart': LineChartWidget,
|
||||||
'air-quality': AirQualityWidget,
|
'air-quality': AirQualityWidget,
|
||||||
'weather': WeatherWidget,
|
'weather': WeatherWidget,
|
||||||
|
'comfort-index': ComfortIndexWidget,
|
||||||
}
|
}
|
||||||
|
|||||||
25
frontend/src/components/widgets/widget-styles.css
Normal file
25
frontend/src/components/widgets/widget-styles.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* Widget-specific styling overrides */
|
||||||
|
|
||||||
|
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
|
||||||
|
.widget-card .card-body {
|
||||||
|
padding: 1rem; /* 16px instead of 32px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lighter shadow for widgets */
|
||||||
|
.widget-card {
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure cards fill their container properly */
|
||||||
|
.widget-card {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-card .card-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0; /* Allow flex children to shrink */
|
||||||
|
}
|
||||||
@@ -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' | 'air-quality' | 'weather'
|
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index'
|
||||||
|
|
||||||
export interface WidgetConfig {
|
export interface WidgetConfig {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -161,9 +161,10 @@ export default function Dashboard() {
|
|||||||
preventCollision={false}
|
preventCollision={false}
|
||||||
isResizable={true}
|
isResizable={true}
|
||||||
isDraggable={true}
|
isDraggable={true}
|
||||||
|
margin={[12, 12]}
|
||||||
>
|
>
|
||||||
{config.widgets.map((widget) => (
|
{config.widgets.map((widget) => (
|
||||||
<div key={widget.id}>
|
<div key={widget.id} className="h-full">
|
||||||
<WidgetContainer
|
<WidgetContainer
|
||||||
config={widget}
|
config={widget}
|
||||||
onRemove={() => removeWidget(widget.id)}
|
onRemove={() => removeWidget(widget.id)}
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ from iotDashboard.device_manager_client import (
|
|||||||
)
|
)
|
||||||
from iotDashboard import gpt_service_client
|
from iotDashboard import gpt_service_client
|
||||||
from iotDashboard import weather_client
|
from iotDashboard import weather_client
|
||||||
|
from iotDashboard.comfort_index import (
|
||||||
|
ComfortMetrics,
|
||||||
|
ComfortIndexCalculator,
|
||||||
|
calculate_comfort_index_from_telemetry,
|
||||||
|
)
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
DeviceSerializer,
|
DeviceSerializer,
|
||||||
DeviceCreateSerializer,
|
DeviceCreateSerializer,
|
||||||
@@ -212,6 +217,66 @@ class DeviceViewSet(viewsets.ModelViewSet):
|
|||||||
'device_name': device.name,
|
'device_name': device.name,
|
||||||
'metrics': list(metrics)
|
'metrics': list(metrics)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def comfort_index(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Calculate comfort index from latest telemetry data.
|
||||||
|
|
||||||
|
Returns overall comfort score (0-100) and component breakdowns.
|
||||||
|
"""
|
||||||
|
device = self.get_object()
|
||||||
|
|
||||||
|
# Get latest reading for each metric
|
||||||
|
latest_readings = {}
|
||||||
|
metrics_to_check = ['temperature', 'humidity', 'co2', 'CO2', 'noise', 'sound',
|
||||||
|
'pm2.5', 'PM2.5', 'pm10', 'PM10', 'light', 'lux']
|
||||||
|
|
||||||
|
for metric in metrics_to_check:
|
||||||
|
reading = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=device.id, metric=metric)
|
||||||
|
.order_by('-time')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if reading:
|
||||||
|
latest_readings[metric] = reading.value
|
||||||
|
|
||||||
|
if not latest_readings:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No telemetry data available for comfort calculation'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate comfort index
|
||||||
|
comfort_metrics = ComfortMetrics(
|
||||||
|
temperature=latest_readings.get('temperature'),
|
||||||
|
humidity=latest_readings.get('humidity'),
|
||||||
|
co2=latest_readings.get('co2') or latest_readings.get('CO2'),
|
||||||
|
noise=latest_readings.get('noise') or latest_readings.get('sound'),
|
||||||
|
pm25=latest_readings.get('pm2.5') or latest_readings.get('PM2.5'),
|
||||||
|
pm10=latest_readings.get('pm10') or latest_readings.get('PM10'),
|
||||||
|
light=latest_readings.get('light') or latest_readings.get('lux'),
|
||||||
|
)
|
||||||
|
|
||||||
|
comfort_score = ComfortIndexCalculator.calculate(comfort_metrics)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'device_id': device.id,
|
||||||
|
'device_name': device.name,
|
||||||
|
'overall_score': comfort_score.overall_score,
|
||||||
|
'rating': comfort_score.rating,
|
||||||
|
'components': {
|
||||||
|
'temperature': comfort_score.temperature_score,
|
||||||
|
'humidity': comfort_score.humidity_score,
|
||||||
|
'air_quality': comfort_score.air_quality_score,
|
||||||
|
'acoustic': comfort_score.acoustic_score,
|
||||||
|
'light': comfort_score.light_score,
|
||||||
|
},
|
||||||
|
'suggestions': comfort_score.suggestions,
|
||||||
|
'raw_readings': latest_readings,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
|||||||
300
iotDashboard/comfort_index.py
Normal file
300
iotDashboard/comfort_index.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Comfort Index Calculation Module
|
||||||
|
|
||||||
|
Combines temperature, humidity, CO₂, noise, and air quality into a weighted comfort score.
|
||||||
|
Score range: 0-100 (100 = optimal comfort)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComfortMetrics:
|
||||||
|
"""Raw environmental metrics for comfort calculation."""
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
humidity: Optional[float] = None
|
||||||
|
co2: Optional[float] = None
|
||||||
|
noise: Optional[float] = None
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
light: Optional[float] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComfortScore:
|
||||||
|
"""Comfort index results with component scores."""
|
||||||
|
overall_score: float # 0-100
|
||||||
|
temperature_score: float
|
||||||
|
humidity_score: float
|
||||||
|
air_quality_score: float
|
||||||
|
acoustic_score: float
|
||||||
|
light_score: float
|
||||||
|
rating: str # "Excellent", "Good", "Fair", "Poor", "Very Poor"
|
||||||
|
suggestions: List[str]
|
||||||
|
|
||||||
|
class ComfortIndexCalculator:
|
||||||
|
"""Calculate multi-factor comfort index from environmental sensors."""
|
||||||
|
|
||||||
|
# Optimal ranges (comfort score = 100 at these values)
|
||||||
|
OPTIMAL_TEMP_RANGE = (20.0, 24.0) # °C
|
||||||
|
OPTIMAL_HUMIDITY_RANGE = (40.0, 60.0) # %
|
||||||
|
OPTIMAL_CO2_MAX = 800 # ppm (< 1000 is good)
|
||||||
|
OPTIMAL_NOISE_MAX = 40 # dB (office environment)
|
||||||
|
OPTIMAL_PM25_MAX = 12 # μg/m³ (WHO guideline)
|
||||||
|
OPTIMAL_PM10_MAX = 20 # μg/m³
|
||||||
|
OPTIMAL_LIGHT_RANGE = (300, 500) # Lux (office work)
|
||||||
|
|
||||||
|
# Weights for overall score (must sum to 1.0)
|
||||||
|
WEIGHTS = {
|
||||||
|
'temperature': 0.25,
|
||||||
|
'humidity': 0.15,
|
||||||
|
'air_quality': 0.30,
|
||||||
|
'acoustic': 0.15,
|
||||||
|
'light': 0.15,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_temperature_score(temp: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score temperature comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_TEMP_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= temp <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif temp < optimal_min:
|
||||||
|
deviation = optimal_min - temp
|
||||||
|
score = max(0, 100 - (deviation * 10)) # -10 points per degree below
|
||||||
|
if deviation > 2:
|
||||||
|
suggestions.append(f"Temperature too cold ({temp:.1f}°C). Increase heating to {optimal_min}-{optimal_max}°C")
|
||||||
|
else:
|
||||||
|
deviation = temp - optimal_max
|
||||||
|
score = max(0, 100 - (deviation * 10)) # -10 points per degree above
|
||||||
|
if deviation > 2:
|
||||||
|
suggestions.append(f"Temperature too warm ({temp:.1f}°C). Increase cooling to {optimal_min}-{optimal_max}°C")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_humidity_score(humidity: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score humidity comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_HUMIDITY_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= humidity <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif humidity < optimal_min:
|
||||||
|
# Too dry
|
||||||
|
deviation = optimal_min - humidity
|
||||||
|
score = max(0, 100 - (deviation * 2)) # -2 points per % below
|
||||||
|
if deviation > 10:
|
||||||
|
suggestions.append(f"Air too dry ({humidity:.1f}%). Use humidifier to reach {optimal_min}-{optimal_max}%")
|
||||||
|
else:
|
||||||
|
# Too humid
|
||||||
|
deviation = humidity - optimal_max
|
||||||
|
score = max(0, 100 - (deviation * 2)) # -2 points per % above
|
||||||
|
if deviation > 10:
|
||||||
|
suggestions.append(f"Air too humid ({humidity:.1f}%). Use dehumidifier to reach {optimal_min}-{optimal_max}%")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_air_quality_score(co2: Optional[float], pm25: Optional[float], pm10: Optional[float]) -> Tuple[float, List[str]]:
|
||||||
|
"""Score air quality based on CO₂ and particulate matter (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
scores = []
|
||||||
|
|
||||||
|
# CO₂ score
|
||||||
|
if co2 is not None:
|
||||||
|
if co2 <= ComfortIndexCalculator.OPTIMAL_CO2_MAX:
|
||||||
|
co2_score = 100.0
|
||||||
|
elif co2 <= 1000:
|
||||||
|
# Acceptable range (800-1000 ppm)
|
||||||
|
co2_score = 100 - ((co2 - 800) / 2) # -0.5 points per ppm
|
||||||
|
else:
|
||||||
|
# Poor air quality
|
||||||
|
co2_score = max(0, 50 - ((co2 - 1000) / 20)) # Drops to 0 at 2000 ppm
|
||||||
|
if co2 > 1500:
|
||||||
|
suggestions.append(f"CO₂ level very high ({co2:.0f} ppm). Increase ventilation immediately")
|
||||||
|
else:
|
||||||
|
suggestions.append(f"CO₂ level elevated ({co2:.0f} ppm). Improve ventilation")
|
||||||
|
scores.append(co2_score)
|
||||||
|
|
||||||
|
# PM2.5 score
|
||||||
|
if pm25 is not None:
|
||||||
|
if pm25 <= ComfortIndexCalculator.OPTIMAL_PM25_MAX:
|
||||||
|
pm25_score = 100.0
|
||||||
|
elif pm25 <= 35.4:
|
||||||
|
pm25_score = 100 - ((pm25 - 12) / 0.234)
|
||||||
|
else:
|
||||||
|
pm25_score = 0
|
||||||
|
suggestions.append(f"PM2.5 unhealthy ({pm25:.1f} μg/m³). Use air purifier and avoid outdoor air")
|
||||||
|
scores.append(pm25_score)
|
||||||
|
|
||||||
|
# PM10 score
|
||||||
|
if pm10 is not None:
|
||||||
|
if pm10 <= ComfortIndexCalculator.OPTIMAL_PM10_MAX:
|
||||||
|
pm10_score = 100.0
|
||||||
|
elif pm10 <= 50: # Acceptable
|
||||||
|
pm10_score = 100 - ((pm10 - 20) / 0.3)
|
||||||
|
else:
|
||||||
|
pm10_score = max(0, 50 - (pm10 - 50))
|
||||||
|
if pm10 > 100:
|
||||||
|
suggestions.append(f"PM10 very high ({pm10:.1f} μg/m³). Close windows and use filtration")
|
||||||
|
scores.append(pm10_score)
|
||||||
|
|
||||||
|
score = sum(scores) / len(scores) if scores else 50.0
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_acoustic_score(noise: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score acoustic comfort based on noise level (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
if noise <= ComfortIndexCalculator.OPTIMAL_NOISE_MAX:
|
||||||
|
score = 100.0
|
||||||
|
elif noise <= 55:
|
||||||
|
score = 100 - ((noise - 40) * 3)
|
||||||
|
elif noise <= 70: # Noisy
|
||||||
|
score = max(0, 55 - ((noise - 55) * 2))
|
||||||
|
suggestions.append(f"Noise level high ({noise:.1f} dB). Consider noise-canceling or quieter environment")
|
||||||
|
else: # Very noisy
|
||||||
|
score = 0
|
||||||
|
suggestions.append(f"Noise level very high ({noise:.1f} dB). Immediate action needed for hearing protection")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_light_score(light: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score lighting comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_LIGHT_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= light <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif light < optimal_min:
|
||||||
|
# Too dark
|
||||||
|
if light < 100:
|
||||||
|
score = 0
|
||||||
|
suggestions.append(f"Lighting very dim ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
score = (light / optimal_min) * 100
|
||||||
|
suggestions.append(f"Lighting insufficient ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
# Too bright
|
||||||
|
if light > 1000:
|
||||||
|
score = max(0, 100 - ((light - 1000) / 10))
|
||||||
|
suggestions.append(f"Lighting very bright ({light:.0f} lux). May cause glare. Reduce to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
score = 100 - ((light - optimal_max) / 5)
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_rating(score: float) -> str:
|
||||||
|
"""Convert numeric score to rating."""
|
||||||
|
if score >= 90:
|
||||||
|
return "Excellent"
|
||||||
|
elif score >= 75:
|
||||||
|
return "Good"
|
||||||
|
elif score >= 60:
|
||||||
|
return "Fair"
|
||||||
|
elif score >= 40:
|
||||||
|
return "Poor"
|
||||||
|
else:
|
||||||
|
return "Very Poor"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(cls, metrics: ComfortMetrics) -> ComfortScore:
|
||||||
|
"""
|
||||||
|
Calculate overall comfort index from environmental metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics: ComfortMetrics with sensor readings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfortScore with overall score and component breakdowns
|
||||||
|
"""
|
||||||
|
all_suggestions = []
|
||||||
|
component_scores = {}
|
||||||
|
|
||||||
|
# Temperature
|
||||||
|
if metrics.temperature is not None:
|
||||||
|
temp_score, temp_suggestions = cls.calculate_temperature_score(metrics.temperature)
|
||||||
|
component_scores['temperature'] = temp_score
|
||||||
|
all_suggestions.extend(temp_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['temperature'] = 50.0 #
|
||||||
|
|
||||||
|
# Humidity
|
||||||
|
if metrics.humidity is not None:
|
||||||
|
humidity_score, humidity_suggestions = cls.calculate_humidity_score(metrics.humidity)
|
||||||
|
component_scores['humidity'] = humidity_score
|
||||||
|
all_suggestions.extend(humidity_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['humidity'] = 50.0
|
||||||
|
|
||||||
|
# Air Quality (CO₂ + PM)
|
||||||
|
air_score, air_suggestions = cls.calculate_air_quality_score(
|
||||||
|
metrics.co2, metrics.pm25, metrics.pm10
|
||||||
|
)
|
||||||
|
component_scores['air_quality'] = air_score
|
||||||
|
all_suggestions.extend(air_suggestions)
|
||||||
|
|
||||||
|
# Acoustic
|
||||||
|
if metrics.noise is not None:
|
||||||
|
acoustic_score, acoustic_suggestions = cls.calculate_acoustic_score(metrics.noise)
|
||||||
|
component_scores['acoustic'] = acoustic_score
|
||||||
|
all_suggestions.extend(acoustic_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['acoustic'] = 50.0
|
||||||
|
|
||||||
|
# Light
|
||||||
|
if metrics.light is not None:
|
||||||
|
light_score, light_suggestions = cls.calculate_light_score(metrics.light)
|
||||||
|
component_scores['light'] = light_score
|
||||||
|
all_suggestions.extend(light_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['light'] = 50.0
|
||||||
|
|
||||||
|
# Calculate weighted overall score
|
||||||
|
overall = sum(
|
||||||
|
component_scores[key] * cls.WEIGHTS[key]
|
||||||
|
for key in cls.WEIGHTS.keys()
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComfortScore(
|
||||||
|
overall_score=round(overall, 1),
|
||||||
|
temperature_score=round(component_scores['temperature'], 1),
|
||||||
|
humidity_score=round(component_scores['humidity'], 1),
|
||||||
|
air_quality_score=round(component_scores['air_quality'], 1),
|
||||||
|
acoustic_score=round(component_scores['acoustic'], 1),
|
||||||
|
light_score=round(component_scores['light'], 1),
|
||||||
|
rating=cls.get_rating(overall),
|
||||||
|
suggestions=all_suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_comfort_index_from_telemetry(telemetry_data: Dict[str, float]) -> ComfortScore:
|
||||||
|
"""
|
||||||
|
Convenience function to calculate comfort index from telemetry dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: Dict with metric names as keys, e.g. {"temperature": 22.5, "humidity": 45}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfortScore
|
||||||
|
"""
|
||||||
|
metrics = ComfortMetrics(
|
||||||
|
temperature=telemetry_data.get('temperature'),
|
||||||
|
humidity=telemetry_data.get('humidity'),
|
||||||
|
co2=telemetry_data.get('co2') or telemetry_data.get('CO2'),
|
||||||
|
noise=telemetry_data.get('noise') or telemetry_data.get('sound'),
|
||||||
|
pm25=telemetry_data.get('pm2.5') or telemetry_data.get('PM2.5'),
|
||||||
|
pm10=telemetry_data.get('pm10') or telemetry_data.get('PM10'),
|
||||||
|
light=telemetry_data.get('light') or telemetry_data.get('lux'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComfortIndexCalculator.calculate(metrics)
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
Client for GPT Service microservice.
|
||||||
|
|
||||||
|
Handles communication between Django and the GPT analysis service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional, Literal
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceError(Exception):
|
||||||
|
"""Exception raised when GPT service request fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict] = None):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceClient:
|
||||||
|
"""Client for interacting with the GPT analysis microservice."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: Optional[str] = None, timeout: float = 30.0):
|
||||||
|
"""
|
||||||
|
Initialize GPT service client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of GPT service (default: from settings or http://localhost:8001)
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url or getattr(settings, 'GPT_SERVICE_URL', 'http://localhost:8001')
|
||||||
|
self.timeout = timeout
|
||||||
|
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
||||||
|
logger.info(f"Initialized GPTServiceClient with base_url={self.base_url}")
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check if GPT service is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If service is unreachable
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get("/health")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GPT service health check failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def analyze_telemetry(
|
||||||
|
self,
|
||||||
|
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
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze telemetry data using GPT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: List of telemetry data points with keys:
|
||||||
|
- device_id: str
|
||||||
|
- metric: str
|
||||||
|
- value: float
|
||||||
|
- timestamp: str (ISO format)
|
||||||
|
device_info: Optional device metadata (name, location, etc.)
|
||||||
|
prompt_type: Type of analysis to perform
|
||||||
|
custom_prompt: Custom prompt for 'custom' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- analysis: str (AI-generated analysis)
|
||||||
|
- prompt_type: str
|
||||||
|
- data_points_analyzed: int
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If telemetry_data is empty
|
||||||
|
httpx.HTTPError: If service request fails
|
||||||
|
"""
|
||||||
|
if not telemetry_data:
|
||||||
|
raise ValueError("telemetry_data cannot be empty")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"telemetry_data": telemetry_data,
|
||||||
|
"device_info": device_info or {},
|
||||||
|
"prompt_type": prompt_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
if custom_prompt:
|
||||||
|
payload["custom_prompt"] = custom_prompt
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Requesting {prompt_type} analysis for {len(telemetry_data)} data points"
|
||||||
|
)
|
||||||
|
response = await self.client.post("/analyze", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info(
|
||||||
|
f"Analysis completed: {result.get('data_points_analyzed')} points analyzed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_detail = e.response.text
|
||||||
|
logger.error(
|
||||||
|
f"GPT service returned error {e.response.status_code}: {error_detail}"
|
||||||
|
)
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service error: {error_detail}",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
details={"response": error_detail}
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to connect to GPT service: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service unavailable: {str(e)}",
|
||||||
|
status_code=503,
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to analyze telemetry: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"Analysis failed: {str(e)}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def detect_anomalies(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Detect anomalies in telemetry data.
|
||||||
|
|
||||||
|
Convenience method for anomaly_detection analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with analysis containing:
|
||||||
|
- status: normal|warning|critical
|
||||||
|
- summary: Brief overview
|
||||||
|
- anomalies: List of detected anomalies
|
||||||
|
- impacts: List of potential impacts
|
||||||
|
- actions: List of recommended actions
|
||||||
|
"""
|
||||||
|
result = await self.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type="anomaly_detection"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def summarize_trends(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Summarize trends in telemetry data.
|
||||||
|
|
||||||
|
Convenience method for trend_summary analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with analysis containing:
|
||||||
|
- status: excellent|good|fair|poor
|
||||||
|
- summary: Brief overview
|
||||||
|
- trends: List of metric trends
|
||||||
|
- comfort_score: Overall comfort rating
|
||||||
|
- patterns: Identified patterns
|
||||||
|
- recommendations: Suggested actions
|
||||||
|
"""
|
||||||
|
result = await self.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type="trend_summary"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP client connection."""
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def format_telemetry_for_gpt(queryset, device_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Format Django Telemetry queryset for GPT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queryset: Django queryset of Telemetry objects
|
||||||
|
device_id: Optional device_id to include in each point
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts formatted for GPT service
|
||||||
|
"""
|
||||||
|
data = []
|
||||||
|
for point in queryset:
|
||||||
|
data.append({
|
||||||
|
"device_id": device_id or point.device_id,
|
||||||
|
"metric": point.metric,
|
||||||
|
"value": float(point.value),
|
||||||
|
"timestamp": point.time.isoformat(),
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
_client_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gpt_client() -> GPTServiceClient:
|
||||||
|
"""
|
||||||
|
Get or create singleton GPT service client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GPTServiceClient instance
|
||||||
|
"""
|
||||||
|
global _client_instance
|
||||||
|
if _client_instance is None:
|
||||||
|
_client_instance = GPTServiceClient()
|
||||||
|
return _client_instance
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Module-level function to analyze telemetry data.
|
||||||
|
|
||||||
|
Uses singleton client instance. Convenience wrapper for Django views.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: List of telemetry data points
|
||||||
|
device_info: Optional device metadata
|
||||||
|
prompt_type: Type of analysis to perform
|
||||||
|
custom_prompt: Custom prompt for 'custom' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing analysis results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GPTServiceError: If analysis fails
|
||||||
|
"""
|
||||||
|
client = get_gpt_client()
|
||||||
|
return await client.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type=prompt_type,
|
||||||
|
custom_prompt=custom_prompt
|
||||||
|
)
|
||||||
@@ -33,6 +33,8 @@ POSTGRES_USER = os.getenv("POSTGRES_USER")
|
|||||||
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
||||||
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||||
|
|
||||||
|
GPT_SERVICE_URL = os.getenv("GPT_SERVICE_URL", "http://localhost:8001")
|
||||||
|
|
||||||
# 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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user