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:
khalil-bot
2026-05-19 14:11:46 +02:00
parent a2f258763e
commit a465cba225
16 changed files with 177 additions and 182 deletions

View File

@@ -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
View File

@@ -0,0 +1,8 @@
{
"/api": {
"target": "https://api.db.e.kb28.ch",
"secure": true,
"changeOrigin": true,
"logLevel": "info"
}
}

View File

@@ -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])),
],
};

View File

@@ -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>

View File

@@ -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]) => {

View File

@@ -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>
}

View File

@@ -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', () => {

View File

@@ -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) => {

View File

@@ -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 {

View 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}` } }));
}

View File

@@ -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'));
});
});
});

View File

@@ -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));
}
}

View File

@@ -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'));
});
});

View File

@@ -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([]);
}),
);
}
}

View File

@@ -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__',
},
};

View File

@@ -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: '',
},
};