Initial commit: Angular PV Pulse App

This commit is contained in:
2026-03-10 11:29:29 +01:00
commit 90d8cb78fd
69 changed files with 11966 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { inject, Injectable, signal } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
import { SettingsService } from './settings.service';
const TOKEN_KEY = 'pv_access_token';
interface TokenResponse {
token?: string;
access_token?: string;
}
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly settingsService = inject(SettingsService);
private readonly _token = signal<string | null>(localStorage.getItem(TOKEN_KEY));
/** Aktuell gespeicherter Bearer-Token (reaktiv) */
readonly token = this._token.asReadonly();
/** Gibt den aktuellen Token zurück */
getToken(): string | null {
return this._token();
}
/** Löscht den Token aus Speicher und State */
clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
this._token.set(null);
}
/**
* Sendet Anmeldedaten an den Auth-Endpunkt und speichert den erhaltenen Token.
* Unterstützt sowohl `{ token }` als auch `{ access_token }` als Antwortformat.
*/
async login(username: string, password: string): Promise<string> {
const { apiBaseUrl, authEndpoint } = this.settingsService.settings();
const url = `${apiBaseUrl}${authEndpoint}`;
const response = await firstValueFrom(
this.http.post<TokenResponse>(url, { username, password })
);
const token = response.token ?? response.access_token;
if (!token) {
throw new Error('Kein Token in der Server-Antwort gefunden (erwartet: token oder access_token)');
}
localStorage.setItem(TOKEN_KEY, token);
this._token.set(token);
return token;
}
/**
* Wird beim App-Start aufgerufen.
* Wenn Anmeldedaten gespeichert sind und kein Mock-Modus aktiv ist,
* wird automatisch ein frischer Token abgerufen.
*/
async initialize(): Promise<void> {
const { username, password, useMocks } = this.settingsService.settings();
if (useMocks || !username || !password) {
return;
}
try {
await this.login(username, password);
} catch (err) {
console.warn('[AuthService] Auto-Login beim Start fehlgeschlagen:', err);
}
}
}

View File

@@ -0,0 +1,50 @@
import { inject, Injectable, OnDestroy } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import {
Observable,
timer,
switchMap,
distinctUntilChanged,
shareReplay,
} from 'rxjs';
import { map } from 'rxjs/operators';
import { PvLiveData, PvHistoryData } from '../models/pv-data.model';
import { ApiResponse } from '../models/api-response.model';
import { SettingsService } from './settings.service';
@Injectable({ providedIn: 'root' })
export class LiveDataService implements OnDestroy {
private readonly http = inject(HttpClient);
private readonly settingsService = inject(SettingsService);
/** Liefert Livedaten per Polling alle `pollingIntervalMs` Millisekunden. */
getLiveData(plantId: string): Observable<PvLiveData> {
const intervalMs = this.settingsService.settings().pollingIntervalMs;
return timer(0, intervalMs).pipe(
switchMap(() => {
const { apiBaseUrl, endpointLiveData } = this.settingsService.settings();
const url = apiBaseUrl + this.settingsService.resolveEndpoint(endpointLiveData, plantId);
return this.http
.get<ApiResponse<PvLiveData>>(url)
.pipe(map((res) => res.data));
}),
distinctUntilChanged(
(a, b) => a.timestamp === b.timestamp
),
shareReplay({ bufferSize: 1, refCount: true })
);
}
getHistory(
plantId: string,
resolution: 'hour' | 'day' | 'month' | 'year' = 'day'
): Observable<PvHistoryData> {
const { apiBaseUrl, endpointHistory } = this.settingsService.settings();
const url = apiBaseUrl + this.settingsService.resolveEndpoint(endpointHistory, plantId);
return this.http
.get<ApiResponse<PvHistoryData>>(url, { params: { resolution } })
.pipe(map((res) => res.data));
}
ngOnDestroy(): void {}
}

View File

@@ -0,0 +1,27 @@
import { inject, Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PvPlant } from '../models/pv-plant.model';
import { ApiResponse } from '../models/api-response.model';
import { SettingsService } from './settings.service';
@Injectable({ providedIn: 'root' })
export class PvPlantService {
private readonly http = inject(HttpClient);
private readonly settingsService = inject(SettingsService);
getAll(): Observable<PvPlant[]> {
const { apiBaseUrl, endpointPlants } = this.settingsService.settings();
return this.http
.get<ApiResponse<PvPlant[]>>(apiBaseUrl + endpointPlants)
.pipe(map((res) => res.data));
}
getById(id: string): Observable<PvPlant> {
const { apiBaseUrl, endpointPlants } = this.settingsService.settings();
return this.http
.get<ApiResponse<PvPlant>>(`${apiBaseUrl}${endpointPlants}/${encodeURIComponent(id)}`)
.pipe(map((res) => res.data));
}
}

View File

@@ -0,0 +1,78 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '../../../environments/environment';
import { AppSettings } from '../models/settings.model';
const STORAGE_KEY = 'pv_app_settings';
@Injectable({ providedIn: 'root' })
export class SettingsService {
private readonly _settings = signal<AppSettings>(this.loadFromStorage());
private _pinVerified = false;
/** Aktuell aktive Einstellungen (reaktiv) */
readonly settings = this._settings.asReadonly();
private defaultSettings(): AppSettings {
return {
apiBaseUrl: environment.apiBaseUrl,
useMocks: environment.useMocks,
pollingIntervalMs: environment.pollingIntervalMs,
authEndpoint: '/auth/token',
username: '',
password: '',
endpointPlants: '/plants',
endpointLiveData: '/plants/{id}/live',
endpointHistory: '/plants/{id}/history',
};
}
private loadFromStorage(): AppSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
return { ...this.defaultSettings(), ...(JSON.parse(raw) as Partial<AppSettings>) };
}
} catch {
// localStorage nicht verfügbar oder ungültiges JSON Standardwerte verwenden
}
return this.defaultSettings();
}
/** Speichert Einstellungen in localStorage und aktualisiert das reaktive Signal */
save(settings: AppSettings): void {
this._settings.set({ ...settings });
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
/** Löst den {id}-Platzhalter in einem Endpunkt-Template auf */
resolveEndpoint(template: string, id: string): string {
return template.replace('{id}', encodeURIComponent(id));
}
/** Prüft ob der eingegebene PIN dem heutigen Datum entspricht (DDMM) */
isValidPin(pin: string): boolean {
const now = new Date();
const day = String(now.getDate()).padStart(2, '0');
const month = String(now.getMonth() + 1).padStart(2, '0');
return pin === `${day}${month}`;
}
/** Verifiziert den PIN und merkt sich das Ergebnis für den Route-Guard */
verifyPin(pin: string): boolean {
if (this.isValidPin(pin)) {
this._pinVerified = true;
return true;
}
return false;
}
/** Gibt zurück ob der PIN in dieser Session bereits erfolgreich eingegeben wurde */
get isPinVerified(): boolean {
return this._pinVerified;
}
/** Setzt die PIN-Verifizierung zurück (z.B. nach Logout) */
resetPinVerification(): void {
this._pinVerified = false;
}
}