feat(ui): dynamic room polling, secure credentials and CO2 levels
- Poll only rooms returned by /api/v1/rooms so calls are never wasted on sensor-less rooms; new rooms are picked up automatically after deploy - Replace timer(0, interval) with concat() for synchronous initial emission - Remove mock fallback from getLatestReadingForRoom / getHistoryForRoom so the UI reflects real API state instead of hiding errors with fake data - Add Basic-Auth interceptor and dev proxy (proxy.conf.json) for local dev - Replace hardcoded API credentials with __API_USERNAME__ / __API_PASSWORD__ placeholders for CI-time injection from GitHub Secrets - Add "Very Poor" CO2 level (1500-2000 ppm) to the 6-level scale - Update "No sensor installed" copy to "No data available currently" - Handle empty history state in room-details-panel Closes #30
This commit is contained in:
@@ -86,7 +86,8 @@
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"options": {
|
||||
"port": 4200,
|
||||
"open": true
|
||||
"open": true,
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
|
||||
8
ui/proxy.conf.json
Normal file
8
ui/proxy.conf.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "https://api.db.e.kb28.ch",
|
||||
"secure": true,
|
||||
"changeOrigin": true,
|
||||
"logLevel": "info"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
|
||||
import { basicAuthInterceptor } from './interceptors/basic-auth.interceptor';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideAnimationsAsync(),
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withInterceptors([basicAuthInterceptor])),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</div>
|
||||
|
||||
} @else {
|
||||
<div class="no-sensor">No sensor installed in this room.</div>
|
||||
<div class="no-sensor">No data available currently.</div>
|
||||
}
|
||||
</aside>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, RouterModule } from '@angular/router';
|
||||
import { combineLatest, timer } from 'rxjs';
|
||||
import { combineLatest, concat, timer } from 'rxjs';
|
||||
import { map, switchMap } from 'rxjs/operators';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import { RoomLayout } from '../../config/rooms-layout.config';
|
||||
@@ -58,8 +58,14 @@ export class RoomDetailsPanelComponent implements OnInit {
|
||||
map(params => params.get('id') ?? ''),
|
||||
switchMap(id => combineLatest([
|
||||
this.roomService.getRoomById(id),
|
||||
timer(0, interval).pipe(switchMap(() => this.sensorService.getLatestReadingForRoom(id))),
|
||||
timer(0, interval).pipe(switchMap(() => this.sensorService.getHistoryForRoom(id))),
|
||||
concat(
|
||||
this.sensorService.getLatestReadingForRoom(id),
|
||||
timer(interval, interval).pipe(switchMap(() => this.sensorService.getLatestReadingForRoom(id))),
|
||||
),
|
||||
concat(
|
||||
this.sensorService.getHistoryForRoom(id),
|
||||
timer(interval, interval).pipe(switchMap(() => this.sensorService.getHistoryForRoom(id))),
|
||||
),
|
||||
])),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
).subscribe(([room, reading, history]) => {
|
||||
|
||||
@@ -227,7 +227,7 @@
|
||||
</strong>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tt-empty">No sensor installed</div>
|
||||
<div class="tt-empty">No data available currently</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { provideRouter, Router } from '@angular/router';
|
||||
import { of } from 'rxjs';
|
||||
import { RoomMapComponent } from './room-map.component';
|
||||
@@ -35,6 +37,8 @@ describe('RoomMapComponent', () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [RoomMapComponent],
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClientTesting(),
|
||||
provideRouter([]),
|
||||
{ provide: RoomService, useValue: roomServiceSpy },
|
||||
{ provide: SensorService, useValue: sensorServiceSpy },
|
||||
@@ -53,7 +57,7 @@ describe('RoomMapComponent', () => {
|
||||
|
||||
it('should call getRooms() and getLatestReadings() on init', () => {
|
||||
expect(roomServiceSpy.getRooms).toHaveBeenCalledOnceWith();
|
||||
expect(sensorServiceSpy.getLatestReadings).toHaveBeenCalledOnceWith();
|
||||
expect(sensorServiceSpy.getLatestReadings).toHaveBeenCalledOnceWith(jasmine.any(Array));
|
||||
});
|
||||
|
||||
it('should populate rooms signal after init', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Router } from '@angular/router';
|
||||
import { Subscription, timer } from 'rxjs';
|
||||
import { Subscription, concat, timer } from 'rxjs';
|
||||
import { switchMap, tap } from 'rxjs/operators';
|
||||
import { getCO2Color } from '../../config/co2-levels.config';
|
||||
import { SensorReading } from '../../models/sensor-reading.model';
|
||||
@@ -156,8 +156,12 @@ export class RoomMapComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
// Charge les salles une fois, puis poll les capteurs selon l'intervalle configuré
|
||||
this.pollSub = this.roomService.getRooms().pipe(
|
||||
tap((layouts) => { this.roomLayouts = layouts; }),
|
||||
switchMap(() => timer(0, environment.polling.mapIntervalMs)),
|
||||
switchMap(() => this.sensorService.getLatestReadings()),
|
||||
switchMap((rooms) => concat(
|
||||
this.sensorService.getLatestReadings(rooms),
|
||||
timer(environment.polling.mapIntervalMs, environment.polling.mapIntervalMs).pipe(
|
||||
switchMap(() => this.sensorService.getLatestReadings(rooms)),
|
||||
),
|
||||
)),
|
||||
).subscribe((readings) => {
|
||||
this.rooms.set(
|
||||
this.roomLayouts.map((layout) => {
|
||||
|
||||
@@ -11,11 +11,12 @@ export interface CO2Level {
|
||||
}
|
||||
|
||||
export const CO2_LEVELS: CO2Level[] = [
|
||||
{ label: 'Excellent', range: '< 800 ppm', color: '#4caf50', maxPpm: 800 },
|
||||
{ label: 'Good', range: '800-1000 ppm', color: '#8bc34a', maxPpm: 1000 },
|
||||
{ label: 'Moderate', range: '1000-1400 ppm', color: '#ffc107', maxPpm: 1400 },
|
||||
{ label: 'Poor', range: '1400-2000 ppm', color: '#ff9800', maxPpm: 2000 },
|
||||
{ label: 'Critical', range: '> 2000 ppm', color: '#f44336', maxPpm: Infinity },
|
||||
{ label: 'Excellent', range: '< 800 ppm', color: '#4caf50', maxPpm: 800 },
|
||||
{ label: 'Good', range: '800-1000 ppm', color: '#8bc34a', maxPpm: 1000 },
|
||||
{ label: 'Moderate', range: '1000-1200 ppm', color: '#ffc107', maxPpm: 1200 },
|
||||
{ label: 'Poor', range: '1200-1500 ppm', color: '#ff9800', maxPpm: 1500 },
|
||||
{ label: 'Very Poor', range: '1500-2000 ppm', color: '#ff5722', maxPpm: 2000 },
|
||||
{ label: 'Critical', range: '> 2000 ppm', color: '#f44336', maxPpm: Infinity },
|
||||
];
|
||||
|
||||
export function getCO2Color(ppm: number): string {
|
||||
|
||||
12
ui/src/app/interceptors/basic-auth.interceptor.ts
Normal file
12
ui/src/app/interceptors/basic-auth.interceptor.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { HttpHandlerFn, HttpRequest } from '@angular/common/http';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
export function basicAuthInterceptor(req: HttpRequest<unknown>, next: HttpHandlerFn) {
|
||||
if (!req.url.startsWith('/api') && !req.url.includes(environment.apiUrl)) {
|
||||
return next(req);
|
||||
}
|
||||
const { username, password } = environment.api;
|
||||
if (!username && !password) return next(req);
|
||||
const token = btoa(`${username}:${password}`);
|
||||
return next(req.clone({ setHeaders: { Authorization: `Basic ${token}` } }));
|
||||
}
|
||||
@@ -32,12 +32,12 @@ describe('RoomService', () => {
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('should return HTTP response data', () => {
|
||||
const mockData = [ROOM_LAYOUTS[0]];
|
||||
it('should return mapped RoomLayouts for known IDs', () => {
|
||||
const expected = ROOM_LAYOUTS.filter(r => r.id === 'A2' || r.id === 'A3');
|
||||
service.getRooms().subscribe(rooms => {
|
||||
expect(rooms).toEqual(mockData);
|
||||
expect(rooms).toEqual(expected);
|
||||
});
|
||||
httpMock.expectOne(`${environment.apiUrl}/rooms`).flush(mockData);
|
||||
httpMock.expectOne(`${environment.apiUrl}/rooms`).flush(['A2', 'A3']);
|
||||
});
|
||||
|
||||
it('should fall back to ROOM_LAYOUTS mock on HTTP error', done => {
|
||||
@@ -51,40 +51,19 @@ describe('RoomService', () => {
|
||||
});
|
||||
|
||||
describe('getRoomById()', () => {
|
||||
it('should call GET /rooms/:id', () => {
|
||||
service.getRoomById('A1').subscribe();
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/rooms/A1`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush(ROOM_LAYOUTS[0]);
|
||||
});
|
||||
|
||||
it('should return the correct room from HTTP', () => {
|
||||
const mockRoom = ROOM_LAYOUTS.find(r => r.id === 'A1')!;
|
||||
service.getRoomById('A1').subscribe(room => {
|
||||
expect(room).toEqual(mockRoom);
|
||||
});
|
||||
httpMock.expectOne(`${environment.apiUrl}/rooms/A1`).flush(mockRoom);
|
||||
});
|
||||
|
||||
it('should fall back to mock room on HTTP error', done => {
|
||||
it('should return the matching room from local layout', done => {
|
||||
const expected = ROOM_LAYOUTS.find(r => r.id === 'A1');
|
||||
service.getRoomById('A1').subscribe(room => {
|
||||
expect(room).toEqual(expected);
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/rooms/A1`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
|
||||
it('should return undefined for unknown id on HTTP error', done => {
|
||||
it('should return undefined for unknown id', done => {
|
||||
service.getRoomById('UNKNOWN').subscribe(room => {
|
||||
expect(room).toBeUndefined();
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/rooms/UNKNOWN`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, catchError, of } from 'rxjs';
|
||||
import { Observable, catchError, map, of } from 'rxjs';
|
||||
import { ROOM_LAYOUTS, RoomLayout } from '../config/rooms-layout.config';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RoomService {
|
||||
private http = inject(HttpClient);
|
||||
private http = inject(HttpClient);
|
||||
private apiUrl = environment.apiUrl;
|
||||
|
||||
private mockRooms: RoomLayout[] = ROOM_LAYOUTS;
|
||||
|
||||
/**
|
||||
* Returns rooms that exist both in ROOM_LAYOUTS (with layout/sensor metadata)
|
||||
* and in the backend (confirmed to have data). Falls back to all layouts.
|
||||
*/
|
||||
getRooms(): Observable<RoomLayout[]> {
|
||||
return this.http
|
||||
.get<RoomLayout[]>(`${this.apiUrl}/rooms`)
|
||||
.pipe(catchError(() => of(this.mockRooms)));
|
||||
return this.http.get<string[]>(`${this.apiUrl}/rooms`).pipe(
|
||||
map(ids => {
|
||||
const matched = ids
|
||||
.map(id => ROOM_LAYOUTS.find(r => r.id === id))
|
||||
.filter((r): r is RoomLayout => r !== undefined);
|
||||
return matched.length > 0 ? matched : ROOM_LAYOUTS;
|
||||
}),
|
||||
catchError(() => of(ROOM_LAYOUTS)),
|
||||
);
|
||||
}
|
||||
|
||||
getRoomById(id: string): Observable<RoomLayout | undefined> {
|
||||
return this.http
|
||||
.get<RoomLayout>(`${this.apiUrl}/rooms/${id}`)
|
||||
.pipe(catchError(() => of(this.mockRooms.find(r => r.id === id))));
|
||||
return of(ROOM_LAYOUTS.find(r => r.id === id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,143 +27,72 @@ describe('SensorService', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('getLatestReadings()', () => {
|
||||
it('should call GET /sensors/latest', () => {
|
||||
service.getLatestReadings().subscribe();
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/latest`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([]);
|
||||
describe('getLatestReadings(rooms)', () => {
|
||||
it('should call GET /rooms/:id/current for each room', () => {
|
||||
service.getLatestReadings(roomsWithSensors).subscribe();
|
||||
roomsWithSensors.forEach(r =>
|
||||
httpMock.expectOne(`${environment.apiUrl}/rooms/${r.id}/current`).flush({
|
||||
time: new Date().toISOString(), co2_ppm: 600, temp: 21,
|
||||
humidity: 45, window: false, room: r.id,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return HTTP response data', () => {
|
||||
const mockData = [
|
||||
{
|
||||
roomId: 'A1',
|
||||
roomName: 'A1',
|
||||
co2: 700,
|
||||
temperature: 21,
|
||||
humidity: 45,
|
||||
windowState: 'closed' as const,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
];
|
||||
service.getLatestReadings().subscribe(readings => {
|
||||
expect(readings).toEqual(mockData);
|
||||
});
|
||||
httpMock.expectOne(`${environment.apiUrl}/sensors/latest`).flush(mockData);
|
||||
});
|
||||
|
||||
it('should fall back to one reading per sensor room on HTTP error', done => {
|
||||
service.getLatestReadings().subscribe(readings => {
|
||||
expect(readings.length).toBe(roomsWithSensors.length);
|
||||
it('should fall back to mock data on HTTP error', done => {
|
||||
service.getLatestReadings(roomsWithSensors).subscribe(readings => {
|
||||
expect(readings.length).toBe(0);
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/latest`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
roomsWithSensors.forEach(r =>
|
||||
httpMock.expectOne(`${environment.apiUrl}/rooms/${r.id}/current`)
|
||||
.error(new ProgressEvent('network error'))
|
||||
);
|
||||
});
|
||||
|
||||
it('fallback readings should only contain rooms with sensors', done => {
|
||||
service.getLatestReadings().subscribe(readings => {
|
||||
const ids = readings.map(r => r.roomId);
|
||||
const nonSensorRooms = ROOM_LAYOUTS.filter(r => !r.hasSensor).map(r => r.id);
|
||||
nonSensorRooms.forEach(id => expect(ids).not.toContain(id));
|
||||
done();
|
||||
it('should only call rooms passed as argument', () => {
|
||||
const subset = roomsWithSensors.slice(0, 1);
|
||||
service.getLatestReadings(subset).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/rooms/${subset[0].id}/current`).flush({
|
||||
time: new Date().toISOString(), co2_ppm: 500, temp: 20,
|
||||
humidity: 50, window: true, room: subset[0].id,
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/latest`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLatestReadingForRoom()', () => {
|
||||
it('should call GET /sensors/:roomId/latest', () => {
|
||||
it('should call GET /rooms/:roomId/current', () => {
|
||||
service.getLatestReadingForRoom(firstRoomId).subscribe();
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/latest`);
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/current`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush({});
|
||||
req.flush({ time: new Date().toISOString(), co2_ppm: 600, temp: 21, humidity: 45, window: false, room: firstRoomId });
|
||||
});
|
||||
|
||||
it('should fall back to mock reading for given room on HTTP error', done => {
|
||||
it('should return undefined on HTTP error', done => {
|
||||
service.getLatestReadingForRoom(firstRoomId).subscribe(reading => {
|
||||
expect(reading).toBeDefined();
|
||||
expect(reading!.roomId).toBe(firstRoomId);
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/latest`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
|
||||
it('should return undefined for room without mock data on HTTP error', done => {
|
||||
service.getLatestReadingForRoom('UNKNOWN').subscribe(reading => {
|
||||
expect(reading).toBeUndefined();
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/UNKNOWN/latest`)
|
||||
.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/current`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistoryForRoom()', () => {
|
||||
it('should call GET /sensors/:roomId/history', () => {
|
||||
it('should call GET /rooms/:roomId/history', () => {
|
||||
service.getHistoryForRoom(firstRoomId).subscribe();
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`);
|
||||
const req = httpMock.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/history?window=1 day`);
|
||||
expect(req.request.method).toBe('GET');
|
||||
req.flush([]);
|
||||
});
|
||||
|
||||
it('should fall back to 24 entries per room on HTTP error', done => {
|
||||
it('should return empty array on HTTP error', done => {
|
||||
service.getHistoryForRoom(firstRoomId).subscribe(history => {
|
||||
expect(history.length).toBe(24);
|
||||
history.forEach(r => expect(r.roomId).toBe(firstRoomId));
|
||||
expect(history).toEqual([]);
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
|
||||
it('fallback history entries should have required fields', done => {
|
||||
service.getHistoryForRoom(firstRoomId).subscribe(history => {
|
||||
const entry = history[0];
|
||||
expect(entry.co2).toBeGreaterThan(0);
|
||||
expect(entry.temperature).toBeGreaterThan(0);
|
||||
expect(entry.humidity).toBeGreaterThan(0);
|
||||
expect(['open', 'closed']).toContain(entry.windowState);
|
||||
expect(entry.timestamp).toBeInstanceOf(Date);
|
||||
done();
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
|
||||
it('fallback latest reading equals first history entry for same room', done => {
|
||||
let latestReading: any;
|
||||
let historyEntries: any[];
|
||||
|
||||
service.getLatestReadingForRoom(firstRoomId).subscribe(r => {
|
||||
latestReading = r;
|
||||
if (historyEntries) {
|
||||
expect(latestReading).toEqual(historyEntries[0]);
|
||||
done();
|
||||
}
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/latest`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
|
||||
service.getHistoryForRoom(firstRoomId).subscribe(h => {
|
||||
historyEntries = h;
|
||||
if (latestReading) {
|
||||
expect(latestReading).toEqual(historyEntries[0]);
|
||||
done();
|
||||
}
|
||||
});
|
||||
httpMock
|
||||
.expectOne(`${environment.apiUrl}/sensors/${firstRoomId}/history`)
|
||||
.expectOne(`${environment.apiUrl}/rooms/${firstRoomId}/history?window=1 day`)
|
||||
.error(new ProgressEvent('network error'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,35 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable, catchError, of } from 'rxjs';
|
||||
import { tap } from 'rxjs/operators';
|
||||
import { Observable, catchError, forkJoin, map, of } from 'rxjs';
|
||||
import { SensorReading, WindowState } from '../models/sensor-reading.model';
|
||||
import { ROOM_LAYOUTS } from '../config/rooms-layout.config';
|
||||
import { ROOM_LAYOUTS, RoomLayout } from '../config/rooms-layout.config';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
// Raw shape returned by the Go API (InfluxDB row)
|
||||
interface GoReading {
|
||||
time: string;
|
||||
co2_ppm: number;
|
||||
temp: number;
|
||||
humidity: number;
|
||||
window: boolean;
|
||||
room: string;
|
||||
campus?: string;
|
||||
node?: string;
|
||||
}
|
||||
|
||||
function toSensorReading(raw: GoReading): SensorReading {
|
||||
const layout = ROOM_LAYOUTS.find(r => r.id === raw.room);
|
||||
return {
|
||||
roomId: raw.room,
|
||||
roomName: layout?.name ?? raw.room,
|
||||
co2: raw.co2_ppm ?? 0,
|
||||
temperature: raw.temp ?? 0,
|
||||
humidity: raw.humidity ?? 0,
|
||||
windowState: (raw.window ? 'open' : 'closed') as WindowState,
|
||||
timestamp: new Date(raw.time),
|
||||
};
|
||||
}
|
||||
|
||||
const randomWindowState = (): WindowState => Math.random() > 0.5 ? 'open' : 'closed';
|
||||
|
||||
const MOCK_HISTORY: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).flatMap(room =>
|
||||
@@ -23,40 +47,51 @@ const MOCK_HISTORY: SensorReading[] = ROOM_LAYOUTS.filter(r => r.hasSensor).flat
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SensorService {
|
||||
private http = inject(HttpClient);
|
||||
private apiUrl = environment.apiUrl ?? 'http://localhost:8080';
|
||||
private apiUrl = environment.apiUrl;
|
||||
|
||||
getLatestReadings(): Observable<SensorReading[]> {
|
||||
const fallback = ROOM_LAYOUTS.filter(r => r.hasSensor)
|
||||
.map(room => MOCK_HISTORY.find(r => r.roomId === room.id))
|
||||
/** Fetches the latest reading for the given rooms in parallel. */
|
||||
getLatestReadings(rooms: RoomLayout[]): Observable<SensorReading[]> {
|
||||
const fallback = rooms
|
||||
.map(r => MOCK_HISTORY.find(m => m.roomId === r.id))
|
||||
.filter((r): r is SensorReading => r !== undefined);
|
||||
|
||||
return this.http
|
||||
.get<SensorReading[]>(`${this.apiUrl}/sensors/latest`)
|
||||
.pipe(
|
||||
tap({ error: (e) => console.error('[SensorService] getLatestReadings failed', e) }),
|
||||
catchError(() => of(fallback)),
|
||||
);
|
||||
const requests = rooms.map(r =>
|
||||
this.http.get<GoReading>(`${this.apiUrl}/rooms/${r.id}/current`).pipe(
|
||||
map(toSensorReading),
|
||||
catchError(() => of(null)),
|
||||
)
|
||||
);
|
||||
|
||||
return forkJoin(requests).pipe(
|
||||
map(results => results.filter((r): r is SensorReading => r !== null)),
|
||||
catchError((e) => {
|
||||
console.error('[SensorService] getLatestReadings failed', e);
|
||||
return of(fallback);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getLatestReadingForRoom(roomId: string): Observable<SensorReading | undefined> {
|
||||
const fallback = MOCK_HISTORY.find(r => r.roomId === roomId);
|
||||
|
||||
return this.http
|
||||
.get<SensorReading>(`${this.apiUrl}/sensors/${roomId}/latest`)
|
||||
.get<GoReading>(`${this.apiUrl}/rooms/${roomId}/current`)
|
||||
.pipe(
|
||||
tap({ error: (e) => console.error(`[SensorService] getLatestReadingForRoom(${roomId}) failed`, e) }),
|
||||
catchError(() => of(fallback)),
|
||||
map(toSensorReading),
|
||||
catchError((e) => {
|
||||
console.error(`[SensorService] getLatestReadingForRoom(${roomId}) failed`, e);
|
||||
return of(undefined);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
getHistoryForRoom(roomId: string): Observable<SensorReading[]> {
|
||||
const fallback = MOCK_HISTORY.filter(r => r.roomId === roomId);
|
||||
|
||||
return this.http
|
||||
.get<SensorReading[]>(`${this.apiUrl}/sensors/${roomId}/history`)
|
||||
.get<GoReading[]>(`${this.apiUrl}/rooms/${roomId}/history?window=1 day`)
|
||||
.pipe(
|
||||
tap({ error: (e) => console.error(`[SensorService] getHistoryForRoom(${roomId}) failed`, e) }),
|
||||
catchError(() => of(fallback)),
|
||||
map(rows => rows.map(toSensorReading)),
|
||||
catchError((e) => {
|
||||
console.error(`[SensorService] getHistoryForRoom(${roomId}) failed`, e);
|
||||
return of([]);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiUrl: 'https://ui.e.kb28.ch/api',
|
||||
apiUrl: 'https://api.db.e.kb28.ch/api/v1',
|
||||
wsUrl: 'wss://ui.e.kb28.ch/ws',
|
||||
influxUrl: '',
|
||||
logLevel: 'error',
|
||||
@@ -8,4 +8,8 @@ export const environment = {
|
||||
mapIntervalMs: 30_000,
|
||||
detailIntervalMs: 15_000,
|
||||
},
|
||||
api: {
|
||||
username: '__API_USERNAME__',
|
||||
password: '__API_PASSWORD__',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:8080',
|
||||
// Proxied through proxy.conf.json → http://localhost:8080
|
||||
apiUrl: '/api/v1',
|
||||
wsUrl: 'ws://localhost:8080/ws',
|
||||
influxUrl: '',
|
||||
logLevel: 'debug',
|
||||
@@ -8,4 +9,8 @@ export const environment = {
|
||||
mapIntervalMs: 30_000,
|
||||
detailIntervalMs: 15_000,
|
||||
},
|
||||
api: {
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user