@homebridge-plugins/homebridge-eufy-security 4.4.5-beta.0 → 4.4.5-beta.2

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.
@@ -0,0 +1,161 @@
1
+ ---
2
+ name: new-device-support
3
+ description: Full workflow to add support for a new Eufy Security device type across eufy-security-client and homebridge-eufy-security. Covers exploration, implementation, build verification, and git/PR creation.
4
+ ---
5
+
6
+ # Add New Eufy Security Device Support
7
+
8
+ You are adding support for a new device type to the eufy-security ecosystem. The user will provide a GitHub issue URL or device details (model name, model number like T86P2, device type number like 111). They may also provide raw device properties JSON.
9
+
10
+ Use `$ARGUMENTS` for the issue URL or device details.
11
+
12
+ ## Phase 1 — Gather Information
13
+
14
+ 1. **Fetch the GitHub issue** (if URL provided) to extract: device name, model number (e.g. T86P2), device type number, raw properties JSON, firmware version, and any reference PRs.
15
+ 2. **Run the property mapping script**: Save the raw properties JSON to a temp file and run:
16
+ ```bash
17
+ node homebridge-eufy-security/.claude/skills/new-device-support/map-properties.mjs /tmp/<device>-raw-props.json
18
+ ```
19
+ This maps each raw `param_type` to its `CommandType`/`ParamType` enum name, matching property constants, and which existing device types use them. It also outputs a suggested DeviceProperties block. Use this output to identify the closest existing device and plan the property mapping.
20
+ 3. **Ask the user** two questions:
21
+ - Image naming convention (check if images already exist or need renaming)
22
+ - Enum name for the DeviceType (e.g. `CAMERA_4G_S330`)
23
+
24
+ ## Phase 2 — Plan (use EnterPlanMode)
25
+
26
+ Create a detailed plan covering all files that need changes. The plan must be based on the actual raw device properties — never guess which properties a device supports.
27
+
28
+ ### Files to modify in eufy-security-client
29
+
30
+ #### `src/http/types.ts` — 6 locations:
31
+
32
+ 1. **DeviceType enum**: Add `ENUM_NAME = <number>, //<model>` in numeric order
33
+ 2. **GenericTypeProperty states**: Add `<number>: "<Display Name> (<Model>)"` in numeric order
34
+ 3. **DeviceProperties**: Add `[DeviceType.ENUM_NAME]` block. Always starts with `...GenericDeviceProperties`. Map each raw param_type to its corresponding `PropertyName.*` property constant. Base on the closest existing device but only include properties that match the raw data.
35
+ 4. **StationProperties**: Add `[DeviceType.ENUM_NAME]` block if device can act as its own station (solo cameras, integrated devices). Use `...BaseStationProperties` plus station-specific properties.
36
+ 5. **DeviceCommands**: Add `[DeviceType.ENUM_NAME]` array. Commands depend on device capabilities (livestream, talkback, pan/tilt, download, snooze, preset positions, calibrate, alarm).
37
+ 6. **StationCommands**: Add `[DeviceType.ENUM_NAME]` array if device has station properties. Typically `[CommandName.StationReboot, CommandName.StationTriggerAlarmSound]`.
38
+
39
+ #### `src/http/device.ts` — Classification methods:
40
+
41
+ Add the new device type to all applicable static classification methods. Common ones:
42
+ - `isCamera()` — if it's a camera/doorbell/floodlight
43
+ - `hasBattery()` — if battery-powered
44
+ - `isPanAndTiltCamera()` — if has PTZ
45
+ - `isOutdoorPanAndTiltCamera()` — if outdoor PTZ (this feeds into `isSoloCameras()`)
46
+ - `isFloodLight()`, `isIndoorCamera()`, `isDoorbell()`, etc. — as applicable
47
+
48
+ Add a **new static type guard method** and matching **instance method**:
49
+ ```typescript
50
+ static isNewDevice(type: number): boolean {
51
+ //<Model>
52
+ return DeviceType.ENUM_NAME == type;
53
+ }
54
+
55
+ public isNewDevice(): boolean {
56
+ return Device.isNewDevice(this.rawDevice.device_type);
57
+ }
58
+ ```
59
+
60
+ Update serial number checks if applicable:
61
+ - `isIntegratedDeviceBySn()` — add `sn.startsWith("<model>")` if the device is integrated/standalone
62
+ - `isSoloCameraBySn()` — add `sn.startsWith("<model>")` if it's a solo camera
63
+
64
+ #### `src/http/station.ts`:
65
+
66
+ - `isIntegratedDevice()` — if the device is standalone or can pair as its own station, it may already be covered by `isSoloCameras()`, `isFloodLight()`, etc. Only add explicit check if needed.
67
+
68
+ #### `src/push/service.ts`:
69
+
70
+ - If the device is 4G LTE or needs special push notification handling, expand the normalization block (~line 768) to include the new type guard.
71
+
72
+ #### `docs/supported_devices.md`:
73
+
74
+ Add an entry in the correct table section:
75
+ ```markdown
76
+ | ![<Model> image](_media/<image_small>.png) | <Display Name> (<Model>) | :wrench: | Firmware: <version> |
77
+ ```
78
+ Use `:wrench:` for initial support.
79
+
80
+ ### Files to modify in homebridge-eufy-security
81
+
82
+ #### `homebridge-ui/public/utils/device-images.js`:
83
+
84
+ Add a case in the `getImage()` switch:
85
+ ```javascript
86
+ case <type_number>: return '<image_large>.png';
87
+ ```
88
+
89
+ #### Image assets:
90
+
91
+ - Rename or add images in `eufy-security-client/docs/_media/` (small + large)
92
+ - Rename or add image in `homebridge-eufy-security/homebridge-ui/public/assets/devices/` (large only)
93
+
94
+ ## Phase 3 — Implement
95
+
96
+ Execute the plan. Key implementation notes:
97
+
98
+ - **Property mapping**: Use the output from `map-properties.mjs` (Phase 1) as the primary guide. When multiple property constants match the same `param_type` (e.g. `DeviceWatermarkProperty` vs `DeviceWatermarkSoloWiredDoorbellProperty`), pick the variant used by the closest existing device. Check the "Used by DeviceTypes" column in the script output.
99
+ - **Insert in order**: When adding to enums, switch statements, or `if` chains, maintain numeric ordering by device type number.
100
+ - **Audio recording property**: Different device families use different audio recording property constants (e.g. `DeviceAudioRecordingProperty`, `DeviceAudioRecordingStarlight4gLTEProperty`). Match the closest existing device.
101
+ - **No Co-Authored-By**: Do not add co-author lines to commits.
102
+
103
+ ## Phase 4 — Build & Lint Verification
104
+
105
+ Run these in parallel:
106
+ ```bash
107
+ cd eufy-security-client && npm run build
108
+ cd homebridge-eufy-security && npm run build
109
+ ```
110
+
111
+ Then verify lint:
112
+ ```bash
113
+ cd eufy-security-client && npm run lint
114
+ cd homebridge-eufy-security && npm run lint
115
+ ```
116
+
117
+ Note: eufy-security-client lint may fail due to a pre-existing `jiti` library issue unrelated to our changes. The TypeScript build succeeding is sufficient validation.
118
+
119
+ ## Phase 5 — Git & PR
120
+
121
+ ### eufy-security-client
122
+
123
+ 1. Discard any unrelated changes (e.g. `package-lock.json`)
124
+ 2. Sync develop with upstream: `git fetch upstream && git checkout develop && git merge upstream/develop`
125
+ 3. Create branch: `git checkout -b feat/<device-slug>`
126
+ 4. Stage only relevant files (images (should match the `<device-slug>`), `src/http/types.ts`, `src/http/device.ts`, `src/push/service.ts`, `docs/supported_devices.md`)
127
+ 5. Commit: `git commit -m "feat: add <Device Name> (<Model>, type <number>) support"`
128
+ 6. Push: `git push origin feat/<device-slug>`
129
+ 7. Create cross-fork PR:
130
+ ```bash
131
+ gh pr create --repo bropat/eufy-security-client --base develop \
132
+ --head lenoxys:feat/<device-slug> \
133
+ --title "feat: add <Device Name> (<Model>, type <number>) support" \
134
+ --body-file /tmp/pr-body-<branch>.md
135
+ ```
136
+
137
+ ### homebridge-eufy-security
138
+
139
+ 1. Branch from the current beta branch (check with `git branch`): `git checkout -b feat/<device-slug>`
140
+ 2. Stage: `homebridge-ui/public/utils/device-images.js` + any added image
141
+ 3. Commit: `git commit -m "feat: add <Device Name> (<Model>, type <number>) device image mapping"`
142
+ 4. Push: `git push origin feat/<device-slug>`
143
+ 5. Create PR:
144
+ ```bash
145
+ gh pr create --repo homebridge-plugins/homebridge-eufy-security \
146
+ --base <beta-branch> \
147
+ --title "feat: add <Device Name> (<Model>, type <number>) device image" \
148
+ --body-file /tmp/pr-body-<branch>.md
149
+ ```
150
+
151
+ ### PR body format
152
+
153
+ Write PR bodies to `/tmp/pr-body-<branch>.md` files. Include:
154
+ - `## Summary` — bullet points describing the changes
155
+ - Cross-references: `Closes homebridge-plugins/homebridge-eufy-security#<issue>` in the client PR, `Closes #<issue>` in the plugin PR
156
+ - `Depends on bropat/eufy-security-client#<pr>` in the plugin PR
157
+ - `## Test plan` — checklist of verification steps
158
+
159
+ ### Link the issue
160
+
161
+ After both PRs are created, update both PR bodies so they reference the issue with closing keywords.
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * map-properties.mjs
4
+ *
5
+ * Maps raw device param_type numbers from a device properties JSON dump
6
+ * to the corresponding property constants in eufy-security-client.
7
+ *
8
+ * Usage:
9
+ * node map-properties.mjs <raw-properties.json>
10
+ * cat raw.json | node map-properties.mjs
11
+ *
12
+ * The input JSON should be the rawProperties object from a device dump,
13
+ * e.g. { "1101": { "value": 100 }, "1013": { "value": 1 }, ... }
14
+ * or an array of { "param_type": 1101, "param_value": "..." } objects.
15
+ *
16
+ * Output: a table mapping each param_type to its CommandType/ParamType enum name
17
+ * and all property constants that use that key.
18
+ */
19
+
20
+ import { readFileSync } from "fs";
21
+ import { join, dirname } from "path";
22
+ import { fileURLToPath } from "url";
23
+
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+
26
+ // Paths relative to this script's location
27
+ // eufy-security-client is a sibling repo at the same level as homebridge-eufy-security
28
+ const CLIENT_ROOT = join(__dirname, "..", "..", "..", "..", "eufy-security-client");
29
+ const TYPES_HTTP = join(CLIENT_ROOT, "src", "http", "types.ts");
30
+ const TYPES_P2P = join(CLIENT_ROOT, "src", "p2p", "types.ts");
31
+
32
+ // ── Step 1: Parse enum values from source ──────────────────────────────────
33
+
34
+ function parseEnum(source, enumName) {
35
+ const map = new Map(); // number → enum member name
36
+ const re = new RegExp(`enum\\s+${enumName}\\s*\\{([\\s\\S]*?)\\}`, "m");
37
+ const match = source.match(re);
38
+ if (!match) return map;
39
+
40
+ const body = match[1];
41
+ for (const line of body.split("\n")) {
42
+ const m = line.match(/^\s*(\w+)\s*=\s*(\d+)/);
43
+ if (m) {
44
+ map.set(Number(m[2]), `${enumName}.${m[1]}`);
45
+ }
46
+ }
47
+ return map;
48
+ }
49
+
50
+ const httpSource = readFileSync(TYPES_HTTP, "utf-8");
51
+ const p2pSource = readFileSync(TYPES_P2P, "utf-8");
52
+
53
+ const paramTypeMap = parseEnum(httpSource, "ParamType");
54
+ const commandTypeMap = parseEnum(p2pSource, "CommandType");
55
+ const trackerCommandTypeMap = parseEnum(p2pSource, "TrackerCommandType");
56
+
57
+ // Merge all enum maps (CommandType takes precedence as it's most commonly used)
58
+ const allEnums = new Map();
59
+ for (const [k, v] of paramTypeMap) allEnums.set(k, v);
60
+ for (const [k, v] of trackerCommandTypeMap) allEnums.set(k, v);
61
+ for (const [k, v] of commandTypeMap) allEnums.set(k, v);
62
+
63
+ // ── Step 2: Parse property constants and their keys ────────────────────────
64
+
65
+ // Match property constant definitions and extract their key field
66
+ // Handles both direct keys and spread patterns
67
+ function parsePropertyConstants(source) {
68
+ const props = []; // { constName, key, keyRaw, propertyName }
69
+
70
+ // Find each "export const XxxProperty" block
71
+ const constRegex = /export\s+const\s+(\w+Property)\s*:\s*\w+\s*=\s*\{([\s\S]*?)\};/g;
72
+ let m;
73
+ while ((m = constRegex.exec(source)) !== null) {
74
+ const constName = m[1];
75
+ const body = m[2];
76
+
77
+ // Extract key field
78
+ let key = null;
79
+ let keyRaw = null;
80
+ const keyMatch = body.match(/key:\s*(.+?)(?:,|\n)/);
81
+ if (keyMatch) {
82
+ keyRaw = keyMatch[1].trim();
83
+ // Resolve to numeric if it's an enum reference
84
+ if (keyRaw.startsWith('"') || keyRaw.startsWith("'")) {
85
+ key = keyRaw.replace(/['"]/g, ""); // string key
86
+ } else {
87
+ // It's an enum reference like CommandType.CMD_GET_BATTERY
88
+ const parts = keyRaw.split(".");
89
+ if (parts.length === 2) {
90
+ const enumName = parts[0];
91
+ let enumMap;
92
+ if (enumName === "CommandType") enumMap = commandTypeMap;
93
+ else if (enumName === "ParamType") enumMap = paramTypeMap;
94
+ else if (enumName === "TrackerCommandType") enumMap = trackerCommandTypeMap;
95
+
96
+ if (enumMap) {
97
+ for (const [num, fullName] of enumMap) {
98
+ if (fullName === keyRaw) {
99
+ key = num;
100
+ break;
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // Extract PropertyName
109
+ let propertyName = null;
110
+ const nameMatch = body.match(/name:\s*(PropertyName\.\w+)/);
111
+ if (nameMatch) {
112
+ propertyName = nameMatch[1];
113
+ }
114
+
115
+ // Handle spread: ...SomeOtherProperty (inherits key if not overridden)
116
+ let spreadFrom = null;
117
+ const spreadMatch = body.match(/\.\.\.(\w+Property)/);
118
+ if (spreadMatch) {
119
+ spreadFrom = spreadMatch[1];
120
+ }
121
+
122
+ props.push({ constName, key, keyRaw, propertyName, spreadFrom });
123
+ }
124
+
125
+ // Resolve spreads: if a property has no key but spreads from another, inherit
126
+ const byName = new Map(props.map((p) => [p.constName, p]));
127
+ for (const prop of props) {
128
+ if (prop.key === null && prop.spreadFrom) {
129
+ const parent = byName.get(prop.spreadFrom);
130
+ if (parent) {
131
+ prop.key = parent.key;
132
+ if (!prop.keyRaw) prop.keyRaw = `(spread from ${prop.spreadFrom}) ${parent.keyRaw}`;
133
+ if (!prop.propertyName) prop.propertyName = parent.propertyName;
134
+ }
135
+ }
136
+ }
137
+
138
+ return props;
139
+ }
140
+
141
+ const allProps = parsePropertyConstants(httpSource);
142
+
143
+ // Build lookup: numeric key → list of property constants
144
+ const keyToProps = new Map(); // key (number|string) → [{ constName, propertyName }]
145
+ for (const p of allProps) {
146
+ if (p.key === null) continue;
147
+ const k = p.key;
148
+ if (!keyToProps.has(k)) keyToProps.set(k, []);
149
+ keyToProps.get(k).push({ constName: p.constName, propertyName: p.propertyName });
150
+ }
151
+
152
+ // ── Step 3: Parse DeviceProperties blocks to see which constants are used ──
153
+
154
+ function parseDevicePropertiesUsage(source) {
155
+ // Find all [DeviceType.XXX]: { ... } blocks inside DeviceProperties
156
+ const usage = new Map(); // constName → Set of DeviceType names that use it
157
+ const dpRegex = /\[DeviceType\.(\w+)\]\s*:\s*\{([\s\S]*?)\}/g;
158
+ // Only search within the DeviceProperties section
159
+ const dpStart = source.indexOf("export const DeviceProperties");
160
+ const dpEnd = source.indexOf("export const StationProperties");
161
+ if (dpStart === -1) return usage;
162
+
163
+ const dpSection = source.slice(dpStart, dpEnd === -1 ? undefined : dpEnd);
164
+ let m;
165
+ while ((m = dpRegex.exec(dpSection)) !== null) {
166
+ const deviceType = m[1];
167
+ const body = m[2];
168
+ // Find all property constant references
169
+ const refRegex = /:\s*(\w+Property)/g;
170
+ let rm;
171
+ while ((rm = refRegex.exec(body)) !== null) {
172
+ const constName = rm[1];
173
+ if (!usage.has(constName)) usage.set(constName, new Set());
174
+ usage.get(constName).add(deviceType);
175
+ }
176
+ }
177
+ return usage;
178
+ }
179
+
180
+ const propUsage = parseDevicePropertiesUsage(httpSource);
181
+
182
+ // ── Step 4: Read and parse input JSON ──────────────────────────────────────
183
+
184
+ let inputData;
185
+ const inputFile = process.argv[2];
186
+ if (inputFile) {
187
+ inputData = readFileSync(inputFile, "utf-8");
188
+ } else {
189
+ // Read from stdin
190
+ inputData = readFileSync("/dev/stdin", "utf-8");
191
+ }
192
+
193
+ let rawParamTypes; // Set of numeric param_type values
194
+ try {
195
+ const parsed = JSON.parse(inputData);
196
+
197
+ if (Array.isArray(parsed)) {
198
+ // Array of { param_type: number, ... }
199
+ rawParamTypes = new Set(parsed.map((item) => Number(item.param_type)));
200
+ } else if (typeof parsed === "object") {
201
+ // Check if it's a full device dump with rawProperties nested
202
+ if (parsed.rawProperties && typeof parsed.rawProperties === "object") {
203
+ rawParamTypes = new Set(Object.keys(parsed.rawProperties).map(Number));
204
+ } else {
205
+ // Direct { "1101": ..., "1013": ... } format
206
+ rawParamTypes = new Set(Object.keys(parsed).map(Number));
207
+ }
208
+ }
209
+ } catch {
210
+ console.error("Error: Could not parse input JSON. Provide either:");
211
+ console.error(' - A raw properties object: { "1101": {...}, "1013": {...}, ... }');
212
+ console.error(' - An array: [{ "param_type": 1101, ... }, ...]');
213
+ console.error(" - A device dump with rawProperties key");
214
+ process.exit(1);
215
+ }
216
+
217
+ // Filter out NaN (from string keys in the input)
218
+ rawParamTypes = new Set([...rawParamTypes].filter((n) => !isNaN(n)));
219
+
220
+ // ── Step 5: Output results ─────────────────────────────────────────────────
221
+
222
+ const COL1 = 12; // param_type
223
+ const COL2 = 48; // enum name
224
+ const COL3 = 50; // property constants
225
+ // COL4 reserved for future use
226
+
227
+ const header = [
228
+ "param_type".padEnd(COL1),
229
+ "Enum Name".padEnd(COL2),
230
+ "Property Constant(s)".padEnd(COL3),
231
+ "Used by DeviceTypes",
232
+ ].join(" | ");
233
+
234
+ console.log("=".repeat(header.length));
235
+ console.log("Device Raw Properties → Property Constants Mapping");
236
+ console.log(`Total raw param_types: ${rawParamTypes.size}`);
237
+ console.log("=".repeat(header.length));
238
+ console.log();
239
+ console.log(header);
240
+ console.log("-".repeat(header.length));
241
+
242
+ const matched = [];
243
+ const unmatched = [];
244
+
245
+ for (const paramType of [...rawParamTypes].sort((a, b) => a - b)) {
246
+ const enumName = allEnums.get(paramType) || "???";
247
+ const props = keyToProps.get(paramType);
248
+
249
+ if (props && props.length > 0) {
250
+ const constNames = props.map((p) => p.constName);
251
+ const propertyNames = [...new Set(props.map((p) => p.propertyName).filter(Boolean))];
252
+ const usedBy = new Set();
253
+ for (const cn of constNames) {
254
+ const devices = propUsage.get(cn);
255
+ if (devices) devices.forEach((d) => usedBy.add(d));
256
+ }
257
+
258
+ matched.push({
259
+ paramType,
260
+ enumName,
261
+ constNames,
262
+ propertyNames,
263
+ usedBy: [...usedBy],
264
+ });
265
+
266
+ console.log(
267
+ [
268
+ String(paramType).padEnd(COL1),
269
+ enumName.padEnd(COL2),
270
+ constNames.join(", ").padEnd(COL3),
271
+ [...usedBy].slice(0, 5).join(", ") + (usedBy.size > 5 ? "..." : ""),
272
+ ].join(" | ")
273
+ );
274
+ } else {
275
+ unmatched.push({ paramType, enumName });
276
+ console.log(
277
+ [
278
+ String(paramType).padEnd(COL1),
279
+ enumName.padEnd(COL2),
280
+ "(no property constant found)".padEnd(COL3),
281
+ "",
282
+ ].join(" | ")
283
+ );
284
+ }
285
+ }
286
+
287
+ console.log();
288
+ console.log("=".repeat(80));
289
+ console.log(`MATCHED: ${matched.length} / ${rawParamTypes.size} param_types have property constants`);
290
+ console.log(`UNMATCHED: ${unmatched.length} param_types have no known property constant`);
291
+ console.log("=".repeat(80));
292
+
293
+ if (unmatched.length > 0) {
294
+ console.log();
295
+ console.log("Unmatched param_types (may need new property constants or may be internal):");
296
+ for (const u of unmatched) {
297
+ console.log(` ${u.paramType} → ${u.enumName}`);
298
+ }
299
+ }
300
+
301
+ // Output a suggested PropertyName list for use in DeviceProperties block
302
+ console.log();
303
+ console.log("=".repeat(80));
304
+ console.log("SUGGESTED DeviceProperties block entries:");
305
+ console.log("=".repeat(80));
306
+ const seen = new Set();
307
+ for (const m of matched) {
308
+ for (let i = 0; i < m.constNames.length; i++) {
309
+ const pn = m.propertyNames[0]; // Use first PropertyName
310
+ const cn = m.constNames[i];
311
+ if (pn && !seen.has(pn)) {
312
+ seen.add(pn);
313
+ console.log(` [${pn}]: ${cn},`);
314
+ }
315
+ }
316
+ }
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const LIB_VERSION = "4.4.5-beta.0";
1
+ export const LIB_VERSION = "4.4.5-beta.2";
2
2
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "displayName": "Homebridge Eufy Security",
3
3
  "name": "@homebridge-plugins/homebridge-eufy-security",
4
- "version": "4.4.5-beta.0",
4
+ "version": "4.4.5-beta.2",
5
5
  "description": "Control Eufy Security from homebridge.",
6
6
  "type": "module",
7
7
  "license": "Apache-2.0",