@apocaliss92/scrypted-reolink-native 0.0.1
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.
- package/.vscode/launch.json +23 -0
- package/.vscode/settings.json +4 -0
- package/.vscode/tasks.json +20 -0
- package/README.md +1 -0
- package/dist/main.nodejs.js +3 -0
- package/dist/main.nodejs.js.LICENSE.txt +3 -0
- package/dist/plugin.zip +0 -0
- package/package.json +47 -0
- package/src/camera.ts +1576 -0
- package/src/intercom.ts +417 -0
- package/src/main.ts +126 -0
- package/src/presets.ts +209 -0
- package/src/stream-utils.ts +112 -0
- package/src/utils.ts +60 -0
- package/tsconfig.json +13 -0
package/src/presets.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { PtzPreset } from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
2
|
+
|
|
3
|
+
import type { ReolinkNativeCamera } from "./camera";
|
|
4
|
+
|
|
5
|
+
export type PtzCapabilitiesShape = {
|
|
6
|
+
presets?: Record<string, string>;
|
|
7
|
+
[key: string]: unknown;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export class ReolinkPtzPresets {
|
|
11
|
+
constructor(private camera: ReolinkNativeCamera) { }
|
|
12
|
+
|
|
13
|
+
private get storageSettings() {
|
|
14
|
+
return this.camera.storageSettings;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
private getPtzCapabilitiesStore(): PtzCapabilitiesShape | undefined {
|
|
18
|
+
return this.camera.ptzCapabilities as PtzCapabilitiesShape | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private setPtzCapabilitiesStore(next: PtzCapabilitiesShape): void {
|
|
22
|
+
this.camera.ptzCapabilities = next;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private parsePresetIdFromSettingValue(value: string): number | undefined {
|
|
26
|
+
const s = String(value ?? '').trim();
|
|
27
|
+
if (!s) return undefined;
|
|
28
|
+
const idPart = s.includes('=') ? s.split('=')[0] : s;
|
|
29
|
+
const id = Number(idPart);
|
|
30
|
+
return Number.isFinite(id) ? id : undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private syncEnabledPresetsSettingAndCaps(available: PtzPreset[]): void {
|
|
34
|
+
const enabled = (this.storageSettings.values.presets ?? []) as string[];
|
|
35
|
+
|
|
36
|
+
// If the user hasn't configured the "presets" setting, keep the auto-discovered preset list
|
|
37
|
+
// (setCachedPtzPresets already applied it).
|
|
38
|
+
if (!enabled.length) return;
|
|
39
|
+
|
|
40
|
+
const nameById = new Map<number, string>(available.map((p) => [p.id, p.name]));
|
|
41
|
+
|
|
42
|
+
// Apply only enabled presets mapping, but do NOT prune or rewrite the setting.
|
|
43
|
+
// Prefer user-provided names ("id=name"), fallback to camera-provided name.
|
|
44
|
+
const mapped: Record<string, string> = {};
|
|
45
|
+
for (const entry of enabled) {
|
|
46
|
+
const id = this.parsePresetIdFromSettingValue(entry);
|
|
47
|
+
if (id === undefined) continue;
|
|
48
|
+
|
|
49
|
+
const providedName = entry.includes('=')
|
|
50
|
+
? entry.substring(entry.indexOf('=') + 1).trim()
|
|
51
|
+
: '';
|
|
52
|
+
const name = providedName || nameById.get(id);
|
|
53
|
+
if (!name) continue;
|
|
54
|
+
|
|
55
|
+
mapped[String(id)] = name;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.setPtzCapabilitiesStore({
|
|
59
|
+
...(this.getPtzCapabilitiesStore() ?? {}),
|
|
60
|
+
presets: mapped,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
setCachedPtzPresets(presets: PtzPreset[] | undefined): void {
|
|
65
|
+
const list = Array.isArray(presets) ? presets : [];
|
|
66
|
+
this.storageSettings.values.cachedPresets = list;
|
|
67
|
+
|
|
68
|
+
const mapped: Record<string, string> = {};
|
|
69
|
+
for (const p of list) {
|
|
70
|
+
mapped[String(p.id)] = p.name;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
this.setPtzCapabilitiesStore({
|
|
74
|
+
...(this.getPtzCapabilitiesStore() ?? {}),
|
|
75
|
+
presets: mapped,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
getCachedPtzPresets(): PtzPreset[] {
|
|
80
|
+
const v = this.storageSettings.values.cachedPresets;
|
|
81
|
+
return Array.isArray(v) ? (v as PtzPreset[]) : [];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
nextFreePresetId(existing: PtzPreset[]): number {
|
|
85
|
+
const used = new Set(existing.map((p) => p.id));
|
|
86
|
+
for (let id = 1; id <= 255; id++) {
|
|
87
|
+
if (!used.has(id)) return id;
|
|
88
|
+
}
|
|
89
|
+
throw new Error('No free PTZ preset id available (1..255)');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async refreshPtzPresets(): Promise<PtzPreset[]> {
|
|
93
|
+
const client = await this.camera.ensureClient();
|
|
94
|
+
const channel = this.camera.getRtspChannel();
|
|
95
|
+
const presets = await client.getPtzPresets(channel);
|
|
96
|
+
this.setCachedPtzPresets(presets);
|
|
97
|
+
this.syncEnabledPresetsSettingAndCaps(presets);
|
|
98
|
+
return presets;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async moveToPreset(presetId: number): Promise<void> {
|
|
102
|
+
const client = await this.camera.ensureClient();
|
|
103
|
+
const channel = this.camera.getRtspChannel();
|
|
104
|
+
await client.moveToPtzPreset(channel, presetId);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Create a new PTZ preset at current position. */
|
|
108
|
+
async createPtzPreset(name: string, presetId?: number): Promise<PtzPreset> {
|
|
109
|
+
const client = await this.camera.ensureClient();
|
|
110
|
+
const channel = this.camera.getRtspChannel();
|
|
111
|
+
const trimmed = String(name ?? '').trim();
|
|
112
|
+
if (!trimmed) throw new Error('Preset name is required');
|
|
113
|
+
const existing = await client.getPtzPresets(channel);
|
|
114
|
+
const existingIds = new Set(existing.map((p) => p.id));
|
|
115
|
+
|
|
116
|
+
const maxAttempts = 5;
|
|
117
|
+
let lastUpdated: PtzPreset[] = [];
|
|
118
|
+
let chosenId: number | undefined;
|
|
119
|
+
|
|
120
|
+
const initialId = presetId ?? this.nextFreePresetId(existing);
|
|
121
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
122
|
+
const id = presetId ?? (initialId + attempt);
|
|
123
|
+
if (!presetId && existingIds.has(id)) continue;
|
|
124
|
+
|
|
125
|
+
await client.setPtzPreset(channel, id, trimmed);
|
|
126
|
+
|
|
127
|
+
const updated = await client.getPtzPresets(channel);
|
|
128
|
+
lastUpdated = updated;
|
|
129
|
+
|
|
130
|
+
const persisted = updated.some((p) => p.id === id);
|
|
131
|
+
if (persisted) {
|
|
132
|
+
chosenId = id;
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// If the camera isn't exposing any presets at all, it may be keeping low IDs reserved/disabled.
|
|
137
|
+
// Try the next ID.
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (chosenId === undefined) {
|
|
141
|
+
this.setCachedPtzPresets(lastUpdated);
|
|
142
|
+
this.syncEnabledPresetsSettingAndCaps(lastUpdated);
|
|
143
|
+
throw new Error(
|
|
144
|
+
`PTZ preset save did not persist (camera returned an empty/unchanged preset list). Try a different preset id.`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.setCachedPtzPresets(lastUpdated);
|
|
149
|
+
|
|
150
|
+
// If the "presets" setting is in use, auto-add the newly created preset so it becomes
|
|
151
|
+
// immediately selectable/visible in the UI. If it's empty/unconfigured, don't start using it.
|
|
152
|
+
const enabled = (this.storageSettings.values.presets ?? []) as string[];
|
|
153
|
+
if (enabled.length) {
|
|
154
|
+
const already = enabled.some((e) => this.parsePresetIdFromSettingValue(e) === chosenId);
|
|
155
|
+
if (!already) {
|
|
156
|
+
enabled.push(`${chosenId}=${trimmed}`);
|
|
157
|
+
this.storageSettings.values.presets = enabled;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Re-apply enabled mapping (so custom names/filters remain effective after setCachedPtzPresets).
|
|
162
|
+
this.syncEnabledPresetsSettingAndCaps(lastUpdated);
|
|
163
|
+
return { id: chosenId, name: trimmed };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Overwrite an existing preset with the current PTZ position (and keep its current name). */
|
|
167
|
+
async updatePtzPresetToCurrentPosition(presetId: number): Promise<void> {
|
|
168
|
+
const client = await this.camera.ensureClient();
|
|
169
|
+
const channel = this.camera.getRtspChannel();
|
|
170
|
+
|
|
171
|
+
const current = this.getCachedPtzPresets();
|
|
172
|
+
const found = current.find((p) => p.id === presetId);
|
|
173
|
+
const name = found?.name ?? `Preset ${presetId}`;
|
|
174
|
+
|
|
175
|
+
await client.setPtzPreset(channel, presetId, name);
|
|
176
|
+
await this.refreshPtzPresets();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Best-effort delete/disable a preset (firmware dependent). */
|
|
180
|
+
async deletePtzPreset(presetId: number): Promise<void> {
|
|
181
|
+
const client = await this.camera.ensureClient();
|
|
182
|
+
const channel = this.camera.getRtspChannel();
|
|
183
|
+
await client.deletePtzPreset(channel, presetId);
|
|
184
|
+
|
|
185
|
+
// Keep enabled preset list clean (remove deleted id), but do not rewrite names for others.
|
|
186
|
+
const enabledRaw = this.storageSettings.values.presets as unknown;
|
|
187
|
+
if (Array.isArray(enabledRaw) && enabledRaw.length) {
|
|
188
|
+
const filtered = (enabledRaw as unknown[])
|
|
189
|
+
.filter((v) => typeof v === 'string')
|
|
190
|
+
.filter((e) => this.parsePresetIdFromSettingValue(e as string) !== presetId) as string[];
|
|
191
|
+
this.storageSettings.values.presets = filtered;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
await this.refreshPtzPresets();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Rename a preset while trying to preserve its position (will move camera to that preset first). */
|
|
198
|
+
async renamePtzPreset(presetId: number, newName: string): Promise<void> {
|
|
199
|
+
const client = await this.camera.ensureClient();
|
|
200
|
+
const channel = this.camera.getRtspChannel();
|
|
201
|
+
const trimmed = String(newName ?? '').trim();
|
|
202
|
+
if (!trimmed) throw new Error('Preset name is required');
|
|
203
|
+
|
|
204
|
+
// Warning: this moves the camera.
|
|
205
|
+
await client.moveToPtzPreset(channel, presetId);
|
|
206
|
+
await client.setPtzPreset(channel, presetId, trimmed);
|
|
207
|
+
await this.refreshPtzPresets();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ReolinkBaichuanApi,
|
|
3
|
+
StreamProfile,
|
|
4
|
+
ScryptedRfc4571TcpServer,
|
|
5
|
+
VideoType,
|
|
6
|
+
} from "@apocaliss92/reolink-baichuan-js" with { "resolution-mode": "import" };
|
|
7
|
+
|
|
8
|
+
export interface StreamManagerOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Creates a dedicated Baichuan session for streaming.
|
|
11
|
+
* Required to support concurrent main+ext streams on firmwares where streamType overlaps.
|
|
12
|
+
*/
|
|
13
|
+
createStreamClient: () => Promise<ReolinkBaichuanApi>;
|
|
14
|
+
getLogger: () => Console;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseStreamProfileFromId(id: string | undefined): StreamProfile | undefined {
|
|
18
|
+
if (!id)
|
|
19
|
+
return;
|
|
20
|
+
if (id === 'mainstream')
|
|
21
|
+
return 'main';
|
|
22
|
+
if (id === 'substream')
|
|
23
|
+
return 'sub';
|
|
24
|
+
if (id === 'extstream')
|
|
25
|
+
return 'ext';
|
|
26
|
+
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class StreamManager {
|
|
31
|
+
private nativeRfcServers = new Map<string, ScryptedRfc4571TcpServer>();
|
|
32
|
+
private nativeRfcServerCreatePromises = new Map<string, Promise<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }>>();
|
|
33
|
+
|
|
34
|
+
constructor(private opts: StreamManagerOptions) {
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private getLogger() {
|
|
38
|
+
return this.opts.getLogger();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private async ensureNativeRfcServer(
|
|
42
|
+
streamKey: string,
|
|
43
|
+
channel: number,
|
|
44
|
+
profile: StreamProfile,
|
|
45
|
+
expectedVideoType?: 'H264' | 'H265',
|
|
46
|
+
): Promise<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }> {
|
|
47
|
+
const existingCreate = this.nativeRfcServerCreatePromises.get(streamKey);
|
|
48
|
+
if (existingCreate) {
|
|
49
|
+
return await existingCreate;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const createPromise = (async () => {
|
|
53
|
+
const cached = this.nativeRfcServers.get(streamKey);
|
|
54
|
+
if (cached?.server?.listening) {
|
|
55
|
+
if (expectedVideoType && cached.videoType !== expectedVideoType) {
|
|
56
|
+
this.getLogger().warn(
|
|
57
|
+
`Native RFC cache codec mismatch for ${streamKey}: cached=${cached.videoType} expected=${expectedVideoType}; recreating server.`,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
return { host: cached.host, port: cached.port, sdp: cached.sdp, audio: cached.audio };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (cached) {
|
|
66
|
+
try {
|
|
67
|
+
await cached.close('recreate');
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// ignore
|
|
71
|
+
}
|
|
72
|
+
this.nativeRfcServers.delete(streamKey);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const api = await this.opts.createStreamClient();
|
|
76
|
+
const { createScryptedRfc4571TcpServer } = await import('@apocaliss92/reolink-baichuan-js');
|
|
77
|
+
const created = await createScryptedRfc4571TcpServer({
|
|
78
|
+
api,
|
|
79
|
+
channel,
|
|
80
|
+
profile,
|
|
81
|
+
logger: this.getLogger(),
|
|
82
|
+
expectedVideoType: expectedVideoType as VideoType | undefined,
|
|
83
|
+
closeApiOnTeardown: true,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
this.nativeRfcServers.set(streamKey, created);
|
|
87
|
+
created.server.once('close', () => {
|
|
88
|
+
const current = this.nativeRfcServers.get(streamKey);
|
|
89
|
+
if (current?.server === created.server) this.nativeRfcServers.delete(streamKey);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return { host: created.host, port: created.port, sdp: created.sdp, audio: created.audio };
|
|
93
|
+
})();
|
|
94
|
+
|
|
95
|
+
this.nativeRfcServerCreatePromises.set(streamKey, createPromise);
|
|
96
|
+
try {
|
|
97
|
+
return await createPromise;
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
this.nativeRfcServerCreatePromises.delete(streamKey);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async getRfcStream(
|
|
105
|
+
channel: number,
|
|
106
|
+
profile: StreamProfile,
|
|
107
|
+
streamKey: string,
|
|
108
|
+
expectedVideoType?: 'H264' | 'H265',
|
|
109
|
+
): Promise<{ host: string; port: number; sdp: string; audio?: { codec: string; sampleRate: number; channels: number } }> {
|
|
110
|
+
return await this.ensureNativeRfcServer(streamKey, channel, profile, expectedVideoType);
|
|
111
|
+
}
|
|
112
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
|
|
2
|
+
import MqttClient from '../../scrypted-apocaliss-base/src/mqtt-client';
|
|
3
|
+
|
|
4
|
+
export const getMqttTopics = (cameraName: string) => {
|
|
5
|
+
const statusTopic = `neolink/${cameraName}/status`;
|
|
6
|
+
const batteryStatusTopic = `neolink/${cameraName}/status/battery_level`;
|
|
7
|
+
const motionStatusTopic = `neolink/${cameraName}/status/motion`;
|
|
8
|
+
const disconnecteStatusdTopic = `neolink/${cameraName}/status/disconnected`;
|
|
9
|
+
const previewStatusTopic = `neolink/${cameraName}/status/preview`;
|
|
10
|
+
const ptzPresetsStatusTopic = `neolink/${cameraName}/status/ptz/preset`;
|
|
11
|
+
|
|
12
|
+
const batteryQueryTopic = `neolink/${cameraName}/query/battery`;
|
|
13
|
+
const previewQueryTopic = `neolink/${cameraName}/query/preview`;
|
|
14
|
+
const ptzPreviewQueryTopic = `neolink/${cameraName}/query/ptz/preset`;
|
|
15
|
+
|
|
16
|
+
const ptzControlTopic = `neolink/${cameraName}/control/ptz`;
|
|
17
|
+
const ptzPresetControlTopic = `neolink/${cameraName}/control/preset`;
|
|
18
|
+
const floodlightControlTopic = `neolink/${cameraName}/control/floodlight`;
|
|
19
|
+
const floodlightTasksControlTopic = `neolink/${cameraName}/control/floodlight_tasks`;
|
|
20
|
+
const sirenControlTopic = `neolink/${cameraName}/control/siren`;
|
|
21
|
+
const rebootControlTopic = `neolink/${cameraName}/control/reboot`;
|
|
22
|
+
const ledControlTopic = `neolink/${cameraName}/control/led`;
|
|
23
|
+
const irControlTopic = `neolink/${cameraName}/control/ir`;
|
|
24
|
+
const pirControlTopic = `neolink/${cameraName}/control/pir`;
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
statusTopic,
|
|
28
|
+
batteryStatusTopic,
|
|
29
|
+
motionStatusTopic,
|
|
30
|
+
disconnecteStatusdTopic,
|
|
31
|
+
ptzPresetsStatusTopic,
|
|
32
|
+
previewStatusTopic,
|
|
33
|
+
batteryQueryTopic,
|
|
34
|
+
previewQueryTopic,
|
|
35
|
+
ptzPreviewQueryTopic,
|
|
36
|
+
ptzControlTopic,
|
|
37
|
+
ptzPresetControlTopic,
|
|
38
|
+
floodlightControlTopic,
|
|
39
|
+
floodlightTasksControlTopic,
|
|
40
|
+
sirenControlTopic,
|
|
41
|
+
rebootControlTopic,
|
|
42
|
+
ledControlTopic,
|
|
43
|
+
irControlTopic,
|
|
44
|
+
pirControlTopic,
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const subscribeToNeolinkTopic = async (client: MqttClient, topic: string, console: Console, cb: (value?: any) => void) => {
|
|
49
|
+
client.subscribe([topic], async (messageTopic, message) => {
|
|
50
|
+
const messageString = message.toString();
|
|
51
|
+
if (messageTopic === topic) {
|
|
52
|
+
cb(messageString);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const unsubscribeFromNeolinkTopic = async (client: MqttClient, topic: string, console: Console) => {
|
|
59
|
+
client.unsubscribe([topic]);
|
|
60
|
+
}
|