examples: one runnable file per recipe on the 2.0 API, the 1.x files in examples/legacy
Ten recipes: lamp, colour lamp, thermostat, blind, lock (deferred), contact sensor (ChangeReport), scene, doorbell (raise), typed errors, and a plug as an ES module. Each reads ALEX2MQTT_USERNAME, _PASSWORD and _ROOT_TOPIC and names the ones that are missing. The nine examples of 1.5.2 move to examples/legacy unchanged. test/examples.test.js starts every file with node, its broker connection sent to a broker on 127.0.0.1 (test/helpers/loopback.js), discovers the device, sends directives and checks the answers. A legacy file is started and its discovery compared with the answer the 1.5.2 build gave, recorded in test/fixtures/legacy-examples. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2cbcde563e
commit
8379a65d78
25 changed files with 1705 additions and 0 deletions
42
examples/README.md
Normal file
42
examples/README.md
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# Examples
|
||||
|
||||
One file per recipe, on the 2.0 API. Each file is complete: it declares one device, answers its directives and
|
||||
connects. The state of the device is an object in the file, which you replace with what drives your device.
|
||||
|
||||
| File | Device | Interfaces | Shows |
|
||||
|---|---|---|---|
|
||||
| [lamp.js](lamp.js) | lamp | PowerController, BrightnessController | handlers, `device.state()`, `ctx.respond()` |
|
||||
| [color-lamp.js](color-lamp.js) | colour lamp | + ColorController, ColorTemperatureController | a state that depends on the mode of the device |
|
||||
| [thermostat.js](thermostat.js) | thermostat | ThermostatController, TemperatureSensor | one setpoint, three modes, temperature scales |
|
||||
| [blind.js](blind.js) | roller blind | RangeController | an instance, friendly names, semantics for open and close |
|
||||
| [lock.js](lock.js) | lock | LockController | `ctx.defer()` for an answer that takes longer than 7 seconds |
|
||||
| [sensor.js](sensor.js) | contact sensor | ContactSensor | `device.changeReport()` |
|
||||
| [scene.js](scene.js) | scene | SceneController | ActivationStarted and DeactivationStarted |
|
||||
| [doorbell.js](doorbell.js) | doorbell | DoorbellEventSource | `device.raise()` |
|
||||
| [errors.js](errors.js) | fan | PowerController, RangeController | `AlexaErrors`, `ctx.error()` |
|
||||
| [plug.mjs](plug.mjs) | plug | PowerController | the library as an ES module |
|
||||
|
||||
## Run one
|
||||
|
||||
The examples read the MQTT user name, the password and the root topic of your Alex2MQTT account from the
|
||||
environment, and say which variable is missing:
|
||||
|
||||
```sh
|
||||
ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lamp.js
|
||||
```
|
||||
|
||||
In a checkout of this repository `npm install` comes first: it builds `dist/`, which `require("alex2node")` resolves
|
||||
to. Then discover the devices in the Alexa app.
|
||||
|
||||
`sensor.js` and `doorbell.js` wait for the Enter key: it opens and closes the door, and rings the bell.
|
||||
`lock.js` takes `BOLT_SECONDS`, the time its bolt needs, 8 by default.
|
||||
|
||||
## What was tested
|
||||
|
||||
`test/examples.test.js` starts every file with `node`, against a broker on 127.0.0.1, discovers the device, sends
|
||||
it directives and checks the answers. The examples were not run against an Alexa account. `doorbell.js` publishes
|
||||
to `<root>/event`, and whether a press reaches Alexa depends on the Alex2MQTT service relaying that topic.
|
||||
|
||||
## 1.x
|
||||
|
||||
[legacy/](legacy) has the nine examples of 1.5.2, on the 1.x API.
|
||||
54
examples/blind.js
Normal file
54
examples/blind.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// A roller blind: Alexa.RangeController with the position in percent, 0 closed and 100 open. The semantics say
|
||||
// what "open", "close", "raise" and "lower" do, and which positions count as open and as closed.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/blind.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, RangeController, asset, text, semantics } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
const blind = { position: 0 };
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "bedroom-blind", name: "Bedroom Blind", categories: ["INTERIOR_BLIND"] });
|
||||
const lift = device.add(RangeController, {
|
||||
instance: "Blind.Lift",
|
||||
friendlyNames: [asset("Alexa.Setting.Opening"), text("Position", "en-US")],
|
||||
range: { min: 0, max: 100, precision: 1 },
|
||||
unit: "Alexa.Unit.Percent",
|
||||
semantics: semantics()
|
||||
.action("Close", "SetRangeValue", { rangeValue: 0 })
|
||||
.action("Open", "SetRangeValue", { rangeValue: 100 })
|
||||
.action("Lower", "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false })
|
||||
.action("Raise", "AdjustRangeValue", { rangeValueDelta: 10, rangeValueDeltaDefault: false })
|
||||
.state("Closed", 0)
|
||||
.stateRange("Open", 1, 100),
|
||||
});
|
||||
|
||||
// lift carries the instance: the property is reported under "Blind.Lift"
|
||||
device.state((s) => s.set(lift, "rangeValue", blind.position).health("OK"));
|
||||
|
||||
const within = (position) => Math.min(100, Math.max(0, Math.round(position)));
|
||||
|
||||
lift.on("SetRangeValue", (ctx) => {
|
||||
blind.position = within(ctx.payload.rangeValue);
|
||||
return ctx.respond();
|
||||
});
|
||||
// rangeValueDeltaDefault: the user named no amount, and the blind picks its own step
|
||||
lift.on("AdjustRangeValue", (ctx) => {
|
||||
const { rangeValueDelta, rangeValueDeltaDefault } = ctx.payload;
|
||||
const delta = rangeValueDeltaDefault ? Math.sign(rangeValueDelta) * 10 : rangeValueDelta;
|
||||
blind.position = within(blind.position + delta);
|
||||
return ctx.respond();
|
||||
});
|
||||
|
||||
bridge.connect();
|
||||
76
examples/color-lamp.js
Normal file
76
examples/color-lamp.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// A lamp that shows a colour or a white: Alexa.ColorController and Alexa.ColorTemperatureController, with power
|
||||
// and brightness. It reports the colour or the colour temperature, whichever it shows.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/color-lamp.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, AlexaErrors, PowerController, BrightnessController, ColorController, ColorTemperatureController } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
// The whites Alexa asks for by name: warm, soft, white, daylight, cool
|
||||
const WHITES = [2200, 2700, 4000, 5500, 7000];
|
||||
// color is null while the lamp shows a white
|
||||
const lamp = { on: false, brightness: 100, color: null, kelvin: 2700 };
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "floor-lamp", name: "Floor Lamp", categories: ["LIGHT"] });
|
||||
const power = device.add(PowerController);
|
||||
const brightness = device.add(BrightnessController);
|
||||
const color = device.add(ColorController);
|
||||
const white = device.add(ColorTemperatureController);
|
||||
|
||||
device.state((s) => {
|
||||
s.set(power, "powerState", lamp.on ? "ON" : "OFF").set(brightness, "brightness", lamp.brightness);
|
||||
if (lamp.color) s.set(color, "color", { ...lamp.color, brightness: lamp.brightness / 100 });
|
||||
else s.set(white, "colorTemperatureInKelvin", lamp.kelvin);
|
||||
s.health("OK");
|
||||
});
|
||||
|
||||
power.on("TurnOn", (ctx) => {
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
power.on("TurnOff", (ctx) => {
|
||||
lamp.on = false;
|
||||
return ctx.respond();
|
||||
});
|
||||
brightness.on("SetBrightness", (ctx) => {
|
||||
lamp.brightness = ctx.payload.brightness;
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
// SetColor comes with brightness 1 whatever the lamp shows: the lamp takes hue and saturation and keeps its brightness
|
||||
color.on("SetColor", (ctx) => {
|
||||
const { hue, saturation } = ctx.payload.color;
|
||||
lamp.color = { hue, saturation };
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
white.on("SetColorTemperature", (ctx) => {
|
||||
lamp.kelvin = Math.min(7000, Math.max(2200, ctx.payload.colorTemperatureInKelvin));
|
||||
lamp.color = null;
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
// "Warmer" and "cooler" have no meaning while the lamp shows a colour. A handler that throws answers with the error.
|
||||
function step(by) {
|
||||
return (ctx) => {
|
||||
if (lamp.color) throw AlexaErrors.notSupportedInCurrentMode("The lamp shows a colour", "COLOR");
|
||||
const next = WHITES.indexOf(WHITES.find((kelvin) => kelvin >= lamp.kelvin) ?? 7000) + by;
|
||||
lamp.kelvin = WHITES[Math.min(WHITES.length - 1, Math.max(0, next))];
|
||||
return ctx.respond();
|
||||
};
|
||||
}
|
||||
white.on("IncreaseColorTemperature", step(1));
|
||||
white.on("DecreaseColorTemperature", step(-1));
|
||||
|
||||
bridge.connect();
|
||||
40
examples/doorbell.js
Normal file
40
examples/doorbell.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// A doorbell: Alexa.DoorbellEventSource. It takes no directive and reports no state of the button;
|
||||
// device.raise() says that somebody rang. Press Enter to ring.
|
||||
//
|
||||
// The event is published to <root>/event. Not tested with an Alexa account yet: whether the press reaches Alexa
|
||||
// depends on the Alex2MQTT service relaying that topic. Alexa wants 30 seconds between two presses of one doorbell.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/doorbell.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, DoorbellEventSource } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "front-doorbell", name: "Front Doorbell", categories: ["DOORBELL"] });
|
||||
device.add(DoorbellEventSource);
|
||||
|
||||
device.state((s) => s.health("OK"));
|
||||
|
||||
let rangAt = 0;
|
||||
process.stdin.on("data", async () => {
|
||||
if (Date.now() - rangAt < 30000) {
|
||||
console.log("not sent: the last press is less than 30 seconds ago");
|
||||
return;
|
||||
}
|
||||
rangAt = Date.now();
|
||||
// raise() throws for an event the device did not declare, and resolves with what became of the publish
|
||||
const result = await device.raise(DoorbellEventSource, "DoorbellPress");
|
||||
console.log(result.ok ? "the press was sent" : `not sent: ${result.error.message}`);
|
||||
});
|
||||
|
||||
bridge.connect();
|
||||
61
examples/errors.js
Normal file
61
examples/errors.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// A fan that says no: the error types of Alexa as answers. A handler throws an AlexaError of AlexaErrors, or
|
||||
// answers with ctx.error(). Anything else a handler throws is answered with INTERNAL_ERROR, and a directive without
|
||||
// a handler with INVALID_DIRECTIVE.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/errors.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, AlexaErrors, PowerController, RangeController, text } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
const SPEEDS = { min: 1, max: 3 };
|
||||
const fan = { on: false, speed: 1 };
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "attic-fan", name: "Attic Fan", categories: ["FAN"] });
|
||||
const power = device.add(PowerController);
|
||||
const speed = device.add(RangeController, {
|
||||
instance: "Fan.Speed",
|
||||
friendlyNames: [text("Speed", "en-US")],
|
||||
range: { min: SPEEDS.min, max: SPEEDS.max, precision: 1 },
|
||||
});
|
||||
|
||||
device.state((s) => s
|
||||
.set(power, "powerState", fan.on ? "ON" : "OFF")
|
||||
.set(speed, "rangeValue", fan.speed)
|
||||
.health("OK"));
|
||||
|
||||
power.on("TurnOn", (ctx) => {
|
||||
fan.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
power.on("TurnOff", (ctx) => {
|
||||
fan.on = false;
|
||||
return ctx.respond();
|
||||
});
|
||||
speed.on("SetRangeValue", (ctx) => {
|
||||
const { rangeValue } = ctx.payload;
|
||||
// Without a throw: the type and the message
|
||||
if (!fan.on) return ctx.error("NOT_IN_OPERATION", "The fan is off");
|
||||
// With a throw: every type has a helper, and the helper of a type with fields of its own takes them
|
||||
if (!Number.isInteger(rangeValue) || rangeValue < SPEEDS.min || rangeValue > SPEEDS.max) {
|
||||
throw AlexaErrors.valueOutOfRange(
|
||||
`The fan has the speeds ${SPEEDS.min} to ${SPEEDS.max}`,
|
||||
{ minimumValue: SPEEDS.min, maximumValue: SPEEDS.max }
|
||||
);
|
||||
}
|
||||
fan.speed = rangeValue;
|
||||
return ctx.respond();
|
||||
});
|
||||
// AdjustRangeValue has no handler: the bridge answers it with INVALID_DIRECTIVE
|
||||
|
||||
bridge.connect();
|
||||
52
examples/lamp.js
Normal file
52
examples/lamp.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// A lamp that switches and dims: Alexa.PowerController and Alexa.BrightnessController.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lamp.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, PowerController, BrightnessController } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
// The lamp itself. Replace it with what drives yours.
|
||||
const lamp = { on: false, brightness: 100 };
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "desk-lamp", name: "Desk Lamp", categories: ["LIGHT"] });
|
||||
const power = device.add(PowerController);
|
||||
const brightness = device.add(BrightnessController);
|
||||
|
||||
// The whole state: the answer to ReportState, and the context of every ctx.respond()
|
||||
device.state((s) => s
|
||||
.set(power, "powerState", lamp.on ? "ON" : "OFF")
|
||||
.set(brightness, "brightness", lamp.brightness)
|
||||
.health("OK"));
|
||||
|
||||
power.on("TurnOn", (ctx) => {
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
power.on("TurnOff", (ctx) => {
|
||||
lamp.on = false;
|
||||
return ctx.respond();
|
||||
});
|
||||
// Both brightness directives turn a lamp on that is off
|
||||
brightness.on("SetBrightness", (ctx) => {
|
||||
lamp.brightness = ctx.payload.brightness;
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
brightness.on("AdjustBrightness", (ctx) => {
|
||||
lamp.brightness = Math.min(100, Math.max(0, lamp.brightness + ctx.payload.brightnessDelta));
|
||||
lamp.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
|
||||
bridge.connect();
|
||||
12
examples/legacy/README.md
Normal file
12
examples/legacy/README.md
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Examples of 1.5.2
|
||||
|
||||
The nine examples of alex2node 1.5.2, as they were. They run on 2.0 through its compat layer, which keeps the 1.x
|
||||
API: `registerDevice()`, `addCapability()`, the `Event` and `ReportState` listeners, `getStatusMessage()` and
|
||||
`getErrorMessage()`. `test/examples.test.js` starts each of them and compares its discovery answer with the one
|
||||
1.5.2 gave; 2.0 adds the `Alexa` interface at the end of the capabilities and changes nothing else in them.
|
||||
|
||||
They read `MQTT_USERNAME`, `MQTT_PASSWORD` and `MQTT_ROOT_TOPIC` from the environment or from a `.env` file, with
|
||||
`dotenv`, which is a development dependency of this repository.
|
||||
|
||||
For a new program use the examples one directory up. [docs/wire-changes.md](../../docs/wire-changes.md) lists what
|
||||
a 1.x program can observe of 2.0.
|
||||
49
examples/lock.js
Normal file
49
examples/lock.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// A lock whose bolt takes longer than the 7 seconds Alex2MQTT waits for an answer: Alexa.LockController. The
|
||||
// handler sends a DeferredResponse at once and the Response when the bolt has moved. Alex2MQTT waits 10 seconds for
|
||||
// the Response after a DeferredResponse.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/lock.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, LockController } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
// How long the bolt takes, in seconds
|
||||
const BOLT_SECONDS = Number(process.env.BOLT_SECONDS ?? 8);
|
||||
const lock = { state: "LOCKED" };
|
||||
|
||||
const moveBolt = (state) => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
lock.state = state;
|
||||
resolve();
|
||||
}, BOLT_SECONDS * 1000);
|
||||
});
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "front-door-lock", name: "Front Door", categories: ["SMARTLOCK"] });
|
||||
const bolt = device.add(LockController);
|
||||
|
||||
device.state((s) => s.set(bolt, "lockState", lock.state).health("OK"));
|
||||
|
||||
function move(state) {
|
||||
return async (ctx) => {
|
||||
// The argument is optional: how many seconds the answer will take
|
||||
await ctx.defer(Math.ceil(BOLT_SECONDS));
|
||||
await moveBolt(state);
|
||||
// After defer() the answer goes to <root>/<endpointId>/deferredResponse
|
||||
return ctx.respond();
|
||||
};
|
||||
}
|
||||
bolt.on("Lock", move("LOCKED"));
|
||||
bolt.on("Unlock", move("UNLOCKED"));
|
||||
|
||||
bridge.connect();
|
||||
35
examples/plug.mjs
Normal file
35
examples/plug.mjs
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
// A plug, as an ES module: the same library with import. Alexa.PowerController.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/plug.mjs
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
import { Alex2MQTT, PowerController } from "alex2node";
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
const plug = { on: false };
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "coffee-plug", name: "Coffee Machine", categories: ["SMARTPLUG"] });
|
||||
const power = device.add(PowerController);
|
||||
|
||||
device.state((s) => s.set(power, "powerState", plug.on ? "ON" : "OFF").health("OK"));
|
||||
|
||||
power.on("TurnOn", (ctx) => {
|
||||
plug.on = true;
|
||||
return ctx.respond();
|
||||
});
|
||||
power.on("TurnOff", (ctx) => {
|
||||
plug.on = false;
|
||||
return ctx.respond();
|
||||
});
|
||||
|
||||
bridge.connect();
|
||||
41
examples/scene.js
Normal file
41
examples/scene.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
// A scene: Alexa.SceneController. A scene is an endpoint that is not a device. It reports no state, and Activate
|
||||
// and Deactivate are answered with ActivationStarted and DeactivationStarted, which ctx.respond() sends.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/scene.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, SceneController } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
// What the scene sets. Replace it with your devices.
|
||||
const room = { lights: "bright", blind: "open" };
|
||||
|
||||
// Alexa wants the word "scene" in the description, and the category SCENE_TRIGGER or ACTIVITY_TRIGGER
|
||||
const device = bridge.addDevice({
|
||||
endpointId: "movie-night", name: "Movie Night", categories: ["SCENE_TRIGGER"],
|
||||
description: "Movie night scene by Alex2Node",
|
||||
});
|
||||
const scene = device.add(SceneController, { supportsDeactivation: true });
|
||||
|
||||
scene.on("Activate", (ctx) => {
|
||||
Object.assign(room, { lights: "dim", blind: "closed" });
|
||||
console.log("movie night on", room);
|
||||
return ctx.respond();
|
||||
});
|
||||
scene.on("Deactivate", (ctx) => {
|
||||
Object.assign(room, { lights: "bright", blind: "open" });
|
||||
console.log("movie night off", room);
|
||||
return ctx.respond();
|
||||
});
|
||||
|
||||
bridge.connect();
|
||||
40
examples/sensor.js
Normal file
40
examples/sensor.js
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// A contact sensor on a door: Alexa.ContactSensor. It takes no directive. It answers ReportState and sends a
|
||||
// ChangeReport when the door opens or closes. Press Enter to open and close the door.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/sensor.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, ContactSensor } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
// DETECTED is open: the two pieces of the sensor are apart
|
||||
const door = { contact: "NOT_DETECTED", changedAt: new Date() };
|
||||
|
||||
const device = bridge.addDevice({ endpointId: "back-door", name: "Back Door", categories: ["CONTACT_SENSOR"] });
|
||||
// proactivelyReported: Alexa takes ChangeReports for the capability
|
||||
const contact = device.add(ContactSensor, { proactivelyReported: true });
|
||||
|
||||
device.state((s) => s.set(contact, "detectionState", door.contact, { timeOfSample: door.changedAt }).health("OK"));
|
||||
|
||||
async function changed(state) {
|
||||
door.contact = state;
|
||||
door.changedAt = new Date();
|
||||
// What changed is set here. The rest of device.state(), the health, is the context of the report.
|
||||
const result = await device.changeReport("PHYSICAL_INTERACTION", (s) => s
|
||||
.set(contact, "detectionState", door.contact, { timeOfSample: door.changedAt }));
|
||||
console.log(result.ok ? `the door reported ${state}` : `not reported: ${result.error.message}`);
|
||||
}
|
||||
|
||||
process.stdin.on("data", () => changed(door.contact === "DETECTED" ? "NOT_DETECTED" : "DETECTED"));
|
||||
|
||||
bridge.connect();
|
||||
72
examples/thermostat.js
Normal file
72
examples/thermostat.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// A thermostat with one setpoint and the modes HEAT, COOL and OFF: Alexa.ThermostatController, and
|
||||
// Alexa.TemperatureSensor for the temperature of the room. It works in Celsius and takes Fahrenheit and Kelvin.
|
||||
//
|
||||
// ALEX2MQTT_USERNAME=... ALEX2MQTT_PASSWORD=... ALEX2MQTT_ROOT_TOPIC=... node examples/thermostat.js
|
||||
//
|
||||
// "alex2node" is the installed package; inside this checkout it resolves to the built dist/.
|
||||
const { Alex2MQTT, AlexaErrors, ThermostatController, TemperatureSensor } = require("alex2node");
|
||||
|
||||
const missing = ["ALEX2MQTT_USERNAME", "ALEX2MQTT_PASSWORD", "ALEX2MQTT_ROOT_TOPIC"].filter((name) => !process.env[name]);
|
||||
if (missing.length > 0) {
|
||||
console.error(`${missing.join(", ")} not set. Set the MQTT user name, the password and the root topic of your Alex2MQTT account.`);
|
||||
process.exit(1);
|
||||
}
|
||||
const { ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC } = process.env;
|
||||
|
||||
const bridge = new Alex2MQTT(ALEX2MQTT_USERNAME, ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC);
|
||||
bridge.on("connect", () => console.log("connected, discover the devices in the Alexa app"));
|
||||
bridge.on("error", (err) => console.error("bridge:", err.message));
|
||||
|
||||
const MODES = ["HEAT", "COOL", "OFF"];
|
||||
const RANGE = { min: 5, max: 30 };
|
||||
const thermostat = { mode: "HEAT", target: 21, room: 19.5 };
|
||||
|
||||
const celsius = (value) => ({ value, scale: "CELSIUS" });
|
||||
// A difference has no offset: 9 degrees Fahrenheit more are 5 degrees Celsius more
|
||||
const deltaInCelsius = ({ value, scale }) => (scale === "FAHRENHEIT" ? (value * 5) / 9 : value);
|
||||
function inCelsius({ value, scale }) {
|
||||
if (scale === "FAHRENHEIT") return ((value - 32) * 5) / 9;
|
||||
return scale === "KELVIN" ? value - 273.15 : value;
|
||||
}
|
||||
|
||||
const device = bridge.addDevice({
|
||||
endpointId: "hall-thermostat", name: "Hall Thermostat", categories: ["THERMOSTAT", "TEMPERATURE_SENSOR"],
|
||||
});
|
||||
const control = device.add(ThermostatController, { supportedModes: MODES, properties: ["targetSetpoint", "thermostatMode"] });
|
||||
const sensor = device.add(TemperatureSensor);
|
||||
|
||||
device.state((s) => s
|
||||
.set(control, "targetSetpoint", celsius(thermostat.target))
|
||||
.set(control, "thermostatMode", thermostat.mode)
|
||||
.set(sensor, "temperature", celsius(thermostat.room))
|
||||
.health("OK"));
|
||||
|
||||
function setTarget(ctx, target) {
|
||||
if (thermostat.mode === "OFF") throw AlexaErrors.thermostatIsOff("The thermostat is off");
|
||||
const rounded = Math.round(target * 2) / 2;
|
||||
if (rounded < RANGE.min || rounded > RANGE.max) {
|
||||
throw AlexaErrors.temperatureOutOfRange(
|
||||
`The thermostat takes ${RANGE.min} to ${RANGE.max} degrees Celsius`,
|
||||
{ minimumValue: celsius(RANGE.min), maximumValue: celsius(RANGE.max) }
|
||||
);
|
||||
}
|
||||
thermostat.target = rounded;
|
||||
return ctx.respond();
|
||||
}
|
||||
|
||||
control.on("SetTargetTemperature", (ctx) => {
|
||||
const { targetSetpoint, lowerSetpoint, upperSetpoint } = ctx.payload;
|
||||
if (!targetSetpoint || lowerSetpoint || upperSetpoint) {
|
||||
throw AlexaErrors.dualSetpointsUnsupported("The thermostat has one setpoint");
|
||||
}
|
||||
return setTarget(ctx, inCelsius(targetSetpoint));
|
||||
});
|
||||
control.on("AdjustTargetTemperature", (ctx) => setTarget(ctx, thermostat.target + deltaInCelsius(ctx.payload.targetSetpointDelta)));
|
||||
control.on("SetThermostatMode", (ctx) => {
|
||||
const mode = ctx.payload.thermostatMode.value;
|
||||
if (!MODES.includes(mode)) throw AlexaErrors.unsupportedThermostatMode(`The thermostat has no mode ${mode}`);
|
||||
thermostat.mode = mode;
|
||||
return ctx.respond();
|
||||
});
|
||||
|
||||
bridge.connect();
|
||||
292
test/examples.test.js
Normal file
292
test/examples.test.js
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"use strict";
|
||||
// The examples run: every file of examples/ is started with node as its reader starts it, is discovered, gets a
|
||||
// directive and answers it. The files of examples/legacy are the examples of 1.5.2 on the 1.x API: each is started
|
||||
// and its discovery answer compared with the one 1.5.2 gave.
|
||||
const { test } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { broker, watcher, directive, until } = require("./helpers/harness.js");
|
||||
const { run, ROOT } = require("./helpers/examples.js");
|
||||
|
||||
const EXAMPLES = path.join(ROOT, "examples");
|
||||
const LEGACY = path.join(EXAMPLES, "legacy");
|
||||
const CELSIUS = (value) => ({ value, scale: "CELSIUS" });
|
||||
|
||||
let shared;
|
||||
const local = () => (shared ??= broker());
|
||||
|
||||
// Start an example on a root topic of its own, and play Alexa for it
|
||||
async function start(file, env = {}) {
|
||||
const at = await local();
|
||||
const root = `examples-${file.replace(/\W/g, "-")}`;
|
||||
const alexa = await watcher(at.url, `${root}/#`);
|
||||
const example = await run(file, {
|
||||
broker: at,
|
||||
root,
|
||||
env: { ALEX2MQTT_USERNAME: "u", ALEX2MQTT_PASSWORD: "p", ALEX2MQTT_ROOT_TOPIC: root, ...env },
|
||||
});
|
||||
let sent = 0;
|
||||
// Send a directive and wait for the n answers to it on the topic
|
||||
const ask = async (endpointId, namespace, name, payload = {}, { instance, topic = "alexaResponce", answers = 1 } = {}) => {
|
||||
const token = `token-${++sent}`;
|
||||
const message = directive(namespace, name, endpointId, token, payload);
|
||||
if (instance) message.header.instance = instance;
|
||||
alexa.send(root, message);
|
||||
const mine = () => alexa.on(`${root}/${endpointId}/${topic}`).filter(({ event }) => event.header.correlationToken === token);
|
||||
await until(() => mine().length >= answers, 5000, `${file} to answer ${namespace}.${name}`);
|
||||
return mine()[answers - 1];
|
||||
};
|
||||
const discover = async () => {
|
||||
alexa.publish(`${root}/discover`, "{}");
|
||||
await until(() => alexa.on(`${root}/discover_r`).length > 0, 5000, `${file} to answer the discovery`);
|
||||
return alexa.on(`${root}/discover_r`)[0];
|
||||
};
|
||||
return { root, alexa, example, ask, discover };
|
||||
}
|
||||
|
||||
const interfaces = (endpoint) => endpoint.capabilities.map((capability) => capability.interface);
|
||||
const name = (answer) => `${answer.event.header.namespace}.${answer.event.header.name}`;
|
||||
// The context of an answer as { "<interface>.<property>": value }, the interface without "Alexa."
|
||||
const state = (answer) => Object.fromEntries(answer.context.properties
|
||||
.map((property) => [`${property.namespace.replace("Alexa.", "")}.${property.name}`, property.value]));
|
||||
|
||||
test("every file of examples/ is run by a test here", () => {
|
||||
const files = fs.readdirSync(EXAMPLES).filter((file) => /\.m?js$/.test(file)).sort();
|
||||
const source = fs.readFileSync(__filename, "utf8");
|
||||
const untested = files.filter((file) => !source.includes(`start("${file}"`));
|
||||
assert.deepEqual(untested, []);
|
||||
assert.equal(files.length, 10);
|
||||
});
|
||||
|
||||
test("an example without its environment says which variables to set, and ends", async () => {
|
||||
const example = await run("lamp.js", { env: { ALEX2MQTT_USERNAME: "u" } });
|
||||
assert.equal(await example.exited, 1);
|
||||
assert.match(example.output(), /^ALEX2MQTT_PASSWORD, ALEX2MQTT_ROOT_TOPIC not set\./);
|
||||
});
|
||||
|
||||
test("lamp.js: a lamp with power and brightness", async () => {
|
||||
const { discover, ask, example } = await start("lamp.js");
|
||||
const [endpoint] = await discover();
|
||||
assert.equal(endpoint.endpointId, "desk-lamp");
|
||||
assert.deepEqual(endpoint.displayCategories, ["LIGHT"]);
|
||||
assert.deepEqual(interfaces(endpoint), ["Alexa.PowerController", "Alexa.BrightnessController", "Alexa.EndpointHealth", "Alexa"]);
|
||||
assert.match(example.output(), /^connected/);
|
||||
|
||||
const on = await ask("desk-lamp", "Alexa.PowerController", "TurnOn");
|
||||
assert.equal(name(on), "Alexa.Response");
|
||||
assert.deepEqual(state(on), { "PowerController.powerState": "ON", "BrightnessController.brightness": 100, "EndpointHealth.connectivity": { value: "OK" } });
|
||||
const dimmed = await ask("desk-lamp", "Alexa.BrightnessController", "SetBrightness", { brightness: 40 });
|
||||
assert.equal(state(dimmed)["BrightnessController.brightness"], 40);
|
||||
const darker = await ask("desk-lamp", "Alexa.BrightnessController", "AdjustBrightness", { brightnessDelta: -50 });
|
||||
assert.equal(state(darker)["BrightnessController.brightness"], 0);
|
||||
const off = await ask("desk-lamp", "Alexa.PowerController", "TurnOff");
|
||||
assert.equal(state(off)["PowerController.powerState"], "OFF");
|
||||
const report = await ask("desk-lamp", "Alexa", "ReportState");
|
||||
assert.equal(name(report), "Alexa.StateReport");
|
||||
assert.deepEqual(state(report), { "PowerController.powerState": "OFF", "BrightnessController.brightness": 0, "EndpointHealth.connectivity": { value: "OK" } });
|
||||
});
|
||||
|
||||
test("color-lamp.js: a lamp that reports the colour or the white it shows", async () => {
|
||||
const { discover, ask } = await start("color-lamp.js");
|
||||
const [endpoint] = await discover();
|
||||
assert.deepEqual(interfaces(endpoint), [
|
||||
"Alexa.PowerController", "Alexa.BrightnessController", "Alexa.ColorController", "Alexa.ColorTemperatureController",
|
||||
"Alexa.EndpointHealth", "Alexa",
|
||||
]);
|
||||
|
||||
await ask("floor-lamp", "Alexa.BrightnessController", "SetBrightness", { brightness: 50 });
|
||||
const red = await ask("floor-lamp", "Alexa.ColorController", "SetColor", { color: { hue: 0, saturation: 1, brightness: 1 } });
|
||||
assert.deepEqual(state(red)["ColorController.color"], { hue: 0, saturation: 1, brightness: 0.5 });
|
||||
assert.equal("ColorTemperatureController.colorTemperatureInKelvin" in state(red), false);
|
||||
const refused = await ask("floor-lamp", "Alexa.ColorTemperatureController", "IncreaseColorTemperature");
|
||||
assert.equal(name(refused), "Alexa.ErrorResponse");
|
||||
assert.deepEqual(refused.event.payload, { type: "NOT_SUPPORTED_IN_CURRENT_MODE", message: "The lamp shows a colour", currentDeviceMode: "COLOR" });
|
||||
|
||||
const white = await ask("floor-lamp", "Alexa.ColorTemperatureController", "SetColorTemperature", { colorTemperatureInKelvin: 4000 });
|
||||
assert.equal(state(white)["ColorTemperatureController.colorTemperatureInKelvin"], 4000);
|
||||
assert.equal("ColorController.color" in state(white), false);
|
||||
const cooler = await ask("floor-lamp", "Alexa.ColorTemperatureController", "IncreaseColorTemperature");
|
||||
assert.equal(state(cooler)["ColorTemperatureController.colorTemperatureInKelvin"], 5500);
|
||||
const warmer = await ask("floor-lamp", "Alexa.ColorTemperatureController", "DecreaseColorTemperature");
|
||||
assert.equal(state(warmer)["ColorTemperatureController.colorTemperatureInKelvin"], 4000);
|
||||
});
|
||||
|
||||
test("thermostat.js: one setpoint, three modes, and the errors of a thermostat", async () => {
|
||||
const { discover, ask } = await start("thermostat.js");
|
||||
const [endpoint] = await discover();
|
||||
const [control] = endpoint.capabilities;
|
||||
assert.deepEqual(control.properties.supported, [{ name: "targetSetpoint" }, { name: "thermostatMode" }]);
|
||||
assert.deepEqual(control.configuration, { supportedModes: ["HEAT", "COOL", "OFF"], supportsScheduling: false });
|
||||
|
||||
const set = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: { value: 68, scale: "FAHRENHEIT" } });
|
||||
assert.deepEqual(state(set), {
|
||||
"ThermostatController.targetSetpoint": CELSIUS(20), "ThermostatController.thermostatMode": "HEAT",
|
||||
"TemperatureSensor.temperature": CELSIUS(19.5), "EndpointHealth.connectivity": { value: "OK" },
|
||||
});
|
||||
const warmer = await ask("hall-thermostat", "Alexa.ThermostatController", "AdjustTargetTemperature", { targetSetpointDelta: CELSIUS(2) });
|
||||
assert.deepEqual(state(warmer)["ThermostatController.targetSetpoint"], CELSIUS(22));
|
||||
|
||||
const hot = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: CELSIUS(40) });
|
||||
assert.equal(name(hot), "Alexa.ErrorResponse");
|
||||
assert.equal(hot.event.payload.type, "TEMPERATURE_VALUE_OUT_OF_RANGE");
|
||||
assert.deepEqual(hot.event.payload.validRange, { minimumValue: CELSIUS(5), maximumValue: CELSIUS(30) });
|
||||
const two = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { lowerSetpoint: CELSIUS(18), upperSetpoint: CELSIUS(24) });
|
||||
assert.equal(name(two), "Alexa.ThermostatController.ErrorResponse");
|
||||
assert.equal(two.event.payload.type, "DUAL_SETPOINTS_UNSUPPORTED");
|
||||
const eco = await ask("hall-thermostat", "Alexa.ThermostatController", "SetThermostatMode", { thermostatMode: { value: "ECO" } });
|
||||
assert.equal(eco.event.payload.type, "UNSUPPORTED_THERMOSTAT_MODE");
|
||||
|
||||
const off = await ask("hall-thermostat", "Alexa.ThermostatController", "SetThermostatMode", { thermostatMode: { value: "OFF" } });
|
||||
assert.equal(state(off)["ThermostatController.thermostatMode"], "OFF");
|
||||
const late = await ask("hall-thermostat", "Alexa.ThermostatController", "SetTargetTemperature", { targetSetpoint: CELSIUS(20) });
|
||||
assert.equal(late.event.payload.type, "THERMOSTAT_IS_OFF");
|
||||
});
|
||||
|
||||
test("blind.js: a position in percent, with open and close as semantics", async () => {
|
||||
const { discover, ask } = await start("blind.js");
|
||||
const [endpoint] = await discover();
|
||||
const [lift] = endpoint.capabilities;
|
||||
assert.equal(lift.instance, "Blind.Lift");
|
||||
assert.deepEqual(lift.semantics.actionMappings.map(({ actions, directive: { name: to, payload } }) => [actions, to, payload]), [
|
||||
[["Alexa.Actions.Close"], "SetRangeValue", { rangeValue: 0 }],
|
||||
[["Alexa.Actions.Open"], "SetRangeValue", { rangeValue: 100 }],
|
||||
[["Alexa.Actions.Lower"], "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false }],
|
||||
[["Alexa.Actions.Raise"], "AdjustRangeValue", { rangeValueDelta: 10, rangeValueDeltaDefault: false }],
|
||||
]);
|
||||
assert.equal(lift.semantics.stateMappings.length, 2);
|
||||
|
||||
const open = await ask("bedroom-blind", "Alexa.RangeController", "SetRangeValue", { rangeValue: 100 }, { instance: "Blind.Lift" });
|
||||
assert.equal(name(open), "Alexa.Response");
|
||||
const [position] = open.context.properties;
|
||||
assert.deepEqual([position.namespace, position.instance, position.name, position.value], ["Alexa.RangeController", "Blind.Lift", "rangeValue", 100]);
|
||||
const lower = await ask("bedroom-blind", "Alexa.RangeController", "AdjustRangeValue", { rangeValueDelta: -10, rangeValueDeltaDefault: false }, { instance: "Blind.Lift" });
|
||||
assert.equal(state(lower)["RangeController.rangeValue"], 90);
|
||||
});
|
||||
|
||||
test("lock.js: a DeferredResponse at once, the Response when the bolt has moved", async () => {
|
||||
const { discover, ask, alexa, root } = await start("lock.js", { BOLT_SECONDS: "0.3" });
|
||||
const [endpoint] = await discover();
|
||||
assert.deepEqual(interfaces(endpoint), ["Alexa.LockController", "Alexa.EndpointHealth", "Alexa"]);
|
||||
|
||||
const unlocked = await ask("front-door-lock", "Alexa.LockController", "Unlock", {}, { topic: "deferredResponse" });
|
||||
assert.equal(name(unlocked), "Alexa.Response");
|
||||
assert.equal(state(unlocked)["LockController.lockState"], "UNLOCKED");
|
||||
const [deferred, ...more] = alexa.on(`${root}/front-door-lock/alexaResponce`);
|
||||
assert.equal(name(deferred), "Alexa.DeferredResponse");
|
||||
assert.deepEqual(deferred.event.payload, { estimatedDeferralInSeconds: 1 });
|
||||
assert.equal(deferred.event.header.correlationToken, unlocked.event.header.correlationToken);
|
||||
assert.deepEqual(more, []);
|
||||
});
|
||||
|
||||
test("sensor.js: a ChangeReport when the door opens, and the state for ReportState", async () => {
|
||||
const { discover, ask, alexa, root, example } = await start("sensor.js");
|
||||
const [endpoint] = await discover();
|
||||
assert.deepEqual(endpoint.capabilities[0].properties, { supported: [{ name: "detectionState" }], proactivelyReported: true, retrievable: true });
|
||||
|
||||
example.write("\n");
|
||||
await until(() => alexa.on(`${root}/changeReport`).length === 1, 5000, "the ChangeReport");
|
||||
const [report] = alexa.on(`${root}/changeReport`);
|
||||
assert.equal(name(report), "Alexa.ChangeReport");
|
||||
assert.equal(report.event.endpoint.endpointId, "back-door");
|
||||
assert.equal(report.event.payload.change.cause.type, "PHYSICAL_INTERACTION");
|
||||
assert.deepEqual(report.event.payload.change.properties.map((property) => [property.namespace, property.name, property.value]),
|
||||
[["Alexa.ContactSensor", "detectionState", "DETECTED"]]);
|
||||
assert.deepEqual(state(report), { "EndpointHealth.connectivity": { value: "OK" } });
|
||||
await until(() => /the door reported DETECTED/.test(example.output()), 5000, "the line of the example");
|
||||
|
||||
const asked = await ask("back-door", "Alexa", "ReportState");
|
||||
assert.equal(state(asked)["ContactSensor.detectionState"], "DETECTED");
|
||||
assert.equal(asked.context.properties[0].timeOfSample, report.event.payload.change.properties[0].timeOfSample);
|
||||
});
|
||||
|
||||
test("scene.js: Activate is answered with ActivationStarted, Deactivate with DeactivationStarted", async () => {
|
||||
const { discover, ask } = await start("scene.js");
|
||||
const [endpoint] = await discover();
|
||||
assert.deepEqual(endpoint.displayCategories, ["SCENE_TRIGGER"]);
|
||||
assert.deepEqual(interfaces(endpoint), ["Alexa.SceneController", "Alexa"]);
|
||||
assert.equal(endpoint.capabilities[0].supportsDeactivation, true);
|
||||
|
||||
const started = await ask("movie-night", "Alexa.SceneController", "Activate");
|
||||
assert.equal(name(started), "Alexa.SceneController.ActivationStarted");
|
||||
assert.equal(started.event.payload.cause.type, "VOICE_INTERACTION");
|
||||
const stopped = await ask("movie-night", "Alexa.SceneController", "Deactivate");
|
||||
assert.equal(name(stopped), "Alexa.SceneController.DeactivationStarted");
|
||||
});
|
||||
|
||||
test("doorbell.js: a press is raised as DoorbellPress, a second one within 30 seconds is not", async () => {
|
||||
const { discover, alexa, root, example } = await start("doorbell.js");
|
||||
const [endpoint] = await discover();
|
||||
assert.deepEqual(interfaces(endpoint), ["Alexa.DoorbellEventSource", "Alexa.EndpointHealth", "Alexa"]);
|
||||
|
||||
example.write("\n");
|
||||
await until(() => alexa.on(`${root}/event`).length === 1, 5000, "the DoorbellPress");
|
||||
const [press] = alexa.on(`${root}/event`);
|
||||
assert.equal(name(press), "Alexa.DoorbellEventSource.DoorbellPress");
|
||||
assert.equal(press.event.endpoint.endpointId, "front-doorbell");
|
||||
assert.equal(press.event.payload.cause.type, "PHYSICAL_INTERACTION");
|
||||
|
||||
example.write("\n");
|
||||
await until(() => /not sent: the last press/.test(example.output()), 5000, "the second press to be refused");
|
||||
assert.equal(alexa.on(`${root}/event`).length, 1);
|
||||
});
|
||||
|
||||
test("errors.js: the answers of a device that says no", async () => {
|
||||
const { discover, ask } = await start("errors.js");
|
||||
await discover();
|
||||
const speed = (rangeValue) => ask("attic-fan", "Alexa.RangeController", "SetRangeValue", { rangeValue }, { instance: "Fan.Speed" });
|
||||
|
||||
const stopped = await speed(2);
|
||||
assert.equal(name(stopped), "Alexa.ErrorResponse");
|
||||
assert.deepEqual(stopped.event.payload, { type: "NOT_IN_OPERATION", message: "The fan is off" });
|
||||
await ask("attic-fan", "Alexa.PowerController", "TurnOn");
|
||||
const fast = await speed(7);
|
||||
assert.deepEqual(fast.event.payload, {
|
||||
type: "VALUE_OUT_OF_RANGE", message: "The fan has the speeds 1 to 3", validRange: { minimumValue: 1, maximumValue: 3 },
|
||||
});
|
||||
assert.equal(state(await speed(3))["RangeController.rangeValue"], 3);
|
||||
const adjusted = await ask("attic-fan", "Alexa.RangeController", "AdjustRangeValue", { rangeValueDelta: 1, rangeValueDeltaDefault: false }, { instance: "Fan.Speed" });
|
||||
assert.equal(adjusted.event.payload.type, "INVALID_DIRECTIVE");
|
||||
});
|
||||
|
||||
test("plug.mjs: the library as an ES module", async () => {
|
||||
const { discover, ask } = await start("plug.mjs");
|
||||
const [endpoint] = await discover();
|
||||
assert.equal(endpoint.endpointId, "coffee-plug");
|
||||
assert.deepEqual(interfaces(endpoint), ["Alexa.PowerController", "Alexa.EndpointHealth", "Alexa"]);
|
||||
|
||||
const on = await ask("coffee-plug", "Alexa.PowerController", "TurnOn");
|
||||
assert.deepEqual(state(on), { "PowerController.powerState": "ON", "EndpointHealth.connectivity": { value: "OK" } });
|
||||
});
|
||||
|
||||
// examples/legacy
|
||||
|
||||
const before = require("./fixtures/legacy-examples/discovery-1.5.2.json");
|
||||
|
||||
// What 2.0 announces differently for a device of the 1.x API (docs/wire-changes.md): the Alexa interface at the end
|
||||
// of the capabilities. Without it the answer is the one of 1.5.2.
|
||||
function as152(endpoints) {
|
||||
return endpoints.map((endpoint) => {
|
||||
const last = endpoint.capabilities[endpoint.capabilities.length - 1];
|
||||
assert.deepEqual(last, { type: "AlexaInterface", interface: "Alexa", version: "3" });
|
||||
return { ...endpoint, capabilities: endpoint.capabilities.slice(0, -1) };
|
||||
});
|
||||
}
|
||||
|
||||
test("examples/legacy has the nine examples of 1.5.2", () => {
|
||||
assert.deepEqual(fs.readdirSync(LEGACY).filter((file) => file.endsWith(".js")).sort(), Object.keys(before).sort());
|
||||
assert.equal(Object.keys(before).length, 9);
|
||||
});
|
||||
|
||||
for (const file of Object.keys(before)) {
|
||||
test(`legacy/${file} runs on 2.0 and is discovered as on 1.5.2`, async () => {
|
||||
const at = await local();
|
||||
const root = `legacy-${file.replace(/\.js$/, "")}`;
|
||||
const alexa = await watcher(at.url, `${root}/#`);
|
||||
await run(file, { broker: at, root, directory: LEGACY, env: { MQTT_USERNAME: "u", MQTT_PASSWORD: "p", MQTT_ROOT_TOPIC: root } });
|
||||
alexa.publish(`${root}/discover`, "{}");
|
||||
await until(() => alexa.on(`${root}/discover_r`).length > 0, 5000, `${file} to answer the discovery`);
|
||||
|
||||
assert.deepEqual(as152(alexa.on(`${root}/discover_r`)[0]), before[file]);
|
||||
});
|
||||
}
|
||||
770
test/fixtures/legacy-examples/discovery-1.5.2.json
vendored
Normal file
770
test/fixtures/legacy-examples/discovery-1.5.2.json
vendored
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
{
|
||||
"BlindControl.js": [
|
||||
{
|
||||
"endpointId": "bedroom-blinds-1",
|
||||
"friendlyName": "Bedroom Blinds",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.PowerController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "powerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "NodeJS.Toggle",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Bedroom Blinds",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"semantics": {
|
||||
"actionMappings": [
|
||||
{
|
||||
"@type": "ActionsToDirective",
|
||||
"actions": [
|
||||
"Alexa.Actions.Close"
|
||||
],
|
||||
"directive": {
|
||||
"name": "TurnOn"
|
||||
}
|
||||
},
|
||||
{
|
||||
"@type": "ActionsToDirective",
|
||||
"actions": [
|
||||
"Alexa.Actions.Open"
|
||||
],
|
||||
"directive": {
|
||||
"name": "TurnOff"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExampleModeController.js": [
|
||||
{
|
||||
"endpointId": "fan-1",
|
||||
"friendlyName": "Test mode controller",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"OTHER"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.ModeController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "mode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"ordered": true,
|
||||
"supportedModes": [
|
||||
{
|
||||
"value": "mode.fanmode.Normal",
|
||||
"modeResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Normal",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"value": "mode.fanmode.Low",
|
||||
"modeResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Low",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "mode.fanmode",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Fan Mode",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExamplePowerController.js": [
|
||||
{
|
||||
"endpointId": "bedroom-light-1",
|
||||
"friendlyName": "Bedroom Light",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.PowerController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "powerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExamplePowerControllerDeferred.js": [
|
||||
{
|
||||
"endpointId": "deferred-light-1",
|
||||
"friendlyName": "Deferred Light",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.PowerController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "powerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExamplePowerControllerWithBrightness.js": [
|
||||
{
|
||||
"endpointId": "living-room-light-1",
|
||||
"friendlyName": "Living Room Light",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.PowerController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "powerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.BrightnessController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "brightness"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExamplePowerControllerWithErrors.js": [
|
||||
{
|
||||
"endpointId": "bad-light-1",
|
||||
"friendlyName": "bad light",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.PowerController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "powerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExamplePowerControllerWithMomentary.js": [
|
||||
{
|
||||
"endpointId": "kitchen-light-1",
|
||||
"friendlyName": "Kitchen Light",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.PowerController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "powerState"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ModeController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "mode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"ordered": true,
|
||||
"supportedModes": [
|
||||
{
|
||||
"value": "mode.signal.beep.Normal",
|
||||
"modeResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Normal",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"value": "mode.signal.beep.Beep",
|
||||
"modeResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Beep",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "mode.signal.beep",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Signal",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExampleThermostat.js": [
|
||||
{
|
||||
"endpointId": "thermostat-1",
|
||||
"friendlyName": "Thermostat Test",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"TEMPERATURE_SENSOR",
|
||||
"THERMOSTAT",
|
||||
"OTHER"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.ThermostatController",
|
||||
"version": "3.2",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "targetSetpoint"
|
||||
},
|
||||
{
|
||||
"name": "lowerSetpoint"
|
||||
},
|
||||
{
|
||||
"name": "upperSetpoint"
|
||||
},
|
||||
{
|
||||
"name": "thermostatMode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"supportedModes": [
|
||||
"HEAT",
|
||||
"COOL",
|
||||
"AUTO",
|
||||
"OFF"
|
||||
],
|
||||
"supportsScheduling": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.TemperatureSensor",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "temperature"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ModeController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "mode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
"ordered": true,
|
||||
"supportedModes": [
|
||||
{
|
||||
"value": "mode.fanmode.Auto",
|
||||
"modeResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Auto",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"value": "mode.fanmode.On",
|
||||
"modeResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "On",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "mode.fanmode",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Fan",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ExampleToggleController.js": [
|
||||
{
|
||||
"endpointId": "custom-device-1",
|
||||
"friendlyName": "Custom Device",
|
||||
"description": "Alexa to Node.js bridge",
|
||||
"manufacturerName": "Alex2Node",
|
||||
"displayCategories": [
|
||||
"LIGHT"
|
||||
],
|
||||
"additionalAttributes": {
|
||||
"manufacturer": "Alex2Node",
|
||||
"model": "Alex2Node_v1.0.0",
|
||||
"serialNumber": "Alex2Node",
|
||||
"firmwareVersion": "1.0.0",
|
||||
"softwareVersion": "1.0.0",
|
||||
"customIdentifier": "Alex2Node"
|
||||
},
|
||||
"capabilities": [
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch1",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 1",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch2",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 2",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch3",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 3",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch4",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 4",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch5",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 5",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch6",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 6",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch7",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 7",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"interface": "Alexa.ToggleController",
|
||||
"version": "3",
|
||||
"type": "AlexaInterface",
|
||||
"properties": {
|
||||
"retrievable": true,
|
||||
"proactivelyReported": false,
|
||||
"supported": [
|
||||
{
|
||||
"name": "toggleState"
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance": "nodejs.toggle.switch8",
|
||||
"capabilityResources": {
|
||||
"friendlyNames": [
|
||||
{
|
||||
"@type": "text",
|
||||
"value": {
|
||||
"text": "Switch 8",
|
||||
"locale": "en-US"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
53
test/helpers/examples.js
Normal file
53
test/helpers/examples.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
"use strict";
|
||||
// Runs a file of examples/ as its reader does, with node, in a process of its own. The process gets the environment
|
||||
// the test names and nothing else, a directory without a .env file, and test/helpers/loopback.js, which sends its
|
||||
// broker connection to the broker of the test.
|
||||
const { spawn } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { cleanup, until } = require("./harness.js");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const LOOPBACK = path.join(__dirname, "loopback.js");
|
||||
|
||||
/**
|
||||
* Start examples/<file>. broker is one of harness.broker(); without it the example can connect nowhere. Resolves
|
||||
* with { child, output, write, exited, stop } once the example has subscribed to <root>/discover or, without a
|
||||
* root, at once. output() is what it wrote to stdout and stderr so far.
|
||||
*/
|
||||
async function run(file, { broker, root, env = {}, directory = path.join(ROOT, "examples") } = {}) {
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "alex2node-example-"));
|
||||
let subscribed = false;
|
||||
const onSubscribe = (subscriptions) => {
|
||||
if (subscriptions.some(({ topic }) => topic === `${root}/discover` || topic === `${root}/#`)) subscribed = true;
|
||||
};
|
||||
if (broker && root) broker.aedes.on("subscribe", onSubscribe);
|
||||
|
||||
const child = spawn(process.execPath, ["--require", LOOPBACK, path.join(directory, file)], {
|
||||
cwd,
|
||||
env: { PATH: process.env.PATH, LOOPBACK_PORT: String(broker ? broker.port : 1), ...env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
let text = "";
|
||||
child.stdout.on("data", (chunk) => { text += chunk; });
|
||||
child.stderr.on("data", (chunk) => { text += chunk; });
|
||||
const exited = new Promise((resolve) => child.once("exit", (code) => resolve(code)));
|
||||
const stop = async () => {
|
||||
if (broker && root) broker.aedes.off("subscribe", onSubscribe);
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
await exited;
|
||||
fs.rmdirSync(cwd);
|
||||
};
|
||||
cleanup(stop);
|
||||
|
||||
const started = { child, output: () => text, write: (line) => child.stdin.write(line), exited, stop };
|
||||
if (broker && root) {
|
||||
await until(() => subscribed || child.exitCode !== null, 10000, `${file} to subscribe`);
|
||||
broker.aedes.off("subscribe", onSubscribe);
|
||||
if (!subscribed) throw new Error(`${file} ended before it subscribed:\n${text}`);
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
module.exports = { run, ROOT };
|
||||
16
test/helpers/loopback.js
Normal file
16
test/helpers/loopback.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"use strict";
|
||||
// Preloaded with node --require into an example that a test runs. An example connects to the broker of Alex2MQTT,
|
||||
// the test has one on 127.0.0.1: every TCP connection of the process goes to LOOPBACK_PORT on 127.0.0.1, whatever
|
||||
// host and port it names. Nothing of an example under test leaves the machine.
|
||||
const net = require("node:net");
|
||||
|
||||
const port = Number(process.env.LOOPBACK_PORT);
|
||||
if (!Number.isInteger(port) || port <= 0) throw new Error("LOOPBACK_PORT is not set: the port of the broker of the test");
|
||||
|
||||
const connect = net.Socket.prototype.connect;
|
||||
net.Socket.prototype.connect = function toLoopback(...args) {
|
||||
// connect(options, listener), connect(port, host, listener), or the two in an array as net.createConnection() passes them
|
||||
const given = Array.isArray(args[0]) ? args[0] : args;
|
||||
const listener = given.find((arg) => typeof arg === "function");
|
||||
return connect.call(this, { host: "127.0.0.1", port }, ...(listener ? [listener] : []));
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue