Files
sleep-guard/packages/proxy/src/services/upsnap.ts
Vadim Sobinin a91c4ba137 fix(proxy): fix UpSnap wake — use /api/upsnap/wake endpoint
PATCH { status: "on" } no longer works in current UpSnap version.
Switch to GET /api/upsnap/wake/{id} with 10s timeout (WoL packet
is sent immediately, endpoint blocks until device responds).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-15 01:09:14 +03:00

123 lines
3.6 KiB
TypeScript

import type { UpSnapAuthResponse } from '@sleepguard/shared';
import { config } from '../config.js';
let token: string | null = null;
async function authenticate(): Promise<string> {
// PocketBase v0.23+ moved admins to _superusers collection
const collections = ['_superusers', 'users'];
for (const collection of collections) {
const url = `${config.upsnap.url}/api/collections/${collection}/auth-with-password`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
identity: config.upsnap.username,
password: config.upsnap.password,
}),
});
if (res.ok) {
const data = (await res.json()) as UpSnapAuthResponse;
token = data.token;
return token;
}
const errBody = await res.text().catch(() => '');
console.error(`[UpSnap] Auth attempt ${collection} failed (${res.status}): ${errBody}`);
}
throw new Error('UpSnap auth failed: could not authenticate as superuser or user');
}
async function getToken(): Promise<string> {
if (!token) {
return authenticate();
}
return token;
}
async function upSnapFetch(path: string, options: RequestInit = {}): Promise<Response> {
let authToken = await getToken();
const doFetch = (t: string) =>
fetch(`${config.upsnap.url}${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: t,
...options.headers,
},
});
let res = await doFetch(authToken);
// Token expired — re-auth once
if (res.status === 401 || res.status === 403) {
authToken = await authenticate();
res = await doFetch(authToken);
}
return res;
}
export async function wakeDevice(): Promise<void> {
// Use the dedicated wake endpoint — it sends a WoL packet.
// This endpoint may block until the device responds or its internal timeout expires,
// so we abort after 10s — the WoL packet is already sent by then.
const authToken = await getToken();
try {
const res = await fetch(
`${config.upsnap.url}/api/upsnap/wake/${config.upsnap.deviceId}`,
{
method: 'GET',
headers: { Authorization: authToken },
signal: AbortSignal.timeout(10_000),
},
);
if (res.status === 401 || res.status === 403) {
const newToken = await authenticate();
await fetch(
`${config.upsnap.url}/api/upsnap/wake/${config.upsnap.deviceId}`,
{
method: 'GET',
headers: { Authorization: newToken },
signal: AbortSignal.timeout(10_000),
},
).catch(() => {});
// WoL packet sent regardless of response
return;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`UpSnap wake failed: ${res.status}${body}`);
}
} catch (err) {
// AbortError / TimeoutError is expected — the WoL packet was already sent
if (err instanceof DOMException && err.name === 'TimeoutError') {
console.log('[UpSnap] Wake request timed out (expected — WoL packet was sent)');
return;
}
if (err instanceof DOMException && err.name === 'AbortError') {
console.log('[UpSnap] Wake request aborted (expected — WoL packet was sent)');
return;
}
throw err;
}
}
export async function shutdownDevice(): Promise<void> {
const res = await upSnapFetch(`/api/upsnap/shutdown/${config.upsnap.deviceId}`, {
method: 'GET',
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`UpSnap shutdown failed: ${res.status}${body}`);
}
}