@camstack/server 1.2.81 → 1.2.82
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -68,10 +68,8 @@ exports.buildNodesProvider = buildNodesProvider;
|
|
|
68
68
|
exports.buildIntegrationsProvider = buildIntegrationsProvider;
|
|
69
69
|
exports.dispatchCustomAction = dispatchCustomAction;
|
|
70
70
|
exports.buildAddonsProvider = buildAddonsProvider;
|
|
71
|
-
const node_child_process_1 = require("node:child_process");
|
|
72
71
|
const node_crypto_1 = require("node:crypto");
|
|
73
72
|
const os = __importStar(require("node:os"));
|
|
74
|
-
const node_util_1 = require("node:util");
|
|
75
73
|
const system_1 = require("@camstack/system");
|
|
76
74
|
const types_1 = require("@camstack/types");
|
|
77
75
|
const server_1 = require("@trpc/server");
|
|
@@ -79,7 +77,6 @@ const integration_id_backfill_1 = require("../../boot/integration-id-backfill");
|
|
|
79
77
|
const addon_package_service_js_1 = require("../../core/addon/addon-package.service.js");
|
|
80
78
|
const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
|
|
81
79
|
const collection_preference_js_1 = require("./collection-preference.js");
|
|
82
|
-
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
83
80
|
// ── system ──────────────────────────────────────────────────────────
|
|
84
81
|
function getRetention(registry) {
|
|
85
82
|
return (registry?.getSingleton('analysis-data-persistence')?.retention ?? null);
|
|
@@ -569,6 +566,117 @@ function getDeviceProvider(ar, addonId) {
|
|
|
569
566
|
const provider = ar.getCapabilityRegistry().getProviderByAddon('device-provider', addonId);
|
|
570
567
|
return isDeviceProvider(provider) ? provider : null;
|
|
571
568
|
}
|
|
569
|
+
function isConnectionTestProvider(value) {
|
|
570
|
+
return (value !== null &&
|
|
571
|
+
typeof value === 'object' &&
|
|
572
|
+
typeof Reflect.get(value, 'testSettings') === 'function');
|
|
573
|
+
}
|
|
574
|
+
function getConnectionTestProvider(ar, addonId) {
|
|
575
|
+
const provider = ar.getCapabilityRegistry().getProviderByAddon('connection-test', addonId);
|
|
576
|
+
return isConnectionTestProvider(provider) ? provider : null;
|
|
577
|
+
}
|
|
578
|
+
function isBrokerProvider(value) {
|
|
579
|
+
return (value !== null &&
|
|
580
|
+
typeof value === 'object' &&
|
|
581
|
+
typeof Reflect.get(value, 'testConnection') === 'function');
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Does this addon declare a pre-creation test at all? Drives
|
|
585
|
+
* `getAvailableTypes().canTest`, so the wizard can say "this one cannot be
|
|
586
|
+
* tested" instead of offering a button that always answers the same nonsense.
|
|
587
|
+
*
|
|
588
|
+
* Broker-mode addons count: their connection identity is the broker, and the
|
|
589
|
+
* broker cap owns a real `testConnection`.
|
|
590
|
+
*/
|
|
591
|
+
function declaresConnectionTest(ar, addonId, mode) {
|
|
592
|
+
if (getConnectionTestProvider(ar, addonId) !== null)
|
|
593
|
+
return true;
|
|
594
|
+
if (mode !== 'broker')
|
|
595
|
+
return false;
|
|
596
|
+
return isBrokerProvider(ar.getCapabilityRegistry().getProviderByAddon('broker', addonId));
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Run the DECLARED pre-creation test for `(addonId, settings)`.
|
|
600
|
+
*
|
|
601
|
+
* Never throws: a test that blows up is `inconclusive`, which is emphatically
|
|
602
|
+
* NOT `rejected`. A timeout must not read as a refusal — it would block a
|
|
603
|
+
* perfectly good create on a flaky network, and the operator would have no way
|
|
604
|
+
* to tell the two apart.
|
|
605
|
+
*/
|
|
606
|
+
async function runConnectionTest(ar, addonId, settings, logger) {
|
|
607
|
+
// 1. The provider's own declared test — the primary path.
|
|
608
|
+
const tester = getConnectionTestProvider(ar, addonId);
|
|
609
|
+
if (tester) {
|
|
610
|
+
try {
|
|
611
|
+
const outcome = await tester.testSettings({ settings });
|
|
612
|
+
if (outcome.outcome === 'validated') {
|
|
613
|
+
return {
|
|
614
|
+
status: 'validated',
|
|
615
|
+
testedBy: addonId,
|
|
616
|
+
...(outcome.latencyMs !== undefined ? { latencyMs: outcome.latencyMs } : {}),
|
|
617
|
+
...(outcome.detail !== undefined ? { detail: outcome.detail } : {}),
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
if (outcome.outcome === 'rejected') {
|
|
621
|
+
return { status: 'rejected', error: outcome.error, testedBy: addonId };
|
|
622
|
+
}
|
|
623
|
+
return { status: 'inconclusive', error: outcome.error, testedBy: addonId };
|
|
624
|
+
}
|
|
625
|
+
catch (err) {
|
|
626
|
+
logger.warn('connection test could not run', {
|
|
627
|
+
tags: { addonId },
|
|
628
|
+
meta: { phase: 'test', error: (0, types_1.errMsg)(err) },
|
|
629
|
+
});
|
|
630
|
+
return {
|
|
631
|
+
status: 'inconclusive',
|
|
632
|
+
error: `Connection test could not run: ${(0, types_1.errMsg)(err)}`,
|
|
633
|
+
testedBy: addonId,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// 2. Broker-backed integrations carry their connection identity as a
|
|
638
|
+
// `brokerId`; the broker already owns the real semantic check (HA opens a
|
|
639
|
+
// temporary WS handshake, MQTT pings the bridge). Reached because the
|
|
640
|
+
// settings NAME a broker, not because nothing else matched.
|
|
641
|
+
const brokerId = settings['brokerId'];
|
|
642
|
+
if (typeof brokerId === 'string' && brokerId.length > 0) {
|
|
643
|
+
const brokerProvider = ar
|
|
644
|
+
.getCapabilityRegistry()
|
|
645
|
+
.getProviderByAddonId('broker', addonId);
|
|
646
|
+
if (!brokerProvider) {
|
|
647
|
+
return {
|
|
648
|
+
status: 'inconclusive',
|
|
649
|
+
error: `Broker provider for addon '${addonId}' is not available`,
|
|
650
|
+
testedBy: null,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
const result = await brokerProvider.testConnection({ id: brokerId });
|
|
655
|
+
return result.ok
|
|
656
|
+
? { status: 'validated', latencyMs: result.latencyMs, testedBy: addonId }
|
|
657
|
+
: { status: 'rejected', error: result.error, testedBy: addonId };
|
|
658
|
+
}
|
|
659
|
+
catch (err) {
|
|
660
|
+
logger.warn('broker connection test could not run', {
|
|
661
|
+
tags: { addonId },
|
|
662
|
+
meta: { phase: 'test', brokerId, error: (0, types_1.errMsg)(err) },
|
|
663
|
+
});
|
|
664
|
+
return {
|
|
665
|
+
status: 'inconclusive',
|
|
666
|
+
error: `Broker test could not run: ${(0, types_1.errMsg)(err)}`,
|
|
667
|
+
testedBy: addonId,
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
// 3. Nothing declared. Say so plainly — this must never render as a pass,
|
|
672
|
+
// and must never render as a failure we did not observe.
|
|
673
|
+
return {
|
|
674
|
+
status: 'unsupported',
|
|
675
|
+
error: `Integration '${addonId}' declares no connection test, ` +
|
|
676
|
+
`so these settings cannot be validated before it is created.`,
|
|
677
|
+
testedBy: null,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
572
680
|
/**
|
|
573
681
|
* Marker caps that flag an addon as a creatable integration type:
|
|
574
682
|
* - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
|
|
@@ -621,6 +729,38 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
|
|
|
621
729
|
throw new Error(`Addon "${input.addonId}" is unique-instance and already has an integration (${existing[0].id})`);
|
|
622
730
|
}
|
|
623
731
|
}
|
|
732
|
+
// ── the gate ────────────────────────────────────────────────────
|
|
733
|
+
//
|
|
734
|
+
// "Next" must validate and create ONLY on success. `rejected` is the one
|
|
735
|
+
// status that blocks: it means the remote was asked and said no. An
|
|
736
|
+
// `unsupported` (no test declared) or `inconclusive` (declared test could
|
|
737
|
+
// not complete) observed NOTHING about the credentials — refusing on
|
|
738
|
+
// those would make every integration lacking the hook uncreatable, and
|
|
739
|
+
// would fail a good create on a flaky network. They pass, and they SAY SO
|
|
740
|
+
// in the log: silence here would read as "validated".
|
|
741
|
+
const verdict = await runConnectionTest(ar, input.addonId, input.settings ?? {}, logger);
|
|
742
|
+
if (verdict.status === 'rejected') {
|
|
743
|
+
logger.warn('rejected — connection test refused the settings', {
|
|
744
|
+
tags: { addonId: input.addonId },
|
|
745
|
+
meta: { phase: 'create', status: verdict.status, testedBy: verdict.testedBy },
|
|
746
|
+
});
|
|
747
|
+
throw new server_1.TRPCError({
|
|
748
|
+
code: 'BAD_REQUEST',
|
|
749
|
+
message: verdict.error ?? `Connection test failed for '${input.addonId}'`,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
if (verdict.status === 'validated') {
|
|
753
|
+
logger.info('connection test validated — proceeding', {
|
|
754
|
+
tags: { addonId: input.addonId },
|
|
755
|
+
meta: { phase: 'create', latencyMs: verdict.latencyMs },
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
else {
|
|
759
|
+
logger.warn('creating without validation — the settings were not validated', {
|
|
760
|
+
tags: { addonId: input.addonId },
|
|
761
|
+
meta: { phase: 'create', status: verdict.status, reason: verdict.error },
|
|
762
|
+
});
|
|
763
|
+
}
|
|
624
764
|
const integration = await reg.createIntegration(payload);
|
|
625
765
|
logger.info('persisted', {
|
|
626
766
|
tags: { integrationId: integration.id, addonId: integration.addonId },
|
|
@@ -911,6 +1051,7 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
|
|
|
911
1051
|
kind,
|
|
912
1052
|
brokerKind,
|
|
913
1053
|
supportsLocationImport,
|
|
1054
|
+
canTest: declaresConnectionTest(ar, m.id, mode),
|
|
914
1055
|
existingInstances: existing.map((i) => ({
|
|
915
1056
|
id: i.id,
|
|
916
1057
|
name: i.name,
|
|
@@ -920,63 +1061,24 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
|
|
|
920
1061
|
});
|
|
921
1062
|
},
|
|
922
1063
|
testConnection: async (input) => {
|
|
923
|
-
//
|
|
924
|
-
//
|
|
925
|
-
//
|
|
926
|
-
//
|
|
927
|
-
//
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
}
|
|
942
|
-
try {
|
|
943
|
-
const result = await brokerProvider.testConnection({ id: brokerId });
|
|
944
|
-
return result.ok ? { success: true } : { success: false, error: result.error };
|
|
945
|
-
}
|
|
946
|
-
catch (err) {
|
|
947
|
-
return { success: false, error: (0, types_1.errMsg)(err) };
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
// Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
|
|
951
|
-
const url = String(input.settings['main_stream_url'] ?? input.settings['url'] ?? '').trim();
|
|
952
|
-
if (!url)
|
|
953
|
-
return { success: false, error: 'No stream URL provided' };
|
|
954
|
-
try {
|
|
955
|
-
const { stdout } = await execFileAsync('ffprobe', [
|
|
956
|
-
'-v',
|
|
957
|
-
'error',
|
|
958
|
-
'-rtsp_transport',
|
|
959
|
-
'tcp',
|
|
960
|
-
'-timeout',
|
|
961
|
-
'3000000',
|
|
962
|
-
'-show_entries',
|
|
963
|
-
'stream=codec_name,width,height',
|
|
964
|
-
'-of',
|
|
965
|
-
'json',
|
|
966
|
-
url,
|
|
967
|
-
], { timeout: 5000 });
|
|
968
|
-
const parsed = (0, types_1.asJsonObject)(JSON.parse(stdout));
|
|
969
|
-
const streams = (0, types_1.asJsonArray)(parsed?.streams);
|
|
970
|
-
return streams.length > 0
|
|
971
|
-
? { success: true }
|
|
972
|
-
: { success: false, error: 'No streams found at URL' };
|
|
973
|
-
}
|
|
974
|
-
catch (err) {
|
|
975
|
-
return {
|
|
976
|
-
success: false,
|
|
977
|
-
error: `Connection failed: ${(0, types_1.errMsg)(err)}`,
|
|
978
|
-
};
|
|
979
|
-
}
|
|
1064
|
+
// The Test button. Routes to the DECLARED test (see `runConnectionTest`)
|
|
1065
|
+
// and reports the tri-state verbatim: `success` is true ONLY for
|
|
1066
|
+
// `validated`, so a test that never ran can never render as a tick — and
|
|
1067
|
+
// `status` lets the UI show "could not be validated" in its own colour
|
|
1068
|
+
// instead of a red failure nobody observed.
|
|
1069
|
+
const verdict = await runConnectionTest(ar, input.addonId, input.settings, logger);
|
|
1070
|
+
logger.info('connection test', {
|
|
1071
|
+
tags: { addonId: input.addonId },
|
|
1072
|
+
meta: { phase: 'test', status: verdict.status, testedBy: verdict.testedBy },
|
|
1073
|
+
});
|
|
1074
|
+
return {
|
|
1075
|
+
success: verdict.status === 'validated',
|
|
1076
|
+
status: verdict.status,
|
|
1077
|
+
testedBy: verdict.testedBy,
|
|
1078
|
+
...(verdict.error !== undefined ? { error: verdict.error } : {}),
|
|
1079
|
+
...(verdict.latencyMs !== undefined ? { latencyMs: verdict.latencyMs } : {}),
|
|
1080
|
+
...(verdict.detail !== undefined ? { detail: verdict.detail } : {}),
|
|
1081
|
+
};
|
|
980
1082
|
},
|
|
981
1083
|
};
|
|
982
1084
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// AUTO-GENERATED by scripts/generate-cap-mounts.ts — DO NOT EDIT
|
|
3
3
|
// Re-run: npx tsx scripts/generate-cap-mounts.ts
|
|
4
4
|
//
|
|
5
|
-
// Mounted:
|
|
5
|
+
// Mounted: 145 Skipped (legacy): 3
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
7
|
exports.LEGACY_SHAPE_SKIP = void 0;
|
|
8
8
|
exports.mountAllCaps = mountAllCaps;
|
|
@@ -111,6 +111,15 @@ function mountAllCaps(services) {
|
|
|
111
111
|
carbonMonoxide: (0, generated_cap_routers_js_1.createCapRouter_carbonMonoxide)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'carbon-monoxide'), remoteCapProxy),
|
|
112
112
|
climateControl: (0, generated_cap_routers_js_1.createCapRouter_climateControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'climate-control'), remoteCapProxy),
|
|
113
113
|
color: (0, generated_cap_routers_js_1.createCapRouter_color)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'color'), remoteCapProxy),
|
|
114
|
+
connectionTest: (0, generated_cap_routers_js_1.createCapRouter_connectionTest)((_ctx, addonId) => {
|
|
115
|
+
if (!reg)
|
|
116
|
+
return null;
|
|
117
|
+
if (addonId !== undefined) {
|
|
118
|
+
return reg.getProviderByAddonId('connection-test', addonId);
|
|
119
|
+
}
|
|
120
|
+
const entries = reg.getCollectionEntries('connection-test');
|
|
121
|
+
return entries[0]?.[1] ?? null;
|
|
122
|
+
}, remoteCapProxy),
|
|
114
123
|
connectivity: (0, generated_cap_routers_js_1.createCapRouter_connectivity)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'connectivity'), remoteCapProxy),
|
|
115
124
|
consumables: (0, generated_cap_routers_js_1.createCapRouter_consumables)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'consumables'), remoteCapProxy),
|
|
116
125
|
contact: (0, generated_cap_routers_js_1.createCapRouter_contact)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'contact'), remoteCapProxy),
|