Compare commits
44 Commits
07719bbc1f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
197fda9ad9 | ||
|
|
6e61cff231 | ||
|
|
4d7efaf6dc | ||
|
|
4569d786d8 | ||
|
|
963c0ca9a6 | ||
|
|
2749ebef2b | ||
|
|
2dd3805a54 | ||
|
|
0e7abb9113 | ||
|
|
ba74eb0deb | ||
|
|
4a3a994359 | ||
|
|
908350f696 | ||
|
|
37d3e2ede4 | ||
|
|
b938068b61 | ||
|
|
772a54aca8 | ||
|
|
c98304fe8e | ||
|
|
ff66061c32 | ||
|
|
92ff251021 | ||
|
|
ff87c3b5ce | ||
|
|
12e7611267 | ||
|
|
b7daaab9cf | ||
|
|
c7faa4fc0a | ||
|
|
9a9b99c84c | ||
|
|
d5b178fabe | ||
|
|
bef511ea72 | ||
|
|
26256276d9 | ||
|
|
f027818046 | ||
|
|
008eda6f71 | ||
|
|
8948470baa | ||
|
|
d1d0ff55a5 | ||
|
|
e1ef661a7a | ||
|
|
a8280ce27f | ||
|
|
350d7dee3e | ||
|
|
ef7752c680 | ||
|
|
b9a494a87c | ||
|
|
5866216e36 | ||
|
|
18708add17 | ||
|
|
8670a9ab71 | ||
|
|
0cdeec5eef | ||
|
|
57bbae2919 | ||
|
|
34fee498eb | ||
|
|
7ef755f120 | ||
|
|
40c8f144b5 | ||
|
|
419ec65cc6 | ||
|
|
105fb3aecb |
13
.env.example
13
.env.example
@@ -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
3
.gitignore
vendored
@@ -25,3 +25,6 @@ yarn-error.log*
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Gadgetbridge DB
|
||||
/src/gadgetbridge/db/*
|
||||
|
||||
968
package-lock.json
generated
968
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -6,7 +6,8 @@
|
||||
"clean": "rimraf dist",
|
||||
"build": "npm run clean && tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"devdbg": "tsx watch --inspect-brk src/index.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "Darius",
|
||||
@@ -14,18 +15,25 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@dpu/shared": "git+https://git.dariusbag.dev/DarDarBinks/dpu-shared.git",
|
||||
"@fastify/sse": "^0.4.0",
|
||||
"@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"
|
||||
}
|
||||
|
||||
@@ -5,20 +5,34 @@ dotenv.config();
|
||||
export const Config = {
|
||||
api_key: process.env.API_KEY,
|
||||
port: process.env.PORT || "8080",
|
||||
env_dev: process.env.ENV_DEV || false,
|
||||
|
||||
tidal: {
|
||||
host: process.env.TIDAL_HOST || "",
|
||||
port: process.env.TIDAL_PORT || "",
|
||||
timezone: process.env.TIMEZONE || "Europe/Berlin",
|
||||
|
||||
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",
|
||||
},
|
||||
|
||||
grist: {
|
||||
api_url: process.env.GRIST_API_URL || "",
|
||||
api_token: process.env.GRIST_API_TOKEN || "",
|
||||
|
||||
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_desk_sensor_binary: process.env.HA_DESK_SENSOR_BINARY || "",
|
||||
id_room_sensors: process.env.HA_ROOMTEMP_SENSOR_IDS?.split(",") || [],
|
||||
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;
|
||||
|
||||
56
src/gadgetbridge/client.ts
Normal file
56
src/gadgetbridge/client.ts
Normal 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[];
|
||||
}
|
||||
}
|
||||
47
src/gadgetbridge/public-routes.ts
Normal file
47
src/gadgetbridge/public-routes.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
30
src/gadgetbridge/service.ts
Normal file
30
src/gadgetbridge/service.ts
Normal 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
15
src/grist/client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/grist/private-routes.ts
Normal file
39
src/grist/private-routes.ts
Normal 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
64
src/grist/service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,6 @@ 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>(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { HomeAssistantDeskPositionResult } from "@dpu/shared";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { HomeAssistantService } from "../homeassistant/service.js";
|
||||
import type { HomeAssistantService } from "./service.js";
|
||||
|
||||
export async function homeAssistantRoutes(
|
||||
export async function privateHomeAssistantRoutes(
|
||||
fastify: FastifyInstance,
|
||||
{
|
||||
haService,
|
||||
@@ -24,7 +24,6 @@ export async function homeAssistantRoutes(
|
||||
tags: ["homeassistant"],
|
||||
response: {
|
||||
200: z.object({
|
||||
position: z.string(),
|
||||
is_standing: z.boolean(),
|
||||
last_changed: z.string(),
|
||||
last_changed_seconds: z.number(),
|
||||
@@ -94,7 +93,7 @@ export async function homeAssistantRoutes(
|
||||
},
|
||||
},
|
||||
async (_request, reply) => {
|
||||
const service_result = await haService.getTemperatureText();
|
||||
const service_result = await haService.getTemperature();
|
||||
|
||||
if (!service_result.successful) {
|
||||
reply.code(418);
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
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";
|
||||
@@ -37,9 +36,7 @@ export class HomeAssistantService extends BaseService<HomeAssistantClient> {
|
||||
|
||||
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.getErrorResult("error starting stand automation.", error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,71 +45,49 @@ export class HomeAssistantService extends BaseService<HomeAssistantClient> {
|
||||
> {
|
||||
try {
|
||||
const raw = await this.getClient().getEntityState(
|
||||
Config.homeassistant.id_desk_sensor_binary,
|
||||
Config.homeassistant.id_sensor_desk_binary,
|
||||
);
|
||||
|
||||
return this.getSuccessfulResult(this.convertHaEntityToPosResult(raw));
|
||||
} catch (error) {
|
||||
return this.getErrorResult("error getting desk position.", error);
|
||||
}
|
||||
}
|
||||
|
||||
convertHaEntityToPosResult(
|
||||
raw: HomeAssistantEntity,
|
||||
): HomeAssistantDeskPositionResult {
|
||||
const position = Number(raw.state);
|
||||
|
||||
const result = {
|
||||
return {
|
||||
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(),
|
||||
),
|
||||
};
|
||||
|
||||
return this.getSuccessfulResult(result);
|
||||
} catch (error) {
|
||||
const error_message = "error getting desk position";
|
||||
logWarning(error_message, error);
|
||||
return this.getErrorResult(error_message);
|
||||
}
|
||||
}
|
||||
|
||||
convertPosResultToApiAnswer(
|
||||
position: HomeAssistantDeskPositionResult,
|
||||
): API_HA_DeskPosition {
|
||||
return {
|
||||
position: position.as_text(),
|
||||
is_standing: position.as_boolean,
|
||||
last_changed: position.last_changed.toReadable(true),
|
||||
last_changed_seconds: position.last_changed.seconds,
|
||||
};
|
||||
}
|
||||
|
||||
async getTemperatureText(): Promise<ServiceResult<string>> {
|
||||
async getTemperature(): 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);
|
||||
}
|
||||
}
|
||||
|
||||
private async getTemperatures(): Promise<HomeAssistantEntity[]> {
|
||||
try {
|
||||
return await this.getClient().getEntityStates(
|
||||
Config.homeassistant.id_room_sensors,
|
||||
const entity = await this.getClient().getEntityState(
|
||||
Config.homeassistant.id_sensor_roomtemp,
|
||||
);
|
||||
|
||||
return this.getSuccessfulResult(entity.state);
|
||||
} catch (error) {
|
||||
logWarning("error getting temperatures:", error);
|
||||
return [];
|
||||
return this.getErrorResult("error getting temperature.", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
164
src/homepage/private-routes.ts
Normal file
164
src/homepage/private-routes.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { type HomepageService } from "./service.js";
|
||||
import { type API_HA_DeskPosition, type TidalGetCurrent } from "@dpu/shared";
|
||||
|
||||
export async function homepageRoutes(
|
||||
fastify: FastifyInstance,
|
||||
{
|
||||
hpService,
|
||||
}: {
|
||||
hpService: HomepageService;
|
||||
},
|
||||
) {
|
||||
fastify.get(
|
||||
"/homepage/status",
|
||||
{
|
||||
schema: {
|
||||
description: "Get all current information for dpu status page",
|
||||
tags: ["homepage"],
|
||||
response: {
|
||||
200: z.object({
|
||||
ha_desk_position: z.custom<API_HA_DeskPosition>().nullable(),
|
||||
ha_temp: z.string().nullable(),
|
||||
tidal_current: z.custom<TidalGetCurrent>().nullable(),
|
||||
}),
|
||||
418: z.object({
|
||||
error: z.string(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_request, reply) => {
|
||||
const service_result = await hpService.getFullInformation();
|
||||
|
||||
if (!service_result.successful) {
|
||||
reply.code(418);
|
||||
return { error: service_result.result };
|
||||
}
|
||||
|
||||
return service_result.result;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,151 +1,283 @@
|
||||
import {
|
||||
type API_HA_DeskPosition,
|
||||
BaseService,
|
||||
FullInformation,
|
||||
HomeAssistantDeskPositionResult,
|
||||
SseClientChangeEvent,
|
||||
SseService,
|
||||
TidalGetCurrent,
|
||||
type ComponentUpdate,
|
||||
createComponentUpdate,
|
||||
type FullInformation,
|
||||
type GristRecord_PersonalGoals,
|
||||
type HomeAssistantDeskPositionResult,
|
||||
type ServiceResult,
|
||||
StatsComponent,
|
||||
type TidalGetCurrent,
|
||||
type WsService,
|
||||
} from "@dpu/shared";
|
||||
import { logWarning } from "@dpu/shared/dist/logger.js";
|
||||
import { HomeAssistantService } from "../homeassistant/service";
|
||||
import { TidalService } from "../tidal/service";
|
||||
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 sseService: SseService;
|
||||
private pollingInterval: ReturnType<typeof setInterval> | null = null;
|
||||
private songEndTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastPoll: FullInformation | null = null;
|
||||
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,
|
||||
sseService: SseService,
|
||||
wsService: WsService,
|
||||
) {
|
||||
super(null);
|
||||
this.gristService = gristService;
|
||||
this.haService = haService;
|
||||
this.tidalService = tidalService;
|
||||
this.sseService = sseService;
|
||||
this.listenForClientChange();
|
||||
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, song] = await Promise.all([
|
||||
this.haService.getDeskPosition(),
|
||||
this.haService.getTemperatureText(),
|
||||
this.tidalService.getSong(),
|
||||
]);
|
||||
const [desk, temp, tidal, personal_goals] = await this._getAll();
|
||||
|
||||
const result = {
|
||||
ha_desk_position: desk.successful
|
||||
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,
|
||||
ha_temp: temp.successful ? temp.result : null,
|
||||
tidal_current: song ? (song.result as TidalGetCurrent) : null,
|
||||
};
|
||||
|
||||
return this.getSuccessfulResult(this.updateLastPoll(result));
|
||||
} catch {
|
||||
const error_message = "error getting all information";
|
||||
logWarning(error_message);
|
||||
return this.getErrorResult(error_message);
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
updateLastPoll(newPoll: FullInformation) {
|
||||
const updates = [];
|
||||
if (
|
||||
this.lastPoll?.ha_desk_position?.is_standing !==
|
||||
newPoll.ha_desk_position?.is_standing
|
||||
) {
|
||||
updates.push({
|
||||
component: "ha_desk_position",
|
||||
data: newPoll.ha_desk_position,
|
||||
});
|
||||
private async _getTemp(): Promise<string | null> {
|
||||
const temp = await this.haService.getTemperature();
|
||||
return temp.successful ? temp.result : null;
|
||||
}
|
||||
|
||||
if (this.lastPoll?.ha_temp !== newPoll.ha_temp) {
|
||||
updates.push({
|
||||
component: "ha_temp",
|
||||
data: newPoll.ha_temp,
|
||||
});
|
||||
private async _getTidal(): Promise<TidalGetCurrent | null> {
|
||||
const tidal = await this.tidalService.getSong();
|
||||
return tidal ? (tidal.result as TidalGetCurrent) : null;
|
||||
}
|
||||
|
||||
if (
|
||||
this.lastPoll?.tidal_current?.title !== newPoll.tidal_current?.title ||
|
||||
this.lastPoll?.tidal_current?.status !== newPoll.tidal_current?.status
|
||||
) {
|
||||
updates.push({
|
||||
component: "tidal_current",
|
||||
data: newPoll.tidal_current,
|
||||
});
|
||||
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) {
|
||||
this.sseService.notifyClients({
|
||||
logInfo("Updating clients");
|
||||
this.wsService.broadcast({
|
||||
type: "update",
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
this.lastPoll = newPoll;
|
||||
this.scheduleSongEndPoll(newPoll.tidal_current);
|
||||
return newPoll;
|
||||
}
|
||||
|
||||
private scheduleSongEndPoll(tidal: TidalGetCurrent | null): void {
|
||||
this.clearSongEndPoll();
|
||||
|
||||
if (!tidal || tidal.status === "paused") {
|
||||
return;
|
||||
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:
|
||||
}
|
||||
}
|
||||
|
||||
const remainingSeconds = tidal.durationInSeconds - tidal.currentInSeconds;
|
||||
if (remainingSeconds > 0) {
|
||||
this.songEndTimeout = setTimeout(
|
||||
() => {
|
||||
this.songEndTimeout = null;
|
||||
this.getFullInformation();
|
||||
},
|
||||
(remainingSeconds + 1) * 1000,
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private clearSongEndPoll(): void {
|
||||
if (this.songEndTimeout) {
|
||||
clearTimeout(this.songEndTimeout);
|
||||
this.songEndTimeout = null;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
listenForClientChange(): void {
|
||||
this.sseService.onClientChange((clientChange: SseClientChangeEvent) => {
|
||||
if (clientChange.clientCount === 0) {
|
||||
this.stopPolling();
|
||||
} else {
|
||||
if (!this.pollingInterval) {
|
||||
this.startPolling();
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
startPolling(): void {
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.getFullInformation();
|
||||
}, 30000);
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
stopPolling(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval);
|
||||
}
|
||||
this.clearSongEndPoll();
|
||||
}
|
||||
}
|
||||
|
||||
205
src/index.ts
205
src/index.ts
@@ -1,56 +1,103 @@
|
||||
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,
|
||||
} 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 { TidalClient } from "./tidal/client.js";
|
||||
import { TidalService } from "./tidal/service.js";
|
||||
import { tidalRoutes } from "./tidal/routes.js";
|
||||
import { sseRoutes } from "./sse/routes.js";
|
||||
import { SseService } from "@dpu/shared";
|
||||
import { privateHomepageRoutes } from "./homepage/private-routes.js";
|
||||
import { HomepageService } from "./homepage/service.js";
|
||||
import { homepageRoutes } from "./homepage/routes.js";
|
||||
import fastifySSE from "@fastify/sse";
|
||||
import { TidalClient } from "./tidal/client.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, {
|
||||
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: [
|
||||
{ url: "http://localhost:8080", description: "dev" },
|
||||
{ url: "https://dpu.dariusbag.dev/api", description: "prod" },
|
||||
],
|
||||
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, {
|
||||
function getSwaggerUiObject(indexPrefix: string): FastifySwaggerUiOptions {
|
||||
return {
|
||||
routePrefix: "/docs",
|
||||
indexPrefix: `${Config.env_dev ? "" : "/api"}`,
|
||||
indexPrefix: indexPrefix,
|
||||
uiConfig: {
|
||||
docExpansion: "list",
|
||||
deepLinking: false,
|
||||
@@ -58,10 +105,22 @@ await fastify.register(fastifySwaggerUi, {
|
||||
theme: {
|
||||
css: [{ filename: "theme.css", content: content }],
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
await fastify.register(fastifyAxios, {
|
||||
await publicServer.register(fastifySwaggerUi, getSwaggerUiObject("/api"));
|
||||
await privateServer.register(fastifySwaggerUi, getSwaggerUiObject(""));
|
||||
|
||||
// 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: {
|
||||
@@ -69,44 +128,92 @@ await fastify.register(fastifyAxios, {
|
||||
},
|
||||
},
|
||||
tidal: {
|
||||
baseURL: `${Config.tidal.host}:${Config.tidal.port}`,
|
||||
baseURL: `${Config.tidal.address}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
await fastify.register(fastifySSE);
|
||||
await privateServer.register(fastifyAxios, getAxiosConfig());
|
||||
|
||||
const haClient = new HomeAssistantClient(fastify.axios.homeassistant);
|
||||
// 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(fastify.axios.tidal);
|
||||
const tidalClient = new TidalClient(privateServer.axios.tidal);
|
||||
const tidalService = new TidalService(tidalClient);
|
||||
|
||||
const sseService = new SseService();
|
||||
const wsService = new WsService();
|
||||
|
||||
const hpService = new HomepageService(haService, tidalService, sseService);
|
||||
const hpService = new HomepageService(
|
||||
gristService,
|
||||
haService,
|
||||
tidalService,
|
||||
wsService,
|
||||
);
|
||||
|
||||
hpService.scheduleMidnightGristUpdate();
|
||||
|
||||
async function verifyAPIKey(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const apiKey = request.headers["x-api-key"];
|
||||
const apiKey = request.headers["x-api-key"] ?? request.headers.authorization;
|
||||
|
||||
if (!apiKey || apiKey !== Config.api_key) {
|
||||
logInfo("POST Request with wrong API key received");
|
||||
logError("POST Request with wrong API key received");
|
||||
return reply.code(401).send({ error: "Invalid API key" });
|
||||
}
|
||||
}
|
||||
|
||||
const port = parseInt(Config.port, 10);
|
||||
|
||||
// Register routes
|
||||
await fastify.register(homeAssistantRoutes, { haService, verifyAPIKey });
|
||||
await fastify.register(tidalRoutes, { tidalService, verifyAPIKey });
|
||||
await fastify.register(sseRoutes, { sseService });
|
||||
await fastify.register(homepageRoutes, { hpService });
|
||||
await publicServer.register(publicWsRoutes, { wsService, hpService });
|
||||
|
||||
fastify.get(
|
||||
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: {
|
||||
@@ -122,8 +229,20 @@ fastify.get(
|
||||
},
|
||||
);
|
||||
|
||||
await fastify.ready();
|
||||
fastify.listen({ port: port, host: "0.0.0.0" }, (err, address) => {
|
||||
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);
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { logInfo, type SseEvent, type SseService } from "@dpu/shared";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
export async function sseRoutes(
|
||||
fastify: FastifyInstance,
|
||||
{
|
||||
sseService,
|
||||
}: {
|
||||
sseService: SseService;
|
||||
},
|
||||
) {
|
||||
fastify.get(
|
||||
"/dpu/events",
|
||||
{
|
||||
schema: {
|
||||
description: "Register for SSE",
|
||||
tags: ["sse"],
|
||||
hide: true,
|
||||
},
|
||||
sse: true,
|
||||
},
|
||||
async (_request, reply) => {
|
||||
reply.sse.keepAlive();
|
||||
|
||||
const clientId = randomUUID();
|
||||
const sendEvent = (data: SseEvent) => {
|
||||
reply.sse.send({
|
||||
data: JSON.stringify(data),
|
||||
});
|
||||
};
|
||||
|
||||
sseService.addClient({ id: clientId, send: sendEvent });
|
||||
|
||||
await reply.sse.send({ data: "Connected" });
|
||||
logInfo(`Connection for client ${clientId} established`);
|
||||
|
||||
reply.sse.onClose(() => {
|
||||
sseService.removeClient(clientId);
|
||||
logInfo(`Connection for client ${clientId} closed`);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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> {
|
||||
@@ -27,10 +26,8 @@ export class TidalService extends BaseService<TidalClient> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,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(
|
||||
@@ -88,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
37
src/websocket/public-routes.ts
Normal file
37
src/websocket/public-routes.ts
Normal 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`);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
###
|
||||
|
||||
Reference in New Issue
Block a user