Compare commits

..

53 Commits

Author SHA1 Message Date
Darius
197fda9ad9 fix timezone issues 2026-03-06 01:30:05 +01:00
Darius
6e61cff231 steps private => public 2026-03-05 20:18:42 +01:00
Darius
4d7efaf6dc bugfix import 2026-03-05 20:00:46 +01:00
Darius
4569d786d8 status => stats 2026-03-05 19:52:24 +01:00
Darius
963c0ca9a6 status => stats 2026-03-05 19:48:53 +01:00
Darius
2749ebef2b change to better sqlite3 2026-03-05 18:15:22 +01:00
Darius
2dd3805a54 formatting 2026-03-05 01:22:04 +01:00
Darius
0e7abb9113 reconnect on new db 2026-03-05 01:21:33 +01:00
Darius
ba74eb0deb Add Old Database 2026-03-05 01:17:11 +01:00
Darius
4a3a994359 gadgetbridge integration 2026-03-04 16:27:05 +01:00
Darius
908350f696 test 2026-02-10 09:53:52 +01:00
Darius
37d3e2ede4 initial poll 2026-02-10 09:51:06 +01:00
Darius
b938068b61 send last poll instead of polling new 2026-02-10 09:43:04 +01:00
Darius
772a54aca8 update grist every night 2026-02-10 01:03:03 +01:00
Darius
c98304fe8e even cooler now 2026-02-09 20:02:47 +01:00
Darius
ff66061c32 only update with id = today 2026-02-09 00:39:50 +01:00
Darius
92ff251021 privatization 2026-02-09 00:07:53 +01:00
Darius
ff87c3b5ce grist update 2026-02-08 16:31:44 +01:00
Darius
12e7611267 grist updates 2026-02-08 16:31:12 +01:00
Darius
b7daaab9cf change from poll to update from hooks 2026-02-08 14:55:54 +01:00
Darius
c7faa4fc0a logInfo => logError 2026-02-07 21:16:15 +01:00
Darius
9a9b99c84c fix errors 2026-02-07 00:47:08 +01:00
Darius
d5b178fabe fix today being berlin timezone 2026-02-07 00:37:45 +01:00
Darius
bef511ea72 update schema 2026-02-06 22:26:45 +01:00
Darius
26256276d9 schema update 2026-02-06 21:43:53 +01:00
Darius
f027818046 update deps 2026-02-06 21:38:27 +01:00
Darius
008eda6f71 misse a little oopsie 2026-02-06 21:30:12 +01:00
Darius
8948470baa update for partial polling, add grist 2026-02-06 21:28:48 +01:00
Darius
d1d0ff55a5 fix grist service 2026-02-06 19:31:36 +01:00
Darius
e1ef661a7a formatting and grist 2026-02-06 18:59:34 +01:00
Darius
a8280ce27f formatting 2026-02-06 15:43:14 +01:00
Darius
350d7dee3e retype smth 2026-02-06 11:22:05 +01:00
Darius
ef7752c680 proper reset for polling 2026-02-06 11:19:07 +01:00
Darius
b9a494a87c websocket stuff 2026-02-06 11:15:11 +01:00
Darius
5866216e36 sse => ws 2026-02-06 10:08:36 +01:00
Darius
18708add17 Generic Commit; Most likely a fix or small feature 2026-02-06 00:15:38 +01:00
Darius
8670a9ab71 maybe this helps? 2026-02-06 00:13:28 +01:00
Darius
0cdeec5eef stringify because otherwise borken? 2026-02-06 00:03:55 +01:00
Darius
57bbae2919 add cors handling 2026-02-06 00:00:31 +01:00
Darius
34fee498eb add logging 2026-02-05 23:54:10 +01:00
Darius
7ef755f120 proper SSE handling 2026-02-05 23:51:17 +01:00
Darius
40c8f144b5 dont stringify more than needed 2026-02-05 23:48:32 +01:00
Darius
419ec65cc6 remove song end poll 2026-02-05 23:41:45 +01:00
Darius
105fb3aecb log 2026-02-05 23:34:53 +01:00
Darius
07719bbc1f add seconds to standing/sitting time 2026-02-05 22:53:56 +01:00
Darius
7b6a056141 dont send empty updates 2026-02-05 22:45:14 +01:00
Darius
582c82d66f implement sse 2026-02-05 22:40:50 +01:00
Darius
6e1c5e4e67 change route 2026-02-05 20:05:06 +01:00
Darius
0043b75c95 add better sse handling 2026-02-05 04:34:14 +01:00
Darius
4db61998bd hide event register from schema 2026-02-05 01:10:30 +01:00
Darius
7fd3e4b9d3 fix schema and add nullable 2026-02-05 01:06:51 +01:00
Darius
6b5b7a037d update deps 2026-02-05 01:00:38 +01:00
Darius
fd7a80f525 add all needed routes and stuff 2026-02-05 00:53:23 +01:00
22 changed files with 3780 additions and 2367 deletions

View File

@@ -1,12 +1 @@
API_KEY=apiKey
PORT=
ENV_DEV=true
TIDAL_HOST=http://localhost
TIDAL_PORT=47836
HA_API_URL=http://homeassistant.com/api/states/
HA_API_TOKEN=Nina hätte hier jetzt ihr Token ausversehen stehen hihi
HA_ROOMTEMP_SENSOR_IDS=entityId(,separated)
HA_STANDING_WEBHOOK=webhookId
HA_DESK_SENSOR_BINARY=entityId
look into config.ts

3
.gitignore vendored
View File

@@ -25,3 +25,6 @@ yarn-error.log*
# Environment
.env
.env.local
# Gadgetbridge DB
/src/gadgetbridge/db/*

4482
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,31 +1,40 @@
{
"name": "@dpu/api",
"version": "1.0.0",
"description": "",
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts"
},
"keywords": [],
"author": "Darius",
"license": "",
"type": "module",
"dependencies": {
"@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git",
"@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.3",
"dotenv": "^17.2.2",
"fastify": "^5.6.2",
"fastify-axios": "^1.3.0",
"fastify-type-provider-zod": "^6.1.0",
"swagger-themes": "^1.4.3",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/node": "^24.10.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
"name": "@dpu/api",
"version": "1.0.0",
"description": "",
"scripts": {
"clean": "rimraf dist",
"build": "npm run clean && tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"devdbg": "tsx watch --inspect-brk src/index.ts"
},
"keywords": [],
"author": "Darius",
"license": "",
"type": "module",
"dependencies": {
"@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git",
"@fastify/cors": "^11.2.0",
"@fastify/swagger": "^9.6.1",
"@fastify/swagger-ui": "^5.2.3",
"@fastify/websocket": "^11.2.0",
"better-sqlite3": "^12.6.2",
"dotenv": "^17.2.2",
"fastify": "^5.6.2",
"fastify-axios": "^1.3.0",
"fastify-type-provider-zod": "^6.1.0",
"luxon": "^3.7.2",
"swagger-themes": "^1.4.3",
"zod": "^4.1.12"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/luxon": "^3.7.1",
"@types/node": "^24.10.1",
"@types/ws": "^8.18.1",
"openapi-types": "^12.1.3",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
}
}

View File

@@ -3,22 +3,36 @@ import dotenv from "dotenv";
dotenv.config();
export const Config = {
api_key: process.env.API_KEY,
port: process.env.PORT || "8080",
env_dev: process.env.ENV_DEV || false,
api_key: process.env.API_KEY,
port: process.env.PORT || "8080",
tidal: {
host: process.env.TIDAL_HOST || "",
port: process.env.TIDAL_PORT || "",
},
timezone: process.env.TIMEZONE || "Europe/Berlin",
homeassistant: {
api_url: process.env.HA_API_URL || "",
api_token: process.env.HA_API_TOKEN || "",
gadgetbridge: {
db_path:
process.env.GADGETBRIDGE_DB_PATH || "src/gadgetbridge/db/Gadgetbridge.db",
old_db_path:
process.env.GADGETBRIDGE_OLD_DB_PATH || "src/gadgetbridge/db/OldSteps.db",
},
id_desk_sensor_binary: process.env.HA_DESK_SENSOR_BINARY || "",
id_room_sensors: process.env.HA_ROOMTEMP_SENSOR_IDS?.split(",") || [],
grist: {
api_url: process.env.GRIST_API_URL || "",
api_token: process.env.GRIST_API_TOKEN || "",
id_webhook_stand: process.env.HA_STANDING_WEBHOOK || "",
},
table_personal_goals_path: process.env.GRIST_TPG_PATH || "",
},
homeassistant: {
api_url: process.env.HA_API_URL || "",
api_token: process.env.HA_API_TOKEN || "",
id_sensor_desk_binary: process.env.HA_SENSOR_DESK_BINARY || "",
id_sensor_roomtemp: process.env.HA_SENSOR_ROOMTEMP || "",
id_webhook_stand: process.env.HA_STANDING_WEBHOOK || "",
},
tidal: {
address: process.env.TIDAL_ADDRESS || "",
},
} as const;

View File

@@ -0,0 +1,56 @@
import { watchFile } from "node:fs";
import type { StepRow } from "@dpu/shared";
import Database from "better-sqlite3";
export class GadgetbridgeClient {
private db: Database.Database;
private readonly dbPath: string;
private readonly oldDbPath: string;
constructor(dbPath: string, oldDbPath: string) {
this.dbPath = dbPath;
this.oldDbPath = oldDbPath;
this.db = this.openDb();
watchFile(dbPath, { interval: 20 * 60 * 1000 }, () => {
try {
this.db.close();
} catch {}
this.db = this.openDb();
});
}
private openDb(): Database.Database {
const db = new Database(this.dbPath, { readonly: true });
db.exec(`ATTACH DATABASE '${this.oldDbPath}' AS old`);
return db;
}
getStepsPerDay(fromTimestamp: number, toTimestamp: number, utcOffset: string): StepRow[] {
const stmt = this.db.prepare(`
SELECT date, SUM(steps) AS steps
FROM (
SELECT
DATE(TIMESTAMP, 'unixepoch', ?) AS date,
STEPS AS steps
FROM HUAMI_EXTENDED_ACTIVITY_SAMPLE
WHERE TIMESTAMP >= ? AND TIMESTAMP < ?
UNION ALL
SELECT date, steps
FROM old.steps
WHERE date >= DATE(?, 'unixepoch', ?)
AND date < DATE(?, 'unixepoch', ?)
)
GROUP BY date
ORDER BY date
`);
return stmt.all(
utcOffset,
fromTimestamp,
toTimestamp,
fromTimestamp,
utcOffset,
toTimestamp,
utcOffset,
) as StepRow[];
}
}

View File

@@ -0,0 +1,47 @@
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { GadgetbridgeService } from "./service.js";
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
export async function publicGadgetbridgeRoutes(
fastify: FastifyInstance,
{ gadgetbridgeService }: { gadgetbridgeService: GadgetbridgeService },
) {
fastify.get(
"/gadgetbridge/steps",
{
schema: {
description: "Get steps per day for a timespan",
tags: ["gadgetbridge"],
querystring: z.object({
from: z.string().regex(dateRegex, "Must be YYYY-MM-DD"),
to: z.string().regex(dateRegex, "Must be YYYY-MM-DD"),
}),
response: {
200: z.array(
z.object({
date: z.string(),
steps: z.number(),
}),
),
418: z.object({
error: z.string(),
}),
},
},
},
async (request, reply) => {
const { from, to } = request.query as any;
const service_result = gadgetbridgeService.getStepsForTimespan(from, to);
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
}

View File

@@ -0,0 +1,30 @@
import { BaseService, type ServiceResult, type StepRow } from "@dpu/shared";
import { DateTime } from "luxon";
import { Config } from "../config.js";
import type { GadgetbridgeClient } from "./client.js";
export class GadgetbridgeService extends BaseService<GadgetbridgeClient> {
getStepsForTimespan(from: string, to: string): ServiceResult<StepRow[] | string> {
try {
const tz = Config.timezone;
const fromDt = DateTime.fromISO(from, { zone: tz }).startOf("day");
const toDt = DateTime.fromISO(to, { zone: tz }).startOf("day").plus({ days: 1 });
const fromTs = Math.floor(fromDt.toSeconds());
const toTs = Math.floor(toDt.toSeconds());
const offsetMinutes = fromDt.offset;
const sign = offsetMinutes >= 0 ? "+" : "-";
const abs = Math.abs(offsetMinutes);
const utcOffset = `${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
const rows = this.getClient().getStepsPerDay(fromTs, toTs, utcOffset);
return this.getSuccessfulResult(rows);
} catch (error) {
return this.getErrorResult(
"error getting steps from gadgetbridge.",
error,
);
}
}
}

15
src/grist/client.ts Normal file
View File

@@ -0,0 +1,15 @@
import { BaseClient } from "@dpu/shared";
import { printNetworkError } from "@dpu/shared/dist/logger.js";
export class GristClient extends BaseClient {
async get<T>(endpoint: string): Promise<T> {
try {
const response = await this.getAxios().get<T>(`${endpoint}`);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
}

View File

@@ -0,0 +1,39 @@
import type { GristRecord_PersonalGoals } from "@dpu/shared";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import type { GristService } from "./service";
export async function privateGristRoutes(
fastify: FastifyInstance,
{
gristService,
}: {
gristService: GristService;
},
) {
fastify.get(
"/grist/today",
{
schema: {
description: "Get goals for today",
tags: ["grist"],
response: {
200: z.custom<GristRecord_PersonalGoals>(),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await gristService.getToday();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
}

64
src/grist/service.ts Normal file
View File

@@ -0,0 +1,64 @@
import {
BaseService,
type GristRecord_PersonalGoals,
type ServiceResult,
} from "@dpu/shared";
import { DateTime } from "luxon";
import { Config } from "../config.js";
import type { GristClient } from "./client.js";
export class GristService extends BaseService<GristClient> {
async getToday(): Promise<ServiceResult<GristRecord_PersonalGoals | string>> {
try {
const filter_string = encodeURIComponent(
`{ "id": [${this.getTodayAsId()}]}`,
);
const query = `${Config.grist.table_personal_goals_path}?filter=${filter_string}`;
const response = await this.getClient().get<{
records: Record<string, Record<string, unknown>>[];
}>(query);
if (response.records?.length > 0) {
return this.getSuccessfulResult(
this.transformToPersonalGoals(response.records[0].fields),
);
} else {
return this.getErrorResult(
"error getting record from grist. record not found.",
);
}
} catch (error) {
return this.getErrorResult("error getting record from grist.", error);
}
}
getTodayAsId() {
const now = DateTime.now().setZone("Europe/Berlin");
const start = DateTime.fromObject(
{ year: now.year, month: 1, day: 1, hour: 0, minute: 0 },
{ zone: "Europe/Berlin" },
);
const diff = now.diff(start).toMillis();
return Math.ceil(diff / 86400000);
}
transformToPersonalGoals(
obj: Record<string, unknown>,
): GristRecord_PersonalGoals {
return {
went_outside: (obj.went_outside as boolean) ?? false,
standing: (obj.standing as boolean) ?? false,
standing_goal: (obj.standing_goal as number) ?? 0,
steps: (obj.steps as boolean) ?? false,
steps_goal: (obj.steps_goal as number) ?? 0,
pushups: (obj.pushups as boolean) ?? false,
squats: (obj.squats as boolean) ?? false,
leg_raises: (obj.leg_raises as boolean) ?? false,
reps_goal: (obj.reps_goal as number) ?? 0,
stairs: (obj.stairs as boolean) ?? false,
stairs_goal: (obj.stairs_goal as number) ?? 0,
is_workday: (obj.WeekdayHelper as number) === 1,
};
}
}

View File

@@ -2,37 +2,27 @@ import { BaseClient, type HomeAssistantEntity } from "@dpu/shared";
import { printNetworkError } from "@dpu/shared/dist/logger.js";
export class HomeAssistantClient extends BaseClient {
async getEntityStates(entityIds: string[]): Promise<HomeAssistantEntity[]> {
try {
const promises = entityIds.map((id) => this.getEntityState(id));
return await Promise.all(promises);
} catch (error) {
printNetworkError(error);
throw error;
}
}
async getEntityState(entityId: string): Promise<HomeAssistantEntity> {
try {
const response = await this.getAxios().get<HomeAssistantEntity>(
`states/${entityId}`,
);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
async getEntityState(entityId: string): Promise<HomeAssistantEntity> {
try {
const response = await this.getAxios().get<HomeAssistantEntity>(
`states/${entityId}`,
);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
async triggerWebhook(webhookId: string): Promise<unknown> {
try {
const response = await this.getAxios().post<HomeAssistantEntity>(
`webhook/${webhookId}`,
);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
async triggerWebhook(webhookId: string): Promise<unknown> {
try {
const response = await this.getAxios().post<HomeAssistantEntity>(
`webhook/${webhookId}`,
);
return response.data;
} catch (error) {
printNetworkError(error);
throw error;
}
}
}

View File

@@ -0,0 +1,106 @@
import type { HomeAssistantDeskPositionResult } from "@dpu/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import type { HomeAssistantService } from "./service.js";
export async function privateHomeAssistantRoutes(
fastify: FastifyInstance,
{
haService,
verifyAPIKey,
}: {
haService: HomeAssistantService;
verifyAPIKey: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void>;
},
) {
fastify.get(
"/homeassistant/desk/position",
{
schema: {
description: "Get current desk position",
tags: ["homeassistant"],
response: {
200: z.object({
is_standing: z.boolean(),
last_changed: z.string(),
last_changed_seconds: z.number(),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.getDeskPosition();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return haService.convertPosResultToApiAnswer(
service_result.result as HomeAssistantDeskPositionResult,
);
},
);
fastify.post(
"/homeassistant/desk/stand",
{
preHandler: verifyAPIKey,
schema: {
description: "Trigger standing desk automation",
tags: ["homeassistant"],
response: {
200: z.unknown(),
401: z.object({
error: z.literal("Invalid API key"),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.startStandingAutomation();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
fastify.get(
"/homeassistant/temperature",
{
schema: {
description: "Get current room temperature",
tags: ["homeassistant"],
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.getTemperature();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
}

View File

@@ -1,111 +0,0 @@
import type { HomeAssistantDeskPositionResult } from "@dpu/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import type { HomeAssistantService } from "../homeassistant/service.js";
export async function homeAssistantRoutes(
fastify: FastifyInstance,
{
haService,
verifyAPIKey,
}: {
haService: HomeAssistantService;
verifyAPIKey: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void>;
},
) {
fastify.get(
"/homeassistant/desk/position",
{
schema: {
description: "Get current desk position",
tags: ["homeassistant"],
response: {
200: z.object({
position: z.string(),
is_standing: z.boolean(),
last_changed: z.string(),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.getDeskPosition();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
const position_result =
service_result.result as HomeAssistantDeskPositionResult;
return {
position: position_result.as_text(),
is_standing: position_result.as_boolean,
last_changed: position_result.last_changed.toReadable(true),
};
},
);
fastify.post(
"/homeassistant/desk/stand",
{
preHandler: verifyAPIKey,
schema: {
description: "Trigger standing desk automation",
tags: ["homeassistant"],
response: {
200: z.unknown(),
401: z.object({
error: z.literal("Invalid API key"),
}),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.startStandingAutomation();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
fastify.get(
"/homeassistant/temperature",
{
schema: {
description: "Get current room temperature",
tags: ["homeassistant"],
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
},
},
async (_request, reply) => {
const service_result = await haService.getTemperatureText();
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
}

View File

@@ -1,106 +1,93 @@
import {
BaseService,
type HomeAssistantDeskPositionResult,
type HomeAssistantEntity,
type ServiceResult,
type API_HA_DeskPosition,
BaseService,
type HomeAssistantDeskPositionResult,
type HomeAssistantEntity,
type ServiceResult,
} from "@dpu/shared";
import { logWarning } from "@dpu/shared/dist/logger.js";
import { calculateSecondsBetween } from "@dpu/shared/dist/timehelper.js";
import { Config } from "../config.js";
import type { HomeAssistantClient } from "./client.js";
export class HomeAssistantService extends BaseService<HomeAssistantClient> {
async startStandingAutomation(): Promise<ServiceResult<unknown | string>> {
try {
const positionResult = await this.getDeskPosition();
async startStandingAutomation(): Promise<ServiceResult<unknown | string>> {
try {
const positionResult = await this.getDeskPosition();
if (!positionResult.successful) {
throw Error(positionResult.result as string);
}
if (!positionResult.successful) {
throw Error(positionResult.result as string);
}
const position = positionResult.result as HomeAssistantDeskPositionResult;
const position = positionResult.result as HomeAssistantDeskPositionResult;
if (position.as_boolean) {
throw Error(
`desk is already in standing position and has been for ${position.last_changed.toReadable(true)}`,
);
}
if (position.as_boolean) {
throw Error(
`desk is already in standing position and has been for ${position.last_changed.toReadable(true)}`,
);
}
if (position.last_changed.seconds < 300) {
throw Error("desk has moved too recently");
}
if (position.last_changed.seconds < 300) {
throw Error("desk has moved too recently");
}
const result = await this.getClient().triggerWebhook(
Config.homeassistant.id_webhook_stand,
);
const result = await this.getClient().triggerWebhook(
Config.homeassistant.id_webhook_stand,
);
return this.getSuccessfulResult(result);
} catch (error) {
const error_message = `error starting stand automation. ${error instanceof Error ? error.message : error}`;
logWarning(error_message);
return this.getErrorResult(error_message);
}
}
return this.getSuccessfulResult(result);
} catch (error) {
return this.getErrorResult("error starting stand automation.", error);
}
}
async getDeskPosition(): Promise<
ServiceResult<HomeAssistantDeskPositionResult | string>
> {
try {
const raw = await this.getClient().getEntityState(
Config.homeassistant.id_desk_sensor_binary,
);
async getDeskPosition(): Promise<
ServiceResult<HomeAssistantDeskPositionResult | string>
> {
try {
const raw = await this.getClient().getEntityState(
Config.homeassistant.id_sensor_desk_binary,
);
const position = Number(raw.state);
return this.getSuccessfulResult(this.convertHaEntityToPosResult(raw));
} catch (error) {
return this.getErrorResult("error getting desk position.", error);
}
}
const result = {
raw,
as_boolean: position === 1,
as_text: () => {
if (position === 1) return "standing";
if (position === 0) return "sitting";
return "unknown";
},
last_changed: calculateSecondsBetween(
new Date(raw.last_changed).getTime(),
Date.now(),
),
};
convertHaEntityToPosResult(
raw: HomeAssistantEntity,
): HomeAssistantDeskPositionResult {
const position = Number(raw.state);
return this.getSuccessfulResult(result);
} catch (error) {
const error_message = "error getting desk position";
logWarning(error_message, error);
return this.getErrorResult(error_message);
}
}
return {
raw,
as_boolean: position === 1,
last_changed: calculateSecondsBetween(
new Date(raw.last_changed).getTime(),
Date.now(),
),
};
}
async getTemperatureText(): Promise<ServiceResult<string>> {
try {
const entities = await this.getTemperatures();
const values = entities
.map((entity) => parseFloat(entity.state))
.filter((value) => !Number.isNaN(value));
const average =
values.length > 0
? values.reduce((sum, value) => sum + value, 0) / values.length
: 0;
const result = average.toFixed(2);
return this.getSuccessfulResult(result);
} catch (error) {
const error_message = "error getting temperature as text";
logWarning(error_message, error);
return this.getErrorResult(error_message);
}
}
convertPosResultToApiAnswer(
position: HomeAssistantDeskPositionResult,
): API_HA_DeskPosition {
return {
is_standing: position.as_boolean,
last_changed: position.last_changed.toReadable(true),
last_changed_seconds: position.last_changed.seconds,
};
}
private async getTemperatures(): Promise<HomeAssistantEntity[]> {
try {
return await this.getClient().getEntityStates(
Config.homeassistant.id_room_sensors,
);
} catch (error) {
logWarning("error getting temperatures:", error);
return [];
}
}
async getTemperature(): Promise<ServiceResult<string>> {
try {
const entity = await this.getClient().getEntityState(
Config.homeassistant.id_sensor_roomtemp,
);
return this.getSuccessfulResult(entity.state);
} catch (error) {
return this.getErrorResult("error getting temperature.", error);
}
}
}

View File

@@ -0,0 +1,164 @@
import {
type ComponentUpdate,
type GristRecord_PersonalGoals,
type HA_Update,
type HomeAssistantEntity,
StatsComponent,
type TidalGetCurrent,
} from "@dpu/shared";
import type { FastifyInstance, FastifyReply } from "fastify";
import type { FastifyRequest } from "fastify/types/request.js";
import { z } from "zod";
import { Config } from "../config.js";
import type { GristService } from "../grist/service.js";
import type { HomeAssistantService } from "../homeassistant/service.js";
import type { HomepageService } from "./service.js";
export async function privateHomepageRoutes(
fastify: FastifyInstance,
{
hpService,
gristService,
haService,
verifyAPIKey,
}: {
hpService: HomepageService;
gristService: GristService;
haService: HomeAssistantService;
verifyAPIKey: (
request: FastifyRequest,
reply: FastifyReply,
) => Promise<void>;
},
) {
fastify.post(
"/homepage/update/grist",
{
preHandler: verifyAPIKey,
schema: {
description: "Update information for component on dpu stats page",
tags: ["homepage"],
body: z.custom<GristRecord_PersonalGoals>(),
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
hide: true,
},
},
async (request, reply) => {
const updatedRecords = request.body as Record<string, unknown>[];
const record = updatedRecords[0];
if (record.id !== gristService.getTodayAsId()) {
return "no processing done. id not from today.";
}
const service_result = await hpService.updatePartial([
{
component: StatsComponent.GRIST_PERSONAL_GOALS,
data: gristService.transformToPersonalGoals(record),
},
]);
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
fastify.post(
"/homepage/update/homeassistant",
{
preHandler: verifyAPIKey,
schema: {
description: "Update information for component on dpu stats page",
tags: ["homepage"],
body: z.custom<unknown>(),
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
hide: true,
},
},
async (request, reply) => {
const ha_update = request.body as HA_Update;
const ha_entity: HomeAssistantEntity = {
entity_id: ha_update.entity_id,
state: ha_update.state,
attributes: ha_update.attributes,
last_changed: ha_update.timestamp,
};
const updates: ComponentUpdate[] = [];
switch (ha_update.entity_id) {
case Config.homeassistant.id_sensor_desk_binary: {
updates.push({
component: StatsComponent.HA_DESK_POSITION,
data: haService.convertPosResultToApiAnswer(
haService.convertHaEntityToPosResult(ha_entity),
),
});
break;
}
case Config.homeassistant.id_sensor_roomtemp:
updates.push({
component: StatsComponent.HA_TEMP,
data: ha_entity.state,
});
break;
default:
}
const service_result = await hpService.updatePartial(updates);
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
fastify.post(
"/homepage/update/tidal",
{
preHandler: verifyAPIKey,
schema: {
description: "Update information for component on dpu stats page",
tags: ["homepage"],
body: z.custom<TidalGetCurrent>(),
response: {
200: z.string(),
418: z.object({
error: z.string(),
}),
},
hide: true,
},
},
async (request, reply) => {
const update = request.body as TidalGetCurrent;
const service_result = await hpService.updatePartial([
{
component: StatsComponent.TIDAL_CURRENT,
data: update,
},
]);
if (!service_result.successful) {
reply.code(418);
return { error: service_result.result };
}
return service_result.result;
},
);
}

283
src/homepage/service.ts Normal file
View File

@@ -0,0 +1,283 @@
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<null> {
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<void> {
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<void> {
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<ServiceResult<FullInformation | string>> {
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<Iterable<unknown>> {
return await Promise.all([
this._getDesk(),
this._getTemp(),
this._getTidal(),
this._getGristPG(),
]);
}
private async _getDesk(): Promise<API_HA_DeskPosition | null> {
const desk = await this.haService.getDeskPosition();
return desk.successful
? this.haService.convertPosResultToApiAnswer(
desk.result as HomeAssistantDeskPositionResult,
)
: null;
}
private async _getTemp(): Promise<string | null> {
const temp = await this.haService.getTemperature();
return temp.successful ? temp.result : null;
}
private async _getTidal(): Promise<TidalGetCurrent | null> {
const tidal = await this.tidalService.getSong();
return tidal ? (tidal.result as TidalGetCurrent) : null;
}
private async _getGristPG(): Promise<GristRecord_PersonalGoals | null> {
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<ServiceResult<string>> {
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,
),
);
}
}
}

View File

@@ -1,119 +1,251 @@
import { logInfo } from "@dpu/shared/dist/logger.js";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import { WsService } from "@dpu/shared";
import { logError } from "@dpu/shared/dist/logger.js";
import fastifyCors, { type FastifyCorsOptions } from "@fastify/cors";
import fastifySwagger, { type SwaggerOptions } from "@fastify/swagger";
import fastifySwaggerUi, {
type FastifySwaggerUiOptions,
} from "@fastify/swagger-ui";
import fastifyWebsocket from "@fastify/websocket";
import type { FastifyReply, FastifyRequest } from "fastify";
import Fastify from "fastify";
import fastifyAxios from "fastify-axios";
import fastifyAxios, { type FastifyAxiosOptions } from "fastify-axios";
import {
jsonSchemaTransform,
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
jsonSchemaTransform,
serializerCompiler,
validatorCompiler,
type ZodTypeProvider,
} from "fastify-type-provider-zod";
import type { OpenAPIV3 } from "openapi-types";
import { SwaggerTheme, SwaggerThemeNameEnum } from "swagger-themes";
import { z } from "zod";
import { Config } from "./config.js";
import { GadgetbridgeClient } from "./gadgetbridge/client.js";
import { publicGadgetbridgeRoutes } from "./gadgetbridge/public-routes.js";
import { GadgetbridgeService } from "./gadgetbridge/service.js";
import { GristClient } from "./grist/client.js";
import { privateGristRoutes } from "./grist/private-routes.js";
import { GristService } from "./grist/service.js";
import { HomeAssistantClient } from "./homeassistant/client.js";
import { homeAssistantRoutes } from "./homeassistant/routes.js";
import { privateHomeAssistantRoutes } from "./homeassistant/private-routes.js";
import { HomeAssistantService } from "./homeassistant/service.js";
import { privateHomepageRoutes } from "./homepage/private-routes.js";
import { HomepageService } from "./homepage/service.js";
import { TidalClient } from "./tidal/client.js";
import { tidalRoutes } from "./tidal/routes.js";
import { privateTidalRoutes } from "./tidal/private-routes.js";
import { TidalService } from "./tidal/service.js";
import { publicWsRoutes } from "./websocket/public-routes.js";
const fastify = Fastify().withTypeProvider<ZodTypeProvider>();
// Initialize with Zod
const publicServer = Fastify().withTypeProvider<ZodTypeProvider>();
const privateServer = Fastify().withTypeProvider<ZodTypeProvider>();
publicServer.setValidatorCompiler(validatorCompiler);
publicServer.setSerializerCompiler(serializerCompiler);
privateServer.setValidatorCompiler(validatorCompiler);
privateServer.setSerializerCompiler(serializerCompiler);
fastify.setValidatorCompiler(validatorCompiler);
fastify.setSerializerCompiler(serializerCompiler);
// Cors
function getCorsObject(urls: string[]): FastifyCorsOptions {
return {
origin: urls,
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization"],
};
}
await fastify.register(fastifySwagger, {
openapi: {
info: {
title: "DPU API",
description: "API Documentation",
version: "1.0.0",
},
servers: [
{ url: "http://localhost:8080", description: "dev" },
{ url: "https://dpu.dariusbag.dev/api", description: "prod" },
],
},
transform: jsonSchemaTransform,
});
await publicServer.register(
fastifyCors,
getCorsObject(["https://dariusbag.dev"]),
);
await privateServer.register(
fastifyCors,
getCorsObject(["http://localhost:4321"]),
);
// Swagger
function getSwaggerObject(servers: OpenAPIV3.ServerObject[]): SwaggerOptions {
return {
openapi: {
info: {
title: "DPU API",
description: "API Documentation",
version: "1.0.0",
},
servers: servers,
},
transform: jsonSchemaTransform,
};
}
await publicServer.register(
fastifySwagger,
getSwaggerObject([
{ url: "https://dpu.dariusbag.dev/api", description: "prod" },
]),
);
await privateServer.register(
fastifySwagger,
getSwaggerObject([
{ url: "http://localhost:8080", description: "dev" },
{ url: "http://192.168.178.161:20001", description: "prod" },
]),
);
// Swagger UI
const theme = new SwaggerTheme();
const content = theme.getBuffer(SwaggerThemeNameEnum.ONE_DARK);
await fastify.register(fastifySwaggerUi, {
routePrefix: "/docs",
indexPrefix: `${Config.env_dev ? "" : "/api"}`,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
theme: {
css: [{ filename: "theme.css", content: content }],
},
});
await fastify.register(fastifyAxios, {
clients: {
homeassistant: {
baseURL: Config.homeassistant.api_url,
headers: {
Authorization: `Bearer ${Config.homeassistant.api_token}`,
},
},
tidal: {
baseURL: `${Config.tidal.host}:${Config.tidal.port}`,
},
},
});
const haClient = new HomeAssistantClient(fastify.axios.homeassistant);
const haService = new HomeAssistantService(haClient);
const tidalClient = new TidalClient(fastify.axios.tidal);
const tidalService = new TidalService(tidalClient);
async function verifyAPIKey(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const apiKey = request.headers["x-api-key"];
if (!apiKey || apiKey !== Config.api_key) {
logInfo("POST Request with wrong API key received");
return reply.code(401).send({ error: "Invalid API key" });
}
function getSwaggerUiObject(indexPrefix: string): FastifySwaggerUiOptions {
return {
routePrefix: "/docs",
indexPrefix: indexPrefix,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
theme: {
css: [{ filename: "theme.css", content: content }],
},
};
}
const port = parseInt(Config.port, 10);
await publicServer.register(fastifySwaggerUi, getSwaggerUiObject("/api"));
await privateServer.register(fastifySwaggerUi, getSwaggerUiObject(""));
// Register routes
await fastify.register(homeAssistantRoutes, { haService, verifyAPIKey });
await fastify.register(tidalRoutes, { tidalService, verifyAPIKey });
// axios
function getAxiosConfig(): FastifyAxiosOptions {
return {
clients: {
grist: {
baseURL: Config.grist.api_url,
headers: {
Authorization: `Bearer ${Config.grist.api_token}`,
},
},
homeassistant: {
baseURL: Config.homeassistant.api_url,
headers: {
Authorization: `Bearer ${Config.homeassistant.api_token}`,
},
},
tidal: {
baseURL: `${Config.tidal.address}`,
},
},
};
}
fastify.get(
"/ping",
{
schema: {
description: "Health check endpoint",
tags: ["default"],
response: {
200: z.literal("pong"),
},
},
},
async (_request, _reply) => {
return "pong" as const;
},
await privateServer.register(fastifyAxios, getAxiosConfig());
// Websockets
await publicServer.register(fastifyWebsocket);
// Clients and Services
const gadgetbridgeClient = new GadgetbridgeClient(Config.gadgetbridge.db_path, Config.gadgetbridge.old_db_path);
const gadgetbridgeService = new GadgetbridgeService(gadgetbridgeClient);
const gristClient = new GristClient(privateServer.axios.grist);
const gristService = new GristService(gristClient);
const haClient = new HomeAssistantClient(privateServer.axios.homeassistant);
const haService = new HomeAssistantService(haClient);
const tidalClient = new TidalClient(privateServer.axios.tidal);
const tidalService = new TidalService(tidalClient);
const wsService = new WsService();
const hpService = new HomepageService(
gristService,
haService,
tidalService,
wsService,
);
await fastify.ready();
fastify.listen({ port: port, host: "0.0.0.0" }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
hpService.scheduleMidnightGristUpdate();
async function verifyAPIKey(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
const apiKey = request.headers["x-api-key"] ?? request.headers.authorization;
if (!apiKey || apiKey !== Config.api_key) {
logError("POST Request with wrong API key received");
return reply.code(401).send({ error: "Invalid API key" });
}
}
// Register routes
await publicServer.register(publicWsRoutes, { wsService, hpService });
await publicServer.register(publicGadgetbridgeRoutes, {
gadgetbridgeService,
});
await privateServer.register(privateGristRoutes, { gristService });
await privateServer.register(privateHomeAssistantRoutes, {
haService,
verifyAPIKey,
});
await privateServer.register(privateTidalRoutes, {
tidalService,
verifyAPIKey,
});
await privateServer.register(privateHomepageRoutes, {
hpService,
gristService,
haService,
verifyAPIKey,
});
// Ping Routes
publicServer.get(
"/ping",
{
schema: {
description: "Health check endpoint",
tags: ["default"],
response: {
200: z.literal("pong"),
},
},
},
async (_request, _reply) => {
return "pong" as const;
},
);
privateServer.get(
"/ping",
{
schema: {
description: "Health check endpoint",
tags: ["default"],
response: {
200: z.literal("pong"),
},
},
},
async (_request, _reply) => {
return "pong" as const;
},
);
const port = parseInt(Config.port, 10);
const publicPort = port + 1;
await publicServer.ready();
await privateServer.ready();
publicServer.listen({ port: publicPort, host: "0.0.0.0" }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
});
privateServer.listen({ port: port, host: "0.0.0.0" }, (err, address) => {
if (err) {
console.error(err);
process.exit(1);
}
console.log(`Server listening at ${address}`);
});

View File

@@ -1,8 +1,8 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import type { TidalService } from "../tidal/service.js";
import type { TidalService } from "./service.js";
export async function tidalRoutes(
export async function privateTidalRoutes(
fastify: FastifyInstance,
{
tidalService,

View File

@@ -3,7 +3,6 @@ import {
type ServiceResult,
type TidalGetCurrent,
} from "@dpu/shared";
import { logWarning } from "@dpu/shared/dist/logger.js";
import type { TidalClient } from "./client.js";
export class TidalService extends BaseService<TidalClient> {
@@ -11,24 +10,24 @@ export class TidalService extends BaseService<TidalClient> {
const req = await this.getSong();
if (req.successful) {
const song = req.result as TidalGetCurrent;
const status = song.status === "playing" ? "▶️" : "⏸️";
return this.getSuccessfulResult(
`listening to ${song.title} by ${song.artists}. ${status} ${song.current}/${song.duration}. link: ${song.url}`,
);
return this.getSuccessfulResult(this.formatSong(song));
} else {
return this.getErrorResult(req.result as string);
}
}
formatSong(song: TidalGetCurrent): string {
const status = song.status === "playing" ? "▶️" : "⏸️";
return `listening to ${song.title} by ${song.artists}. ${status} ${song.current}/${song.duration}. link: ${song.url}`;
}
async getSong(): Promise<ServiceResult<TidalGetCurrent | string>> {
try {
const response = await this.getClient().get<TidalGetCurrent>("current");
return this.getSuccessfulResult(response);
} catch {
const error_message = "error getting song from tidal";
logWarning(error_message);
return this.getErrorResult(error_message);
} catch (error) {
return this.getErrorResult("error getting song from tidal.", error);
}
}
@@ -39,10 +38,8 @@ export class TidalService extends BaseService<TidalClient> {
return this.getSuccessfulResult(
Math.round(response.volume * 100), // * 100 because it's a decimal and we want a percentage
);
} catch {
const error_message = "error getting volume from tidal";
logWarning(error_message);
return this.getErrorResult(error_message);
} catch (error) {
return this.getErrorResult("error getting volume from tidal.", error);
}
}
@@ -71,9 +68,7 @@ export class TidalService extends BaseService<TidalClient> {
return await this.setVolumeToTidal(clampValue);
}
const error_message = "error parsing volume to set";
logWarning(error_message);
return this.getErrorResult(error_message);
return this.getErrorResult("error parsing volume to set.");
}
async setVolumeToTidal(
@@ -86,10 +81,8 @@ export class TidalService extends BaseService<TidalClient> {
);
return this.getSuccessfulResult(Math.round(volume));
} catch {
const error_message = "error setting volume from tidal";
logWarning(error_message);
return this.getErrorResult(error_message);
} catch (error) {
return this.getErrorResult("error setting volume from tidal.", error);
}
}
}

View File

@@ -0,0 +1,37 @@
import { logInfo, type WsService } from "@dpu/shared";
import type { FastifyInstance } from "fastify";
import type { HomepageService } from "../homepage/service";
export async function publicWsRoutes(
fastify: FastifyInstance,
{
wsService,
hpService,
}: {
wsService: WsService;
hpService: HomepageService;
},
) {
fastify.get(
"/dpu/stats/events",
{
schema: {
description: "Register for WebSocket events",
tags: ["ws"],
hide: true,
},
websocket: true,
},
(socket, _request) => {
wsService.addClient(socket);
socket.send(JSON.stringify({ type: "message", data: "Connected" }));
hpService.sendFullInformationToSocket(socket);
logInfo(`Connection for client established`);
socket.on("close", () => {
wsService.removeClient(socket);
logInfo(`Connection for client closed`);
});
},
);
}

View File

@@ -1,48 +1,48 @@
### Simple GET Request
GET http://localhost:8080/api/ping
GET http://localhost:8080/ping
###
#################### HA #######################
### Simple GET Desk Position
GET http://localhost:8080/api/homeassistant/desk/position
GET http://localhost:8080/homeassistant/desk/position
###
### Simple GET Stand Automation
GET http://localhost:8080/api/homeassistant/desk/stand
GET http://localhost:8080/homeassistant/desk/stand
###
### Simple POST Stand Automation
POST http://localhost:8080/api/homeassistant/desk/stand
POST http://localhost:8080/homeassistant/desk/stand
###
### Simple GET Temps
GET http://localhost:8080/api/homeassistant/temperature
GET http://localhost:8080/homeassistant/temperature
###
#################### TIDAL #######################
### Simple GET Song
GET http://localhost:8080/api/tidal/song
GET http://localhost:8080/tidal/song
###
### Simple GET Song Formatted
GET http://localhost:8080/api/tidal/songFormatted
GET http://localhost:8080/tidal/songFormatted
###
### Simple GET Volume
GET http://localhost:8080/api/tidal/volume
GET http://localhost:8080/tidal/volume
###
### Simple SET Volume
POST http://localhost:8080/api/tidal/volume
POST http://localhost:8080/tidal/volume
###