Add native local NAS and network service integrations

This commit is contained in:
2026-05-05 19:37:20 +00:00
parent a144ef687c
commit ae901a3308
69 changed files with 13245 additions and 183 deletions
@@ -0,0 +1,43 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { PiHoleConfigFlow, PiHoleHttpMatcher, PiHoleManualMatcher, PiHoleCandidateValidator } from '../../ts/integrations/pi_hole/index.js';
tap.test('recognizes Pi-hole HTTP and manual discovery candidates', async () => {
const httpMatch = await new PiHoleHttpMatcher().matches({
url: 'http://192.168.1.2/admin/api.php?summaryRaw',
headers: { server: 'lighttpd' },
});
const manualMatch = await new PiHoleManualMatcher().matches({
host: 'pihole.local',
name: 'Pi-hole',
});
const validation = await new PiHoleCandidateValidator().validate(httpMatch.candidate!);
expect(httpMatch.matched).toBeTrue();
expect(httpMatch.candidate?.integrationDomain).toEqual('pi_hole');
expect(httpMatch.candidate?.metadata?.apiVersion).toEqual(5);
expect(httpMatch.candidate?.metadata?.location).toEqual('admin');
expect(manualMatch.matched).toBeTrue();
expect(manualMatch.candidate?.port).toEqual(80);
expect(validation.matched).toBeTrue();
});
tap.test('config flow parses Pi-hole URL and validates credentials shape', async () => {
const step = await new PiHoleConfigFlow().start({ source: 'manual', name: 'Pi-hole' }, {});
const missingKey = await step.submit!({ host: 'http://192.168.1.2:8080/admin/api.php' });
const done = await step.submit!({
host: 'http://192.168.1.2:8080/admin/api.php',
apiKey: 'secret',
apiVersion: '5',
verifySsl: false,
});
expect(missingKey.kind).toEqual('error');
expect(done.kind).toEqual('done');
expect(done.config?.host).toEqual('192.168.1.2');
expect(done.config?.port).toEqual(8080);
expect(done.config?.location).toEqual('admin');
expect(done.config?.apiVersion).toEqual(5);
expect(done.config?.apiKey).toEqual('secret');
});
export default tap.start();
+120
View File
@@ -0,0 +1,120 @@
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { PiHoleMapper, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
const v5Snapshot: IPiHoleSnapshot = PiHoleMapper.toSnapshot({
config: {
host: '192.168.1.2',
port: 80,
name: 'Home DNS',
apiVersion: 5,
rawData: {
v5Summary: {
status: 'enabled',
ads_blocked_today: 42,
ads_percentage_today: 21.55,
clients_ever_seen: 8,
dns_queries_today: 200,
domains_being_blocked: 150000,
queries_cached: 50,
queries_forwarded: 120,
unique_clients: 5,
unique_domains: 90,
},
v5Versions: {
core_current: 'v5.18.3',
core_latest: 'v5.18.4',
core_update: true,
web_current: 'v5.21',
web_latest: 'v5.21',
web_update: false,
FTL_current: 'v5.25.2',
FTL_latest: 'v5.25.2',
FTL_update: false,
},
},
},
online: true,
source: 'manual',
});
tap.test('maps Pi-hole v5 status, statistics, updates, and switch control', async () => {
const devices = PiHoleMapper.toDevices(v5Snapshot);
const entities = PiHoleMapper.toEntities(v5Snapshot);
expect(devices[0].id).toEqual('pi_hole.service.192_168_1_2_80');
expect(devices[0].online).toBeTrue();
expect(entities.find((entityArg) => entityArg.id === 'switch.home_dns')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'binary_sensor.home_dns_status')?.state).toEqual('on');
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_blocked_today')?.state).toEqual(42);
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_ads_percentage_blocked_today')?.state).toEqual(21.6);
expect(entities.find((entityArg) => entityArg.id === 'sensor.home_dns_dns_queries_today')?.state).toEqual(200);
expect(entities.find((entityArg) => entityArg.id === 'update.home_dns_core_update_available')?.attributes?.latestVersion).toEqual('v5.18.4');
});
tap.test('models Pi-hole enable, disable, and refresh commands without secrets', async () => {
const disableCommand = PiHoleMapper.commandForService(v5Snapshot, {
domain: 'switch',
service: 'turn_off',
target: { entityId: 'switch.home_dns' },
data: { duration: '00:10:00' },
});
const enableCommand = PiHoleMapper.commandForService(v5Snapshot, {
domain: 'pi_hole',
service: 'enable',
target: { deviceId: 'pi_hole.service.192_168_1_2_80' },
});
const refreshCommand = PiHoleMapper.commandForService(v5Snapshot, {
domain: 'pi_hole',
service: 'refresh',
target: {},
});
expect(Boolean(disableCommand && !('error' in disableCommand))).toBeTrue();
if (disableCommand && !('error' in disableCommand)) {
expect(disableCommand.type).toEqual('disable');
expect(disableCommand.path).toEqual('/admin/api.php');
expect(disableCommand.query).toEqual({ disable: 600 });
expect(JSON.stringify(disableCommand).includes('auth')).toBeFalse();
}
expect(Boolean(enableCommand && !('error' in enableCommand))).toBeTrue();
if (enableCommand && !('error' in enableCommand)) {
expect(enableCommand.query).toEqual({ enable: 'True' });
}
expect(refreshCommand && !('error' in refreshCommand) ? refreshCommand.type : undefined).toEqual('refresh');
});
tap.test('maps Pi-hole v6 nested summary and version payloads', async () => {
const snapshot = PiHoleMapper.toSnapshot({
config: {
host: 'pihole.local',
name: 'Family DNS',
apiVersion: 6,
rawData: {
v6Blocking: { blocking: 'disabled' },
v6Summary: {
queries: { blocked: 12, percent_blocked: 24.123, total: 50, cached: 8, forwarded: 30, unique_domains: 40 },
clients: { total: 6, active: 3 },
gravity: { domains_being_blocked: 180000 },
},
v6Versions: {
version: {
core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.1', hash: 'b' } },
web: { local: { version: 'v6.0', hash: 'c' }, remote: { version: 'v6.0', hash: 'c' } },
ftl: { local: { version: 'v6.0', hash: 'd' }, remote: { version: 'v6.0', hash: 'd' } },
},
},
},
},
online: true,
source: 'manual',
});
const entities = PiHoleMapper.toEntities(snapshot);
expect(entities.find((entityArg) => entityArg.id === 'switch.family_dns')?.state).toEqual('off');
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_blocked')?.state).toEqual(12);
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_ads_percentage_blocked')?.state).toEqual(24.12);
expect(entities.find((entityArg) => entityArg.id === 'sensor.family_dns_dns_queries')?.state).toEqual(50);
expect(entities.find((entityArg) => entityArg.id === 'update.family_dns_core_update_available')?.state).toEqual('on');
});
export default tap.start();
+112
View File
@@ -0,0 +1,112 @@
import { createServer } from 'node:http';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { PiHoleClient, PiHoleIntegration, type IPiHoleSnapshot } from '../../ts/integrations/pi_hole/index.js';
const snapshot: IPiHoleSnapshot = {
online: true,
apiVersion: 6,
status: 'enabled',
statistics: {
adsBlocked: 1,
adsPercentage: 10,
clientsSeen: 1,
dnsQueries: 10,
domainsBlocked: 100,
queriesCached: 2,
queriesForwarded: 7,
uniqueClients: 1,
uniqueDomains: 8,
},
versions: { core: {}, web: {}, ftl: {} },
name: 'Pi-hole',
};
tap.test('reads Pi-hole v6 local HTTP API and executes disable through HTTP', async () => {
const requests: Array<{ method?: string; url?: string; sid?: string; csrf?: string; body?: string }> = [];
const server = createServer((requestArg, responseArg) => {
let body = '';
requestArg.on('data', (chunkArg) => { body += String(chunkArg); });
requestArg.on('end', () => {
requests.push({
method: requestArg.method,
url: requestArg.url,
sid: requestArg.headers['x-ftl-sid'] as string | undefined,
csrf: requestArg.headers['x-ftl-csrf'] as string | undefined,
body,
});
responseArg.setHeader('content-type', 'application/json');
if (requestArg.method === 'POST' && requestArg.url === '/api/auth') {
responseArg.end(JSON.stringify({ session: { valid: true, sid: 'sid-1', csrf: 'csrf-1', validity: 300 } }));
return;
}
if (requestArg.method === 'GET' && requestArg.url === '/api/stats/summary') {
responseArg.end(JSON.stringify({ queries: { blocked: 5, percent_blocked: 25, total: 20, cached: 4, forwarded: 10, unique_domains: 12 }, clients: { total: 3, active: 2 }, gravity: { domains_being_blocked: 100000 } }));
return;
}
if (requestArg.method === 'GET' && requestArg.url === '/api/dns/blocking') {
responseArg.end(JSON.stringify({ blocking: 'enabled' }));
return;
}
if (requestArg.method === 'GET' && requestArg.url === '/api/info/version') {
responseArg.end(JSON.stringify({ version: { core: { local: { version: 'v6.0', hash: 'a' }, remote: { version: 'v6.0', hash: 'a' } }, web: {}, ftl: {} } }));
return;
}
if (requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking') {
responseArg.end(JSON.stringify({ blocking: false, timer: 15 }));
return;
}
responseArg.statusCode = 404;
responseArg.end(JSON.stringify({ error: 'not found' }));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
try {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
const runtime = await new PiHoleIntegration().setup({ host: '127.0.0.1', port, apiKey: 'secret', apiVersion: 6, timeoutMs: 1000 }, {});
const entities = await runtime.entities();
const result = await runtime.callService?.({
domain: 'switch',
service: 'turn_off',
target: { entityId: entities.find((entityArg) => entityArg.platform === 'switch')?.id },
data: { duration: '00:00:15' },
});
const disableRequest = requests.find((requestArg) => requestArg.method === 'POST' && requestArg.url === '/api/dns/blocking');
expect(entities.find((entityArg) => entityArg.id === 'sensor.pi_hole_dns_queries')?.state).toEqual(20);
expect(result?.success).toBeTrue();
expect(disableRequest?.sid).toEqual('sid-1');
expect(disableRequest?.csrf).toEqual('csrf-1');
expect(JSON.parse(disableRequest?.body || '{}')).toEqual({ blocking: false, timer: 15 });
await runtime.destroy();
} finally {
await new Promise<void>((resolve, reject) => server.close((errorArg) => errorArg ? reject(errorArg) : resolve()));
}
});
tap.test('does not report snapshot-only Pi-hole commands as successful', async () => {
const runtime = await new PiHoleIntegration().setup({ snapshot }, {});
const switchEntity = (await runtime.entities()).find((entityArg) => entityArg.platform === 'switch');
const result = await runtime.callService?.({
domain: 'switch',
service: 'turn_off',
target: { entityId: switchEntity?.id },
});
expect(result?.success).toBeFalse();
expect(result?.error).toEqual('Pi-hole live commands require config.host or commandExecutor; snapshot-only commands are not reported as successful.');
await runtime.destroy();
});
tap.test('does not fake refresh success without HTTP endpoint or snapshot', async () => {
const client = new PiHoleClient({});
const snapshotResult = await client.getSnapshot();
const refreshResult = await client.refresh();
expect(snapshotResult.online).toBeFalse();
expect(refreshResult.success).toBeFalse();
expect(refreshResult.error).toContain('endpoint');
});
export default tap.start();