Support partial sensor readings and improve room metrics aggregation

- Allow room and card components to handle rooms with missing energy or
CO2 data - Update RoomMetrics type to make energy and co2 fields
optional - Track which sensors provide energy or CO2 data per room -
Aggregate room metrics only from available data (partial readings) -
Update AirQualityCard and RoomMetricsCard to safely access optional
fields - Set MAX_HISTORY_POINTS to 48 in energy store - Improve
robustness of API room fetching and data mapping - Update CLAUDE.md with
new partial reading support and data flow details
This commit is contained in:
rafaeldpsilva
2025-10-03 10:51:48 +01:00
parent f96456ed29
commit e2cf2bc782
6 changed files with 308 additions and 69 deletions

189
CLAUDE.md Normal file
View File

@@ -0,0 +1,189 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Vue.js 3 frontend for a **Real-Time Energy Monitoring Dashboard** that displays sensor data, room metrics, air quality monitoring, and energy analytics for smart buildings. The frontend connects to either a monolithic backend or microservices architecture via WebSocket and REST APIs.
## Development Commands
```bash
# Development
npm run dev # Start dev server (http://localhost:5173)
npm run dev-server # Dev server with host binding
# Building
npm run build # Full production build (type-check + build)
npm run build-only # Vite build without type checking
npm run type-check # TypeScript validation only
# Testing & Quality
npm run test:unit # Run Vitest unit tests
npm run lint # ESLint with auto-fix
npm run format # Prettier code formatting
```
## Architecture
### State Management (Pinia Stores)
The application uses **5 specialized Pinia stores** with clear separation of concerns:
1. **`websocket.ts`** - WebSocket connection management
- Connects to `ws://localhost:8000/ws` or `ws://localhost:8007/ws`
- Buffers incoming messages to prevent UI blocking
- Handles proxy info messages for microservices routing
- Delegates data processing to sensor/room stores
2. **`sensor.ts`** - Sensor device management
- Maintains `Map<sensorId, SensorDevice>` for all devices
- Tracks `latestReadings` as `Map<sensorId, SensorReading>`
- Provides aggregated CO2 metrics (`averageCO2Level`, `maxCO2Level`)
- API integration with auth retry logic via `WindowWithAuth` pattern
3. **`room.ts`** - Room-based metrics aggregation
- Groups sensor data by room into `RoomMetrics`
- Calculates per-room energy consumption and CO2 levels
- Provides CO2 status classification (good/moderate/poor/critical)
- Estimates occupancy based on environmental data
4. **`energy.ts`** - Energy-specific aggregations
- Delegates to other stores (sensor, room, analytics, websocket)
- Maintains energy history with configurable `MAX_HISTORY_POINTS`
- Provides convenience functions for legacy AnalyticsView compatibility
5. **`analytics.ts`** - System-wide analytics
- Fetches analytics summaries from backend API
- Tracks system-wide health status
- Provides aggregated metrics across all sensors/rooms
6. **`auth.ts`** - JWT authentication
- Token generation, validation, and lifecycle management
- LocalStorage persistence with expiry checking
- Exposed globally via `window.__AUTH_STORE__` for API clients
### API Services (`src/services/`)
Service layer organized by domain:
- **`api.ts`** - Base API client with types and health checks
- **`sensorsApi.ts`** - Sensor CRUD operations
- **`roomsApi.ts`** - Room management and data queries
- **`analyticsApi.ts`** - Analytics summaries and trends
- **`authApi.ts`** - Token generation and validation
- **`index.ts`** - Central export point
All services use the `WindowWithAuth` pattern to access auth store without circular dependencies.
### Data Flow
**Real-time updates:**
1. WebSocket receives sensor reading from backend
2. `websocket.ts` buffers and processes message
3. Data sent to `sensor.ts` (individual readings) AND `room.ts` (aggregations)
4. Vue components react to store changes via computed properties
**API data fetching:**
1. Component calls store action (e.g., `sensorStore.fetchApiSensors()`)
2. Store delegates to API service (e.g., `sensorsApi.getSensors()`)
3. Service includes auth header via `useAuthStore().getAuthHeader()`
4. On 401 error, service calls `window.__AUTH_STORE__.ensureAuthenticated()`
5. Retries request with new token
### WindowWithAuth Pattern
To avoid circular imports when stores need authentication, the auth store is attached to the global window object:
```typescript
// In main.ts
(window as any).__AUTH_STORE__ = authStore
// In other stores/services
interface WindowWithAuth extends Window {
__AUTH_STORE__?: {
ensureAuthenticated: () => Promise<boolean>
}
}
const authStore = (window as WindowWithAuth).__AUTH_STORE__
if (authStore) await authStore.ensureAuthenticated()
```
Used in: `sensor.ts`, `room.ts`, `analytics.ts`, `api.ts`
## Data Models
### Dual Format Support
The system handles two data formats for backward compatibility:
**Legacy Format (energy only):**
```typescript
{ sensorId: string, timestamp: number, value: number, unit: string }
```
**Multi-Metric Format (current):**
```typescript
{
sensor_id: string
room: string
timestamp: number
energy: { value: number, unit: string }
co2: { value: number, unit: string }
temperature?: { value: number, unit: string }
}
```
### Partial Reading Support
The system now supports **partial sensor readings** where a single message may contain only energy OR only CO2 data.
**Implementation:**
- `src/stores/room.ts:44-121` - `updateRoomData()` accepts partial readings and aggregates by metric type
- `src/stores/websocket.ts:118-144` - Processes any reading with a room field
- Energy metrics aggregate from sensors with `energy.value !== undefined`
- CO2 metrics aggregate from sensors with `co2.value !== undefined`
This allows `data_simulator_enhanced.py` to send single-metric readings while still populating room-level aggregations correctly.
## Routing
5 main views in `src/router/index.ts`:
- `/` - HomeView (dashboard overview)
- `/sensors` - SensorManagementView (sensor CRUD)
- `/ai-optimization` - AIOptimizationView (AI features)
- `/analytics` - AnalyticsView (detailed analytics)
- `/settings` - SettingsView (configuration)
## Backend Integration
**WebSocket endpoints:**
- Monolithic: `ws://localhost:8000/ws`
- Microservices: `ws://localhost:8007/ws` (sensor service direct)
**REST API:** `http://localhost:8000` (API Gateway for microservices)
**Authentication:** JWT tokens via Token Service (port 8001)
## Component Structure
**Views:** Page-level components in `/views/`
**Cards:** Reusable metric cards in `/components/cards/`
**Charts:** Visualization components (multiple chart libraries: ApexCharts, Chart.js, ECharts)
## Key Technologies
- **Vue 3** - Composition API with `<script setup>`
- **TypeScript** - Type-safe development
- **Pinia** - State management
- **Vue Router** - Client-side routing
- **Vite** - Build tool with HMR
- **Tailwind CSS** - Utility-first styling
- **ECharts** - Primary charting library
- **Vitest** - Unit testing framework
## Prerequisites
- **Node.js 20.19.0+ or 22.12.0+**
- **Redis** on `localhost:6379` (for real-time data)
- **Backend** on `localhost:8000` (monolithic or API Gateway)

View File

@@ -31,16 +31,16 @@
<div v-for="room in roomsList" :key="room.room" class="flex items-center justify-between p-2 rounded">
<div class="flex items-center gap-2">
<div
<div
class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2.status)"
:class="getCO2StatusColor(room.co2?.status || 'good')"
></div>
<span class="text-sm font-medium text-gray-900">{{ room.room }}</span>
</div>
<div class="text-right">
<div class="text-sm text-gray-900">{{ Math.round(room.co2.current) }} ppm</div>
<div class="text-xs" :class="getCO2TextColor(room.co2.status)">
{{ room.co2.status.toUpperCase() }}
<div class="text-sm text-gray-900">{{ Math.round(room.co2?.current || 0) }} ppm</div>
<div class="text-xs" :class="getCO2TextColor(room.co2?.status || 'good')">
{{ (room.co2?.status || 'good').toUpperCase() }}
</div>
</div>
</div>
@@ -75,14 +75,17 @@ import { useRoomStore } from '@/stores/room'
const roomStore = useRoomStore()
const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values()).sort((a, b) =>
b.co2.current - a.co2.current // Sort by CO2 level descending
)
return Array.from(roomStore.roomsData.values())
.filter(room => room.co2) // Only include rooms with CO2 data
.sort((a, b) =>
(b.co2?.current || 0) - (a.co2?.current || 0) // Sort by CO2 level descending
)
})
const overallCO2 = computed(() => {
if (roomsList.value.length === 0) return 0
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length
const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
return total / roomsList.value.length
})
const overallStatus = computed(() => {
@@ -90,18 +93,18 @@ const overallStatus = computed(() => {
})
const roomsWithGoodAir = computed(() => {
return roomsList.value.filter(room => room.co2.status === 'good').length
return roomsList.value.filter(room => room.co2?.status === 'good').length
})
const roomsNeedingAttention = computed(() => {
return roomsList.value.filter(room => ['poor', 'critical'].includes(room.co2.status)).length
return roomsList.value.filter(room => room.co2?.status && ['poor', 'critical'].includes(room.co2.status)).length
})
const recommendations = computed(() => {
const recs = []
const criticalRooms = roomsList.value.filter(room => room.co2.status === 'critical')
const poorRooms = roomsList.value.filter(room => room.co2.status === 'poor')
const criticalRooms = roomsList.value.filter(room => room.co2?.status === 'critical')
const poorRooms = roomsList.value.filter(room => room.co2?.status === 'poor')
if (criticalRooms.length > 0) {
recs.push(`Immediate ventilation needed in ${criticalRooms[0].room}`)
}
@@ -111,7 +114,7 @@ const recommendations = computed(() => {
if (overallCO2.value > 800) {
recs.push('Consider adjusting HVAC settings building-wide')
}
return recs.slice(0, 3) // Max 3 recommendations
})

View File

@@ -13,9 +13,9 @@
<h3 class="font-medium text-gray-900">{{ room.room }}</h3>
<div class="flex items-center gap-2">
<!-- CO2 Status Indicator -->
<div
<div
class="w-3 h-3 rounded-full"
:class="getCO2StatusColor(room.co2.status)"
:class="getCO2StatusColor(room.co2!.status)"
></div>
<!-- Occupancy Indicator -->
<div class="flex items-center gap-1 text-xs text-gray-500">
@@ -32,15 +32,15 @@
<!-- Energy -->
<div class="bg-blue-50 rounded p-2">
<div class="text-blue-600 font-medium">Energy</div>
<div class="text-blue-900">{{ room.energy.current.toFixed(2) }} {{ room.energy.unit }}</div>
<div class="text-blue-600 text-xs">Total: {{ room.energy.total.toFixed(2) }}</div>
<div class="text-blue-900">{{ room.energy!.current.toFixed(2) }} {{ room.energy!.unit }}</div>
<div class="text-blue-600 text-xs">Total: {{ room.energy!.total.toFixed(2) }}</div>
</div>
<!-- CO2 -->
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2.status)">
<div class="font-medium" :class="getCO2TextColor(room.co2.status)">CO2</div>
<div :class="getCO2TextColor(room.co2.status)">{{ Math.round(room.co2.current) }} {{ room.co2.unit }}</div>
<div class="text-xs" :class="getCO2TextColor(room.co2.status)">{{ room.co2.status.toUpperCase() }}</div>
<div class="rounded p-2" :class="getCO2BackgroundColor(room.co2!.status)">
<div class="font-medium" :class="getCO2TextColor(room.co2!.status)">CO2</div>
<div :class="getCO2TextColor(room.co2!.status)">{{ Math.round(room.co2!.current) }} {{ room.co2!.unit }}</div>
<div class="text-xs" :class="getCO2TextColor(room.co2!.status)">{{ room.co2!.status.toUpperCase() }}</div>
</div>
</div>
@@ -77,18 +77,19 @@ import { useRoomStore } from '@/stores/room'
const roomStore = useRoomStore()
const roomsList = computed(() => {
return Array.from(roomStore.roomsData.values()).sort((a, b) =>
a.room.localeCompare(b.room)
)
return Array.from(roomStore.roomsData.values())
.filter(room => room.energy && room.co2) // Only show rooms with both metrics
.sort((a, b) => a.room.localeCompare(b.room))
})
const totalEnergy = computed(() => {
return roomsList.value.reduce((sum, room) => sum + room.energy.current, 0)
return roomsList.value.reduce((sum, room) => sum + (room.energy?.current || 0), 0)
})
const averageCO2 = computed(() => {
if (roomsList.value.length === 0) return 0
return roomsList.value.reduce((sum, room) => sum + room.co2.current, 0) / roomsList.value.length
const total = roomsList.value.reduce((sum, room) => sum + (room.co2?.current || 0), 0)
return total / roomsList.value.length
})
const getCO2StatusColor = (status: string) => {

View File

@@ -5,7 +5,7 @@ import { useSensorStore } from './sensor'
import { useRoomStore } from './room'
import { useAnalyticsStore } from './analytics'
const MAX_HISTORY_POINTS = 100
const MAX_HISTORY_POINTS = 48
/**
* Energy Store - Simplified to only track energy consumption metrics
@@ -67,7 +67,7 @@ export const useEnergyStore = defineStore('energy', () => {
energyHistory.shift()
energyTimestamps.shift()
}
}
},
)
// Update current consumption (called from components or watchers)

View File

@@ -13,13 +13,15 @@ interface WindowWithAuth extends Window {
interface RoomMetrics {
room: string
sensors: string[]
energy: {
energySensors: string[] // Track which sensors provide energy data
co2Sensors: string[] // Track which sensors provide CO2 data
energy?: {
current: number
total: number
average: number
unit: string
}
co2: {
co2?: {
current: number
average: number
max: number
@@ -44,9 +46,9 @@ export const useRoomStore = defineStore('room', () => {
function updateRoomData(data: SensorReading): void {
const sensorStore = useSensorStore()
// Validate data structure and provide fallbacks
if (!data.energy || !data.co2) {
console.warn('Invalid sensor reading data, missing energy or co2 properties:', data)
// Accept partial readings - validate that we have at least room and sensor_id
if (!data.room || !data.sensor_id) {
console.warn('Invalid sensor reading data, missing room or sensor_id:', data)
return
}
@@ -57,11 +59,12 @@ export const useRoomStore = defineStore('room', () => {
let roomMetrics = roomsData.get(data.room)
if (!roomMetrics) {
// Initialize with minimal required fields - energy and co2 are optional
roomMetrics = {
room: data.room,
sensors: [data.sensor_id],
energy: { current: 0, total: 0, average: 0, unit: data.energy?.unit || 'kWh' },
co2: { current: 0, average: 0, max: 0, status: 'good', unit: data.co2?.unit || 'ppm' },
sensors: [],
energySensors: [],
co2Sensors: [],
occupancyEstimate: 'low',
lastUpdated: data.timestamp,
}
@@ -73,32 +76,78 @@ export const useRoomStore = defineStore('room', () => {
roomMetrics.sensors.push(data.sensor_id)
}
// Track which sensors provide which metrics
if (data.energy?.value !== undefined && !roomMetrics.energySensors.includes(data.sensor_id)) {
roomMetrics.energySensors.push(data.sensor_id)
}
if (data.co2?.value !== undefined && !roomMetrics.co2Sensors.includes(data.sensor_id)) {
roomMetrics.co2Sensors.push(data.sensor_id)
}
// Recalculate room metrics from all sensors in the room
const roomSensors = Array.from(sensorStore.latestReadings.values()).filter(
(reading) => reading.room === data.room,
)
// Energy calculations
roomMetrics.energy.current = roomSensors.reduce((sum, sensor) => sum + sensor.energy.value, 0)
roomMetrics.energy.total += data.energy.value // Accumulate total
roomMetrics.energy.average = roomMetrics.energy.total / roomSensors.length
// Energy calculations - only if energy data is present in ANY sensor
const energySensors = roomSensors.filter((sensor) => sensor.energy?.value !== undefined)
if (energySensors.length > 0) {
// Initialize energy object if it doesn't exist
if (!roomMetrics.energy) {
roomMetrics.energy = {
current: 0,
total: 0,
average: 0,
unit: data.energy?.unit || 'kWh',
}
}
// CO2 calculations
const co2Values = roomSensors.map((sensor) => sensor.co2.value)
roomMetrics.co2.current = co2Values.reduce((sum, val) => sum + val, 0) / co2Values.length
roomMetrics.co2.max = Math.max(roomMetrics.co2.max, ...co2Values)
roomMetrics.co2.average = (roomMetrics.co2.average + roomMetrics.co2.current) / 2
roomMetrics.energy.current = energySensors.reduce(
(sum, sensor) => sum + (sensor.energy?.value || 0),
0,
)
if (data.energy?.value !== undefined) {
roomMetrics.energy.total += data.energy.value // Accumulate total only for this reading
}
roomMetrics.energy.average = roomMetrics.energy.total / energySensors.length
if (data.energy?.unit) {
roomMetrics.energy.unit = data.energy.unit
}
}
// CO2 status classification
if (roomMetrics.co2.current < 400) roomMetrics.co2.status = 'good'
else if (roomMetrics.co2.current < 1000) roomMetrics.co2.status = 'moderate'
else if (roomMetrics.co2.current < 5000) roomMetrics.co2.status = 'poor'
else roomMetrics.co2.status = 'critical'
// CO2 calculations - only if co2 data is present in ANY sensor
const co2Sensors = roomSensors.filter((sensor) => sensor.co2?.value !== undefined)
if (co2Sensors.length > 0) {
// Initialize co2 object if it doesn't exist
if (!roomMetrics.co2) {
roomMetrics.co2 = {
current: 0,
average: 0,
max: 0,
status: 'good',
unit: data.co2?.unit || 'ppm',
}
}
// Occupancy estimate based on CO2 levels
if (roomMetrics.co2.current < 600) roomMetrics.occupancyEstimate = 'low'
else if (roomMetrics.co2.current < 1200) roomMetrics.occupancyEstimate = 'medium'
else roomMetrics.occupancyEstimate = 'high'
const co2Values = co2Sensors.map((sensor) => sensor.co2?.value || 0)
roomMetrics.co2.current = co2Values.reduce((sum, val) => sum + val, 0) / co2Values.length
roomMetrics.co2.max = Math.max(roomMetrics.co2.max, ...co2Values)
roomMetrics.co2.average = (roomMetrics.co2.average + roomMetrics.co2.current) / 2
if (data.co2?.unit) {
roomMetrics.co2.unit = data.co2.unit
}
// CO2 status classification
if (roomMetrics.co2.current < 400) roomMetrics.co2.status = 'good'
else if (roomMetrics.co2.current < 1000) roomMetrics.co2.status = 'moderate'
else if (roomMetrics.co2.current < 5000) roomMetrics.co2.status = 'poor'
else roomMetrics.co2.status = 'critical'
// Occupancy estimate based on CO2 levels
if (roomMetrics.co2.current < 600) roomMetrics.occupancyEstimate = 'low'
else if (roomMetrics.co2.current < 1200) roomMetrics.occupancyEstimate = 'medium'
else roomMetrics.occupancyEstimate = 'high'
}
roomMetrics.lastUpdated = data.timestamp
}
@@ -192,8 +241,8 @@ export const useRoomStore = defineStore('room', () => {
sensorCount: sensorsInRoom.length,
sensorTypes: [...new Set(sensorsInRoom.map((s) => s.type))],
hasMetrics: !!roomMetrics,
energyConsumption: roomMetrics?.energy.current || 0,
co2Level: roomMetrics?.co2.current || 0,
energyConsumption: roomMetrics?.energy?.current || 0,
co2Level: roomMetrics?.co2?.current || 0,
lastUpdated: roomMetrics?.lastUpdated || null,
}
}
@@ -263,11 +312,11 @@ export const useRoomStore = defineStore('room', () => {
const result = await handleApiCall(() => roomsApi.getRooms())
if (result) {
// Handle both response formats: {rooms: [...]} or direct array [...]
const roomsArray = Array.isArray(result) ? result : result.rooms || []
const roomsArray = Array.isArray(result) ? result : (result as any).rooms || []
apiRooms.value = roomsArray
// Update available rooms from API data
const roomNames = roomsArray.map((room) => room.name || room.room).filter((name) => name)
const roomNames = roomsArray.map((room: any) => room.name || room.room).filter((name: string) => name)
if (roomNames.length > 0) {
availableRooms.value = [...new Set([...availableRooms.value, ...roomNames])].sort()
}

View File

@@ -124,22 +124,19 @@ export const useWebSocketStore = defineStore('websocket', () => {
const sensorStore = useSensorStore()
const roomStore = useRoomStore()
// Handle new multi-metric data
// Only update room data if we have the proper structure
if (data.energy && data.co2 && data.room) {
// Update individual sensor readings first
sensorStore.updateLatestReading(data)
// Update room data if we have room information (accepts partial readings)
if (data.room) {
if (data.energy) {
sensorStore.updateEnergySensors(data)
}
roomStore.updateRoomData(data)
}
// Map the sensor ID for individual sensor updates
// const mappedSensorId = mapWebSocketSensorId(data.sensorId)
const mappedData = { ...data, sensorId: data.sensorId, id: data.sensorId }
sensorStore.updateLatestReading(data) // Update individual sensor readings for cards
// Update time series for chart if energy data is available
if (data.energy) {
// Update time series for chart (use energy values if available)
const newLabel = new Date(data.timestamp * 1000).toLocaleTimeString()
timeSeriesData.labels.push(newLabel)
timeSeriesData.datasets[0].data.push(data.energy?.value)