// The errors a device answers a directive with (alexa-errorresponse.html): a helper for each type of the table. // The helper of a type whose payload has fields of its own takes them, so that they cannot be misspelt or left out. import { ERROR_TYPES } from "../registry/catalog.js"; import type { ErrorType } from "../registry/catalog.js"; import type { Temperature } from "../registry/schema.js"; /** * The namespace an ErrorResponse of this type goes under: the one the table of error types lists it with * (THERMOSTAT_IS_OFF: Alexa.ThermostatController, OBSTACLE_DETECTED: Alexa.Safety), Alexa for a type the table * does not have. */ export function errorNamespace(type: string): string { return Object.prototype.hasOwnProperty.call(ERROR_TYPES, type) ? ERROR_TYPES[type] : "Alexa"; } /** Thrown by a directive handler, or built to be sent: the directive is answered with this ErrorResponse. */ export class AlexaError extends Error { /** "ENDPOINT_UNREACHABLE" */ readonly type: string; /** payload.message: for the log of the skill, Alexa does not read it to the user. */ readonly alexaMessage: string; /** The fields of the payload next to type and message. */ readonly extra: Record; /** The namespace of the header. */ readonly namespace: string; constructor(type: string, message: string, extra: Record = {}, namespace: string = errorNamespace(type)) { super(`${type}: ${message}`); this.name = "AlexaError"; this.type = type; this.alexaMessage = message; this.extra = extra; this.namespace = namespace; } } // alexa-errorresponse.html, "Properties and objects": the values a field takes const CURRENT_DEVICE_MODES = ["ASLEEP", "COLOR", "DEMAND_RESPONSE", "ECO", "NOT_PROVISIONED", "VACATION", "OTHER"] as const; const REASONS = ["DEEP_SLEEP_MODE", "OUT_OF_NETWORK_CONNECTIVITY", "NO_CONNECTIVITY_PACKAGE_ENABLED", "UNKNOWN"] as const; const CHARGE_STATES = ["ALREADY_CHARGED_TO_REQUIRED_LEVEL", "CURRENTLY_CHARGING", "FULLY_CHARGED", "NOT_CONNECTED_TO_POWER"] as const; const RESOURCE_TYPES = ["WATER"] as const; const MAINTENANCE_ACTIONS = ["EMPTY_BIN"] as const; export type CurrentDeviceMode = (typeof CURRENT_DEVICE_MODES)[number]; export type ControlUnavailableReason = (typeof REASONS)[number]; export type ChargeState = (typeof CHARGE_STATES)[number]; export type ResourceType = (typeof RESOURCE_TYPES)[number]; export type MaintenanceAction = (typeof MAINTENANCE_ACTIONS)[number]; /** The acceptable values of a setting: both ends, as numbers or as temperatures. */ export interface ValidRange { minimumValue: T; maximumValue: T; } /** A sensor that is open while the panel is armed: Alexa says the name, and bypasses the ones with an endpointId. */ export interface EndpointNeedingBypass { friendlyName: string; endpointId?: string; } // A field that was not given is left out of the payload function given(fields: Record): Record { return Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)); } // TypeScript refuses a call without a required field. A JavaScript caller hears of it here, where the helper is // called: the payload would reach Alexa without the field. function required(helper: string, field: string, value: T, values: readonly T[]): T { if (values.includes(value)) return value; throw new TypeError(`AlexaErrors.${helper}() got ${JSON.stringify(value)} as ${field}: pass one of ${values.join(", ")}`); } function range(helper: string, validRange: ValidRange | undefined): ValidRange | undefined { if (validRange === undefined) return undefined; const { minimumValue, maximumValue } = validRange; if (minimumValue === undefined || maximumValue === undefined) { throw new TypeError(`AlexaErrors.${helper}() got a validRange without ${minimumValue === undefined ? "minimumValue" : "maximumValue"}: pass both ends, or no range`); } return { minimumValue, maximumValue }; } // The helpers of the types whose payload has fields next to type and message. A required field is a required // argument; a field the page calls optional can be left out. const withFields = { /** The value is outside what the endpoint takes. For a temperature: temperatureValueOutOfRange(). */ valueOutOfRange(message: string, validRange?: ValidRange): AlexaError { return new AlexaError("VALUE_OUT_OF_RANGE", message, given({ validRange: range("valueOutOfRange", validRange) })); }, temperatureValueOutOfRange(message: string, validRange?: ValidRange): AlexaError { const helper = "temperatureValueOutOfRange"; return new AlexaError("TEMPERATURE_VALUE_OUT_OF_RANGE", message, given({ validRange: range(helper, validRange) })); }, /** A light showing a color asked for a color temperature: "COLOR". */ notSupportedInCurrentMode(message: string, currentDeviceMode: CurrentDeviceMode): AlexaError { const mode = required("notSupportedInCurrentMode", "currentDeviceMode", currentDeviceMode, CURRENT_DEVICE_MODES); return new AlexaError("NOT_SUPPORTED_IN_CURRENT_MODE", message, { currentDeviceMode: mode }); }, /** percentageState: what is left of the battery, 0 to 100. */ endpointLowPower(message: string, percentageState?: number): AlexaError { return new AlexaError("ENDPOINT_LOW_POWER", message, given({ percentageState })); }, endpointControlUnavailable(message: string, reason: ControlUnavailableReason): AlexaError { return new AlexaError("ENDPOINT_CONTROL_UNAVAILABLE", message, { reason: required("endpointControlUnavailable", "reason", reason, REASONS), }); }, /** currentChargeLevelInPercentage: the battery level, 0 to 100. */ notSupportedWithCurrentBatteryChargeState( message: string, currentChargeState: ChargeState, currentChargeLevelInPercentage?: number ): AlexaError { const helper = "notSupportedWithCurrentBatteryChargeState"; return new AlexaError("NOT_SUPPORTED_WITH_CURRENT_BATTERY_CHARGE_STATE", message, given({ currentChargeState: required(helper, "currentChargeState", currentChargeState, CHARGE_STATES), currentChargeLevelInPercentage, })); }, /** What has to be refilled. */ insufficientResource(message: string, resourceType: ResourceType): AlexaError { return new AlexaError("INSUFFICIENT_RESOURCE", message, { resourceType: required("insufficientResource", "resourceType", resourceType, RESOURCE_TYPES), }); }, /** What the user has to do first. */ maintenanceRequired(message: string, maintenanceAction?: MaintenanceAction): AlexaError { const action = maintenanceAction === undefined ? undefined : required("maintenanceRequired", "maintenanceAction", maintenanceAction, MAINTENANCE_ACTIONS); return new AlexaError("MAINTENANCE_REQUIRED", message, given({ maintenanceAction: action })); }, /** Goes under Alexa.ThermostatController. minimumTemperatureDelta: how far apart the setpoints have to be. */ requestedSetpointsTooClose(message: string, minimumTemperatureDelta?: Temperature): AlexaError { return new AlexaError("REQUESTED_SETPOINTS_TOO_CLOSE", message, given({ minimumTemperatureDelta })); }, /** Goes under Alexa.SecurityPanelController. With the endpoints listed, the user can bypass them by voice. */ bypassNeeded(message: string, endpointsNeedingBypass?: EndpointNeedingBypass[]): AlexaError { return new AlexaError("BYPASS_NEEDED", message, given({ endpointsNeedingBypass })); }, }; // "NOT_IN_OPERATION" -> "notInOperation", for the compiler and at run time type CamelCase = S extends `${infer First}_${infer Rest}` ? `${Lowercase}${Capitalize>}` : Lowercase; const camelCase = (type: string): string => type.toLowerCase().replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()); /** The helpers of the types whose payload is the type and the message: AlexaErrors.endpointUnreachable(message). */ export type PlainErrorHelpers = { [T in ErrorType as CamelCase extends keyof typeof withFields ? never : CamelCase]: (message: string) => AlexaError; }; const plain = Object.fromEntries( Object.keys(ERROR_TYPES) .filter((type) => !Object.prototype.hasOwnProperty.call(withFields, camelCase(type))) .map((type) => [camelCase(type), (message: string): AlexaError => new AlexaError(type, message)]) ) as PlainErrorHelpers; /** * An error for every type of the table of alexa-errorresponse.html, under the name of the type in camel case: * * throw AlexaErrors.endpointUnreachable("the lamp does not answer"); * throw AlexaErrors.valueOutOfRange("the lift goes from 0 to 100", { minimumValue: 0, maximumValue: 100 }); * throw AlexaErrors.thermostatIsOff("the thermostat is off"); // sent under Alexa.ThermostatController */ export const AlexaErrors = { ...plain, ...withFields, /** An error of any type, one the table does not have too. */ of(type: string, message: string, extra: Record = {}): AlexaError { return new AlexaError(type, message, extra); }, /** temperatureValueOutOfRange() under its shorter name. */ temperatureOutOfRange: withFields.temperatureValueOutOfRange, /** requestedSetpointsTooClose() under its shorter name. */ setpointsTooClose: withFields.requestedSetpointsTooClose, };