messages: pure builders for every event the bridge sends

src/messages builds Response, StateReport, DeferredResponse, ErrorResponse, ChangeReport and the scene, doorbell
and simple events from plain values; messageId and times are parameters, so a test compares whole objects.
StateBuilder collects properties checked by the descriptors, AlexaError and AlexaErrors carry the payload fields
of an error type. AlexaStatusMessage and AlexaErrorResponse move to src/compat and are written on the builders.
On the wire, against 1.5.2: a DeferredResponse has no context key, a ChangeReport has no correlationToken, the
namespace of an ErrorResponse follows its type, and a ChangeReport without a changed property is not published:
send() resolves "" and the bridge reports an error that names the endpoint.
143 tests pass (108 before).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
David 2026-09-28 19:28:05 +00:00
parent 2558e21787
commit 46fa06728c
68 changed files with 3410 additions and 1208 deletions

View file

@ -0,0 +1,64 @@
import { randomUUID } from "crypto";
import type { MqttClient } from "mqtt";
import { errorResponse } from "../messages/build.js";
import { AlexaErrors } from "../messages/errors.js";
import type { AlexaError } from "../messages/errors.js";
import type { ErrorResponseMessage } from "../messages/types.js";
/** An ErrorResponse as 1.x builds it: device.getErrorMessage(token), setErrorMessage(), send(). */
export class AlexaErrorResponse {
// Sent when setErrorMessage() was not called: 1.x published an empty payload, which is no ErrorResponse to Alexa
private error: AlexaError = AlexaErrors.of("INTERNAL_ERROR", "the device answered with an error and did not say which");
private namespace?: string;
// One id for the message, however often toJSON() is called
private readonly messageId = randomUUID();
/** Where a failed publish is reported (set by the Device that built this message, 1.5.2): send() never rejects. */
public onPublishError?: (err: Error) => void;
constructor(
private readonly correlationToken: string,
private readonly rootTopic: string,
private readonly endpointId: string,
private readonly mqttClient: MqttClient
) {}
/**
* The error to answer with. otherParams are the fields the type adds to the payload (validRange). The header
* carries the namespace the type is documented under, Alexa.ThermostatController for THERMOSTAT_IS_OFF;
* options.namespace names another.
*/
setErrorMessage(
type: string,
message: string,
otherParams: Record<string, any> = {},
options: { namespace?: string } = {}
): void {
this.error = AlexaErrors.of(type, message, otherParams);
this.namespace = options.namespace;
}
toJSON(): ErrorResponseMessage {
const { type, alexaMessage, extra } = this.error;
return errorResponse({
endpointId: this.endpointId,
messageId: this.messageId,
correlationToken: this.correlationToken,
type,
message: alexaMessage,
extra,
namespace: this.namespace ?? this.error.namespace,
});
}
/** Resolves with the topic published to, or "" when the publish failed. Never rejects. */
send(sendAsync = false): Promise<string> {
const topic = `${this.rootTopic}/${this.endpointId}/${sendAsync ? "deferredResponse" : "alexaResponce"}`; // "alexaResponce" is how Alex2MQTT and Alex2ESP spell the topic
return new Promise((resolve) => {
this.mqttClient.publish(topic, JSON.stringify(this.toJSON()), (err) => {
if (!err) return resolve(topic);
if (this.onPublishError) this.onPublishError(err); // -> the bridge's "error" event (when somebody listens)
resolve(""); // 1.5.1 rejected here, and an un-caught send() then killed the host on any broker hiccup
});
});
}
}