Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions bridge-node/src/endpoint-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,6 +1010,53 @@ export class EndpointMapStore {
return changed;
}

/**
* A device is CONFIRMED GONE FOR GOOD — deleted from Indigo, or
* deliberately un-exported — so its record is dropped ENTIRELY rather
* than kept as an orphan (issue #274).
*
* **This is the one place a record's `number` is thrown away**, unlike
* every other mutation here ({@link forget}, {@link voidNumbers}), all of
* which keep it forever precisely so a retired number is never handed to
* a different accessory (ADR-0010). That guarantee still holds after this
* call: matter.js's OWN persisted allocation for the retired
* `Endpoint.id` — a separate store this file never touches — is what
* actually prevents reissue, and it is untouched by deleting our own
* witness of it. What is lost is re-adopt: {@link restorable} and
* {@link orphans} can only offer what is still in `#endpoints`, so a
* destroyed identity is gone from both — no re-adopt picker entry, no
* pre-attach rebuild, ever again.
*
* **Only a caller certain the device will never come back may call
* this.** {@link EndpointMapStore.forget}'s doc comment explains why an
* ORDINARY un-export (an `attach` reconcile that simply does not mention
* an identity this pass) must stay soft: the node cannot tell a
* deliberate departure from a device that merely failed classification
* this one time (issue #274 constraint 2). `node.ts`'s `removeEndpoint`
* is the only caller, and only when the plugin's `remove_endpoint`
* carried `permanent: true`.
*
* Returns how many entries actually existed and were removed, so a call
* over an already-forgotten or never-recorded `uniqueId` costs no disk
* write — the same idempotency `remove_endpoint` itself promises
* (§3.3: `{removed: false}` for an absent endpoint).
*/
destroy(uniqueIds: readonly string[]): number {
let removed = 0;
for (const uniqueId of uniqueIds) {
if (this.#endpoints.delete(uniqueId)) {
removed += 1;
}
}
if (removed > 0) {
this.persist(
`destroyed ${removed} endpoint map record(s): the driving device is gone for good, so ` +
"neither the number nor the role/label is retained for re-adopt",
);
}
return removed;
}

/**
* Compare the live endpoint numbers against the map (PRD §4.3).
*
Expand Down
34 changes: 29 additions & 5 deletions bridge-node/src/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1464,8 +1464,21 @@ export class BridgeNode implements BridgeFacade {
* then `upsert_endpoint` as two SEPARATE commands (§3 steps 3/5), so the
* create half of that supersession has not happened yet when THIS command
* finishes — {@link upsertEndpoint} is what closes the gap when it does.
*
* **`options.hard` (issue #274) destroys rather than orphans the
* `ordinary` bucket.** Only {@link removeEndpoint} ever passes it, and
* only when the plugin has declared the removal PERMANENT — the driving
* device is deleted, or deliberately un-exported. `attach`'s reconcile
* (`reconcile()`) never does: a full reconcile cannot tell "the plugin
* dropped this on purpose" from "this device exists but failed
* classification this pass" (issue #274 constraint 2), so its ordinary
* removals stay soft — orphaned, kept, restorable — exactly as before
* this issue. A hard removal is also never eligible for the supersede
* pairing above (a deleted device gets no later create to pair with) or
* for {@link #lastRemoved} (nothing should retroactively mark a
* DESTROYED entry `supersededBy`).
*/
private forgetRemoved(before: ReadonlyMap<string, number>): void {
private forgetRemoved(before: ReadonlyMap<string, number>, options: { hard?: boolean } = {}): void {
const after = this.livePublishedIdentities();
const removed = [...before.keys()].filter(uniqueId => !after.has(uniqueId));
if (removed.length === 0) {
Expand All @@ -1481,12 +1494,12 @@ export class BridgeNode implements BridgeFacade {
for (const uniqueId of removed) {
const deviceId = before.get(uniqueId)!;
const created = createdDeviceIds.get(deviceId);
if (created !== undefined && supersedes(uniqueId, created)) {
if (!options.hard && created !== undefined && supersedes(uniqueId, created)) {
forgotten += this.#endpointMap.forget([uniqueId], { supersededBy: created });
continue;
}
ordinary.push(uniqueId);
if (created === undefined) {
if (!options.hard && created === undefined) {
// Only when NO identity for this device appeared in this
// mutation: a create that already landed and was NOT a
// supersession (a re-adopt, PR5 design E2/E5) has settled the
Expand All @@ -1495,6 +1508,17 @@ export class BridgeNode implements BridgeFacade {
this.#lastRemoved.set(deviceId, uniqueId);
}
}
if (options.hard) {
forgotten += this.#endpointMap.destroy(ordinary);
if (forgotten > 0) {
this.log(
`${forgotten} endpoint map record(s) destroyed — the driving device is gone for good ` +
"(deleted or un-exported); no re-adopt is offered for them and their numbers are not " +
"retained (issue #274)",
);
}
return;
}
forgotten += this.#endpointMap.forget(ordinary);
if (forgotten > 0) {
this.log(
Expand Down Expand Up @@ -1647,12 +1671,12 @@ export class BridgeNode implements BridgeFacade {
* only one that never looked would have made that invisible until the next
* upsert happened to notice.
*/
async removeEndpoint(indigoDeviceId: number): Promise<RemoveResult> {
async removeEndpoint(indigoDeviceId: number, permanent = false): Promise<RemoveResult> {
const before = this.livePublishedIdentities();
try {
return await this.registry.remove(indigoDeviceId);
} finally {
this.forgetRemoved(before);
this.forgetRemoved(before, { hard: permanent });
this.checkDrift();
}
}
Expand Down
16 changes: 14 additions & 2 deletions bridge-node/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,8 +584,20 @@ export interface BridgeFacade {
reconcile(endpoints: readonly EndpointSpec[], replaceAll: boolean): Promise<StatusReport>;
/** §3.2 — create-or-update. Rejects a role change with `role_change` (§4.1). */
upsertEndpoint(spec: EndpointSpec): Promise<UpsertResult>;
/** §3.3 — idempotent; `{removed: false}` for a device with no live endpoint. */
removeEndpoint(indigoDeviceId: number): Promise<RemoveResult>;
/**
* §3.3 — idempotent; `{removed: false}` for a device with no live endpoint.
*
* `permanent` (issue #274, default `false`) is the confirmed-gone
* declaration: `true` destroys the endpoint-map record along with the
* live endpoint (no orphan, no re-adopt), for a device that has been
* deleted or deliberately un-exported. `false` — the default, and what
* every pre-#274 caller still gets — keeps the pre-existing orphan
* behaviour, which the two-command supersede/re-adopt sequence
* (`export_bridge.replace()`) depends on: its `upsert_endpoint` half
* still needs an orphaned entry to mark `supersededBy`, or to hand to
* the re-adopt picker.
*/
removeEndpoint(indigoDeviceId: number, permanent?: boolean): Promise<RemoveResult>;
/** §3.4 — local (offline-context) writes, so they do not echo as `command`. */
setState(indigoDeviceId: number, states: Record<string, unknown>): Promise<void>;
/** §3.5 — Bridged Device Basic Information `Reachable`. */
Expand Down
25 changes: 25 additions & 0 deletions bridge-node/src/reconcile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,31 @@ export function parsePreserveEndpointNumbers(value: unknown): boolean {
return value;
}

/**
* §3.3: whether a `remove_endpoint` is a PERMANENT departure — the driving
* Indigo device is confirmed gone (deleted, or deliberately un-exported) —
* rather than the removal half of a role-change/re-adopt `replace()`, which
* the plugin always follows with an `upsert_endpoint` for the same identity
* (issue #274).
*
* Absent means `false`, matching every `remove_endpoint` call before this
* flag existed: the two-command supersede/re-adopt sequence
* (`export_bridge.replace()`) must keep getting the ORIGINAL soft removal —
* orphaned, not destroyed — so the second command's `upsert_endpoint` still
* has an entry to retroactively mark `supersededBy`, or to hand back to the
* re-adopt picker. Only a caller that KNOWS the device will never come back
* opts in explicitly.
*/
export function parsePermanentRemoval(value: unknown): boolean {
if (value === undefined) {
return false;
}
if (typeof value !== "boolean") {
throw new ProtocolError(ErrorCode.malformedArgs, "permanent must be a boolean");
}
return value;
}

/** §3.1: the opt-in that makes emptying the endpoint set deliberate. */
export function parseReplaceAll(intent: unknown): boolean {
if (intent === undefined) {
Expand Down
3 changes: 2 additions & 1 deletion bridge-node/src/ws-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
parseEndpointSpec,
parseEndpointSpecs,
parseFabricIndex,
parsePermanentRemoval,
parsePreserveEndpointNumbers,
parseReplaceAll,
} from "./reconcile.js";
Expand Down Expand Up @@ -89,7 +90,7 @@ export class BridgeWsServer {
this.options.bridge.upsertEndpoint(parseEndpointSpec(args.endpoint)),
);
this.#handlers.set("remove_endpoint", async args =>
this.options.bridge.removeEndpoint(parseDeviceId(args.indigoDeviceId)),
this.options.bridge.removeEndpoint(parseDeviceId(args.indigoDeviceId), parsePermanentRemoval(args.permanent)),
);
this.#handlers.set("set_state", async args => this.handleSetState(args));
this.#handlers.set("set_reachable", async args => this.handleSetReachable(args));
Expand Down
59 changes: 59 additions & 0 deletions bridge-node/test/endpoint-map.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -753,6 +753,65 @@ describe("EndpointMapStore.forget — un-export without losing the number (issue
});
});

describe("EndpointMapStore.destroy — a confirmed-gone device loses its record entirely (issue #274)", () => {
it("deletes the entry outright: no number, no role/label, not restorable, not offered for re-adopt", () => {
const dir = storage();
const store = new EndpointMapStore(dir);
store.load();
store.check([
{ uniqueId: "indigo-1", endpointNumber: 2, role: "onOffLight", label: "Lamp" },
{ uniqueId: "indigo-2", endpointNumber: 3, role: "dimmableLight", label: "Other" },
]);

assert.equal(store.destroy(["indigo-1"]), 1);

assert.equal(store.numberFor("indigo-1"), undefined, "the number itself is gone, unlike forget()");
assert.deepEqual(store.restorable(), [
{ uniqueId: "indigo-2", endpointNumber: 3, indigoDeviceId: 2, role: "dimmableLight", label: "Other" },
]);
assert.deepEqual(store.orphans(), [], "destroyed, not orphaned — nothing left to offer the re-adopt picker");
assert.deepEqual(mapFileIn(dir).endpoints, {
"indigo-2": { number: 3, role: "dimmableLight", label: "Other" },
});
});

it("also destroys an entry that was already orphaned (an un-export later confirmed as a deletion)", () => {
const dir = storage();
const store = new EndpointMapStore(dir);
store.load();
store.check([{ uniqueId: "indigo-1", endpointNumber: 2, role: "onOffLight", label: "Lamp" }]);
store.forget(["indigo-1"]);
assert.deepEqual(store.orphans().map(orphan => orphan.uniqueId), ["indigo-1"]);

assert.equal(store.destroy(["indigo-1"]), 1);

assert.deepEqual(store.orphans(), []);
assert.equal(mapFileIn(dir).endpoints["indigo-1"], undefined);
});

it("costs no disk write when there was nothing to destroy — unknown ids are a no-op, like forget()", () => {
const dir = storage();
const store = new EndpointMapStore(dir);
store.load();
store.check([{ uniqueId: "indigo-1", endpointNumber: 2 }]);
const before = readFileSync(join(dir, ENDPOINT_MAP_FILE), "utf8");

assert.equal(store.destroy(["indigo-nope"]), 0);

assert.equal(readFileSync(join(dir, ENDPOINT_MAP_FILE), "utf8"), before);
});

it("is idempotent — destroying an already-destroyed (i.e. absent) entry is a no-op", () => {
const dir = storage();
const store = new EndpointMapStore(dir);
store.load();
store.check([{ uniqueId: "indigo-1", endpointNumber: 2, role: "onOffLight", label: "Lamp" }]);
assert.equal(store.destroy(["indigo-1"]), 1);

assert.equal(store.destroy(["indigo-1"]), 0);
});
});

describe("EndpointMapStore.seed replaces the whole map", () => {
it("refuses to seed fewer numbers than the baseline already holds", () => {
// ⊗ `seed` is a full replace, and nothing said so. Its one caller
Expand Down
17 changes: 17 additions & 0 deletions bridge-node/test/reconcile.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
parseDeviceId,
parseEndpointSpec,
parseEndpointSpecs,
parsePermanentRemoval,
parseReplaceAll,
planReconcile,
} from "../src/reconcile.js";
Expand Down Expand Up @@ -149,6 +150,22 @@ describe("parseEndpointSpecs / parseReplaceAll (§3.1)", () => {
});
});

describe("parsePermanentRemoval (§3.3, issue #274)", () => {
it("defaults to false — every remove_endpoint call before this flag existed keeps the soft (orphan) behaviour", () => {
assert.equal(parsePermanentRemoval(undefined), false);
});

it("honours an explicit true or false", () => {
assert.equal(parsePermanentRemoval(true), true);
assert.equal(parsePermanentRemoval(false), false);
});

it("rejects a non-boolean", () => {
assert.equal(refusal(() => parsePermanentRemoval("true")).code, ErrorCode.malformedArgs);
assert.equal(refusal(() => parsePermanentRemoval(1)).code, ErrorCode.malformedArgs);
});
});

describe("publishedAs (§4.1, issues #219/#240)", () => {
it("defaults publishedAs to indigo-<deviceId> when the wire omits it", () => {
assert.equal(
Expand Down
Loading
Loading