import { type API_HA_DeskPosition, BaseService, type ComponentUpdate, createComponentUpdate, type FullInformation, type GristRecord_PersonalGoals, type HomeAssistantDeskPositionResult, type ServiceResult, StatsComponent, type TidalGetCurrent, type WsService, } from "@dpu/shared"; import { logInfo, logWarning } from "@dpu/shared/dist/logger.js"; import { DateTime } from "luxon"; import type { WebSocket } from "ws"; import type { GristService } from "../grist/service"; import type { HomeAssistantService } from "../homeassistant/service"; import type { TidalService } from "../tidal/service"; export class HomepageService extends BaseService { private gristService: GristService; private haService: HomeAssistantService; private tidalService: TidalService; private wsService: WsService; private lastPoll: FullInformation = { ha_desk_position: null, ha_temp: null, tidal_current: null, grist_personal_goals: null, }; constructor( gristService: GristService, haService: HomeAssistantService, tidalService: TidalService, wsService: WsService, ) { super(null); this.gristService = gristService; this.haService = haService; this.tidalService = tidalService; this.wsService = wsService; } scheduleMidnightGristUpdate(): void { const now = DateTime.now().setZone("Europe/Berlin"); const nextMidnight = now.plus({ days: 1 }).startOf("day"); setTimeout(async () => { await this.fetchAndBroadcastGrist(); this.scheduleMidnightGristUpdate(); }, nextMidnight.diff(now).toMillis()); } private async fetchAndBroadcastGrist(): Promise { try { if (this.wsService.getClientCount() > 0) { const updates: ComponentUpdate[] = []; this.updateGristPG(await this._getGristPG(), updates); if (updates.length > 0) { this.wsService.broadcast({ type: "update", data: updates, }); } } } catch (error) { logWarning("Error fetching midnight Grist update", error); } } async sendFullInformationToSocket(socket: WebSocket): Promise { try { const [desk, temp, tidal, personal_goals] = await this._getAll(); const updates: ComponentUpdate[] = [ createComponentUpdate(StatsComponent.HA_DESK_POSITION, desk), createComponentUpdate(StatsComponent.HA_TEMP, temp), createComponentUpdate(StatsComponent.TIDAL_CURRENT, tidal), createComponentUpdate( StatsComponent.GRIST_PERSONAL_GOALS, personal_goals, ), ]; socket.send( JSON.stringify({ type: "update", data: updates, }), ); } catch (error) { logWarning("error getting all information for socket update.", error); } } async getFullInformation(): Promise> { try { const [desk, temp, tidal, personal_goals] = await this._getAll(); return this.getSuccessfulResult( this.updateFull({ ha_desk_position: desk as API_HA_DeskPosition, ha_temp: temp as string, tidal_current: tidal as TidalGetCurrent, grist_personal_goals: personal_goals as GristRecord_PersonalGoals, }), ); } catch (error) { return this.getErrorResult("error getting all information.", error); } } private async _getAll(): Promise> { return await Promise.all([ this._getDesk(), this._getTemp(), this._getTidal(), this._getGristPG(), ]); } private async _getDesk(): Promise { const desk = await this.haService.getDeskPosition(); return desk.successful ? this.haService.convertPosResultToApiAnswer( desk.result as HomeAssistantDeskPositionResult, ) : null; } private async _getTemp(): Promise { const temp = await this.haService.getTemperature(); return temp.successful ? temp.result : null; } private async _getTidal(): Promise { const tidal = await this.tidalService.getSong(); return tidal ? (tidal.result as TidalGetCurrent) : null; } private async _getGristPG(): Promise { const personal_goals = await this.gristService.getToday(); return personal_goals.successful ? (personal_goals.result as GristRecord_PersonalGoals) : null; } updateFull(newPoll: FullInformation): FullInformation { const updates: ComponentUpdate[] = []; this.updateHaDesk(newPoll.ha_desk_position, updates); this.updateHaTemp(newPoll.ha_temp, updates); this.updateTidal(newPoll.tidal_current, updates); this.updateGristPG(newPoll.grist_personal_goals, updates); if (updates.length > 0) { logInfo("Updating clients"); this.wsService.broadcast({ type: "update", data: updates, }); } return newPoll; } async updatePartial( components: ComponentUpdate[], ): Promise> { const updates: ComponentUpdate[] = []; for (const component of components) { switch (component.component) { case StatsComponent.HA_DESK_POSITION: { this.updateHaDesk( (component.data as API_HA_DeskPosition) ?? (await this._getDesk()), updates, ); break; } case StatsComponent.HA_TEMP: this.updateHaTemp( (component.data as string) ?? (await this._getTemp()), updates, ); break; case StatsComponent.TIDAL_CURRENT: this.updateTidal( (component.data as TidalGetCurrent) ?? (await this._getTidal()), updates, ); break; case StatsComponent.GRIST_PERSONAL_GOALS: this.updateGristPG( (component.data as GristRecord_PersonalGoals) ?? (await this._getGristPG()), updates, ); break; default: } } this.wsService.broadcast({ type: "update", data: updates, }); return this.getSuccessfulResult("update broadcasted"); } updateHaDesk( new_ha_desk_position: API_HA_DeskPosition | null, updates: ComponentUpdate[], ): void { if ( this.lastPoll.ha_desk_position?.is_standing !== new_ha_desk_position?.is_standing ) { this.lastPoll.ha_desk_position = new_ha_desk_position; updates.push( createComponentUpdate( StatsComponent.HA_DESK_POSITION, new_ha_desk_position, ), ); } } updateHaTemp(new_ha_temp: string | null, updates: ComponentUpdate[]): void { if (this.lastPoll.ha_temp !== new_ha_temp) { this.lastPoll.ha_temp = new_ha_temp; updates.push(createComponentUpdate(StatsComponent.HA_TEMP, new_ha_temp)); } } updateTidal( new_tidal_current: TidalGetCurrent | null, updates: ComponentUpdate[], ): void { if ( this.lastPoll.tidal_current?.title !== new_tidal_current?.title || this.lastPoll.tidal_current?.artists !== new_tidal_current?.artists || this.lastPoll.tidal_current?.status !== new_tidal_current?.status || this.lastPoll.tidal_current?.volume !== new_tidal_current?.volume ) { this.lastPoll.tidal_current = new_tidal_current; updates.push( createComponentUpdate(StatsComponent.TIDAL_CURRENT, new_tidal_current), ); } } updateGristPG( new_grist_personal_goals: GristRecord_PersonalGoals | null, updates: ComponentUpdate[], ): void { if ( this.lastPoll.grist_personal_goals?.went_outside !== new_grist_personal_goals?.went_outside || this.lastPoll.grist_personal_goals?.standing !== new_grist_personal_goals?.standing || this.lastPoll.grist_personal_goals?.steps !== new_grist_personal_goals?.steps || this.lastPoll.grist_personal_goals?.pushups !== new_grist_personal_goals?.pushups || this.lastPoll.grist_personal_goals?.squats !== new_grist_personal_goals?.squats || this.lastPoll.grist_personal_goals?.leg_raises !== new_grist_personal_goals?.leg_raises || this.lastPoll.grist_personal_goals?.stairs !== new_grist_personal_goals?.stairs ) { this.lastPoll.grist_personal_goals = new_grist_personal_goals; updates.push( createComponentUpdate( StatsComponent.GRIST_PERSONAL_GOALS, new_grist_personal_goals, ), ); } } }