From 4eb2a84d72fbaa81625d063b326c3edfea1c0b59 Mon Sep 17 00:00:00 2001 From: David <43281665+chaos511@users.noreply.github.com> Date: Sun, 20 Apr 2025 21:19:57 +0000 Subject: [PATCH] initial release of Alex2Node --- dist/ActionMapping.js | 32 +++ dist/Alex2Node.js | 112 +++++++++++ dist/AlexaInterface.js | 291 +++++++++++++++++++++++++++ dist/AlexaStatusMessage.js | 105 ++++++++++ dist/Device.js | 98 +++++++++ dist/DisplayCategory.js | 62 ++++++ dist/index.js | 15 ++ examples/BlindControl.js | 109 ++++++++++ examples/ExamplePowerController.js | 80 ++++++++ package-lock.json | 56 ++++++ package.json | 19 +- readme.md | 181 +++++++++++++++++ src/ActionMapping.ts | 41 ++++ src/Alex2Node.ts | 131 ++++++++++++ src/AlexaInterface.ts | 307 +++++++++++++++++++++++++++++ src/AlexaStatusMessage.ts | 208 +++++++++++++++++++ src/Device.ts | 128 ++++++++++++ src/DisplayCategory.ts | 58 ++++++ src/index.ts | 4 + tsconfig.json | 13 ++ 20 files changed, 2047 insertions(+), 3 deletions(-) create mode 100644 dist/ActionMapping.js create mode 100644 dist/Alex2Node.js create mode 100644 dist/AlexaInterface.js create mode 100644 dist/AlexaStatusMessage.js create mode 100644 dist/Device.js create mode 100644 dist/DisplayCategory.js create mode 100644 dist/index.js create mode 100644 examples/BlindControl.js create mode 100644 examples/ExamplePowerController.js create mode 100644 package-lock.json create mode 100644 readme.md create mode 100644 src/ActionMapping.ts create mode 100644 src/Alex2Node.ts create mode 100644 src/AlexaInterface.ts create mode 100644 src/AlexaStatusMessage.ts create mode 100644 src/Device.ts create mode 100644 src/DisplayCategory.ts create mode 100644 src/index.ts create mode 100644 tsconfig.json diff --git a/dist/ActionMapping.js b/dist/ActionMapping.js new file mode 100644 index 0000000..879fa5b --- /dev/null +++ b/dist/ActionMapping.js @@ -0,0 +1,32 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.ActionMapping = exports.AlexaActions = void 0; +var AlexaActions; +(function (AlexaActions) { + AlexaActions["Open"] = "Alexa.Actions.Open"; + AlexaActions["Close"] = "Alexa.Actions.Close"; + AlexaActions["Raise"] = "Alexa.Actions.Raise"; + AlexaActions["Lower"] = "Alexa.Actions.Lower"; + AlexaActions["SetEcoOn"] = "Alexa.Actions.SetEcoOn"; + AlexaActions["SetEcoOff"] = "Alexa.Actions.SetEcoOff"; +})(AlexaActions || (exports.AlexaActions = AlexaActions = {})); +class ActionMapping { + constructor(actions, directiveName, directivePayload) { + this.type = "ActionsToDirective"; + this.actions = actions; + this.directive = { + name: directiveName, + }; + if (directivePayload) { + this.directive.payload = directivePayload; + } + } + toJSON() { + return { + "@type": this.type, + actions: this.actions, + directive: this.directive, + }; + } +} +exports.ActionMapping = ActionMapping; diff --git a/dist/Alex2Node.js b/dist/Alex2Node.js new file mode 100644 index 0000000..7e63d2e --- /dev/null +++ b/dist/Alex2Node.js @@ -0,0 +1,112 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +const mqtt_1 = __importDefault(require("mqtt")); +const Device_1 = __importDefault(require("./Device")); +class Alex2MQTT { + constructor(username, password, rootTopic, debugLogging) { + this.username = username; + this.password = password; + this.rootTopic = rootTopic; + this.debugLogging = debugLogging; + this.client = null; + this.devices = []; + this.MqttHost = "mqtt://Alex2MQTT.stormysdream.club"; + } + connect() { + const options = { + username: this.username, + password: this.password, + }; + this.client = mqtt_1.default.connect(this.MqttHost, options); + this.client.on("connect", () => { + if (this.debugLogging) { + console.log("[Alex2Node.ts] Connected to MQTT broker"); + } + this.client.subscribe(this.rootTopic + "/#", (err) => { + if (this.debugLogging && !err) { + console.log("[Alex2Node.ts] Subscribed to topics"); + } + if (err) { + throw err; + } + }); + }); + this.client.on("message", (topic, message) => { + if (this.debugLogging) { + console.log(`[Alex2Node.ts] MQTT Message Received`); + console.log(` Topic: ${topic}`); + console.log(` Payload: ${message.toString()}`); + } + if (topic == `${this.rootTopic}/discover`) { + if (this.debugLogging) { + console.log("[Alex2Node.ts] Discovery request received, getting device json..."); + } + const deviceArray = this.devices.map((device) => device.getJSON()); + if (deviceArray.length > 0) { + this.client.publish(topic + "_r", JSON.stringify(deviceArray), (err) => { + if (err) { + throw err; + } + else if (this.debugLogging) { + console.log(`[Alex2Node.ts] Discovery payloads published to ${topic + "_r"}`); + console.log(deviceArray); + } + }); + } + } + else if (topic.split("/").length == 3) { + const [root, endpointId, directiveType] = topic.split("/"); + if (directiveType != "alexaDirective") { + return; + } + const device = this.devices.find((d) => d.endpointId === endpointId); + if (!device) { + if (this.debugLogging) { + console.warn(`[Alex2Node.ts] No device found for endpointId: ${endpointId}`); + } + return; + } + let payload; + try { + payload = JSON.parse(message.toString()); + } + catch (e) { + console.error(`[Alex2Node.ts] Failed to parse payload JSON`, e); + return; + } + if (payload.header && + payload.header.namespace === "Alexa" && + payload.header.name === "ReportState") { + if (this.debugLogging) { + console.log(`[Alex2Node.ts] ReportState directive for device ${endpointId}`); + } + device.emit("ReportState", payload); + } + else { + device.emit("Event", payload, payload.header.namespace); + } + } + }); + this.client.on("error", (err) => { + if (this.debugLogging) { + console.error("[Alex2Node.ts] Connection failed:", err); + } + throw err; + }); + } + registerDevice(name, endpointId) { + if (!this.client) { + throw new Error("Must call connect before creating devices"); + } + if (this.debugLogging) { + console.log(`[Alex2Node.ts] Creating new device with endpoint: ${endpointId}`); + } + const device = new Device_1.default(this.client, this.rootTopic, name, endpointId); + this.devices.push(device); + return device; + } +} +exports.default = Alex2MQTT; diff --git a/dist/AlexaInterface.js b/dist/AlexaInterface.js new file mode 100644 index 0000000..452ed28 --- /dev/null +++ b/dist/AlexaInterface.js @@ -0,0 +1,291 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AlexaInterface = exports.AlexaInterfaceType = void 0; +var AlexaInterfaceType; +(function (AlexaInterfaceType) { + AlexaInterfaceType["APPLICATION_STATE_REPORTER"] = "Alexa.ApplicationStateReporter"; + AlexaInterfaceType["AUDIO_PLAY_QUEUE"] = "Alexa.Audio.PlayQueue"; + AlexaInterfaceType["AUTHORIZATION_CONTROLLER"] = "Alexa.AuthorizationController"; + AlexaInterfaceType["AUTOMATION_MANAGEMENT"] = "Alexa.AutomationManagement"; + AlexaInterfaceType["AUTOMOTIVE_VEHICLE_DATA"] = "Alexa.Automotive.VehicleData"; + AlexaInterfaceType["BRIGHTNESS_CONTROLLER"] = "Alexa.BrightnessController"; + AlexaInterfaceType["CAMERA_LIVE_VIEW_CONTROLLER"] = "Alexa.Camera.LiveViewController"; + AlexaInterfaceType["CAMERA_STREAM_CONTROLLER"] = "Alexa.CameraStreamController"; + AlexaInterfaceType["CHANNEL_CONTROLLER"] = "Alexa.ChannelController"; + AlexaInterfaceType["COLOR_CONTROLLER"] = "Alexa.ColorController"; + AlexaInterfaceType["COLOR_TEMPERATURE_CONTROLLER"] = "Alexa.ColorTemperatureController"; + AlexaInterfaceType["COMMISSIONABLE"] = "Alexa.Commissionable"; + AlexaInterfaceType["CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER"] = "Alexa.ConsentManagement.ConsentRequiredReporter"; + AlexaInterfaceType["CONTACT_SENSOR"] = "Alexa.ContactSensor"; + AlexaInterfaceType["COOKING"] = "Alexa.Cooking"; + AlexaInterfaceType["COOKING_FOOD_TEMPERATURE_CONTROLLER"] = "Alexa.Cooking.FoodTemperatureController"; + AlexaInterfaceType["COOKING_FOOD_TEMPERATURE_SENSOR"] = "Alexa.Cooking.FoodTemperatureSensor"; + AlexaInterfaceType["COOKING_PRESET_CONTROLLER"] = "Alexa.Cooking.PresetController"; + AlexaInterfaceType["COOKING_TEMPERATURE_CONTROLLER"] = "Alexa.Cooking.TemperatureController"; + AlexaInterfaceType["COOKING_TEMPERATURE_SENSOR"] = "Alexa.Cooking.TemperatureSensor"; + AlexaInterfaceType["COOKING_TIME_CONTROLLER"] = "Alexa.Cooking.TimeController"; + AlexaInterfaceType["DATA_CONTROLLER"] = "Alexa.DataController"; + AlexaInterfaceType["DEVICE_USAGE_ESTIMATION"] = "Alexa.DeviceUsage.Estimation"; + AlexaInterfaceType["DEVICE_USAGE_METER"] = "Alexa.DeviceUsage.Meter"; + AlexaInterfaceType["DOORBELL_EVENT_SOURCE"] = "Alexa.DoorbellEventSource"; + AlexaInterfaceType["ENDPOINT_HEALTH"] = "Alexa.EndpointHealth"; + AlexaInterfaceType["EQUALIZER_CONTROLLER"] = "Alexa.EqualizerController"; + AlexaInterfaceType["INPUT_CONTROLLER"] = "Alexa.InputController"; + AlexaInterfaceType["INVENTORY_LEVEL_SENSOR"] = "Alexa.InventoryLevelSensor"; + AlexaInterfaceType["INVENTORY_LEVEL_USAGE_SENSOR"] = "Alexa.InventoryLevelUsageSensor"; + AlexaInterfaceType["INVENTORY_USAGE_SENSOR"] = "Alexa.InventoryUsageSensor"; + AlexaInterfaceType["KEYPAD_CONTROLLER"] = "Alexa.KeypadController"; + AlexaInterfaceType["LAUNCHER"] = "Alexa.Launcher"; + AlexaInterfaceType["LOCK_CONTROLLER"] = "Alexa.LockController"; + AlexaInterfaceType["MEDIA_PLAYBACK"] = "Alexa.Media.Playback"; + AlexaInterfaceType["MEDIA_PLAY_QUEUE"] = "Alexa.Media.PlayQueue"; + AlexaInterfaceType["MEDIA_SEARCH"] = "Alexa.Media.Search"; + AlexaInterfaceType["MODE_CONTROLLER"] = "Alexa.ModeController"; + AlexaInterfaceType["MOTION_SENSOR"] = "Alexa.MotionSensor"; + AlexaInterfaceType["PERCENTAGE_CONTROLLER"] = "Alexa.PercentageController"; + AlexaInterfaceType["PLAYBACK_CONTROLLER"] = "Alexa.PlaybackController"; + AlexaInterfaceType["PLAYBACK_STATE_REPORTER"] = "Alexa.PlaybackStateReporter"; + AlexaInterfaceType["POWER_CONTROLLER"] = "Alexa.PowerController"; + AlexaInterfaceType["POWER_LEVEL_CONTROLLER"] = "Alexa.PowerLevelController"; + AlexaInterfaceType["PROACTIVE_NOTIFICATION_SOURCE"] = "Alexa.ProactiveNotificationSource"; + AlexaInterfaceType["RANGE_CONTROLLER"] = "Alexa.RangeController"; + AlexaInterfaceType["RECORD_CONTROLLER"] = "Alexa.RecordController"; + AlexaInterfaceType["REMOTE_VIDEO_PLAYER"] = "Alexa.RemoteVideoPlayer"; + AlexaInterfaceType["RTC_SESSION_CONTROLLER"] = "Alexa.RTCSessionController"; + AlexaInterfaceType["SCENE_CONTROLLER"] = "Alexa.SceneController"; + AlexaInterfaceType["SECURITY_PANEL_CONTROLLER"] = "Alexa.SecurityPanelController"; + AlexaInterfaceType["SECURITY_PANEL_CONTROLLER_ALERT"] = "Alexa.SecurityPanelController.Alert"; + AlexaInterfaceType["SEEK_CONTROLLER"] = "Alexa.SeekController"; + AlexaInterfaceType["SIMPLE_EVENT_SOURCE"] = "Alexa.SimpleEventSource"; + AlexaInterfaceType["SMART_VISION_OBJECT_DETECTION_SENSOR"] = "Alexa.SmartVision.ObjectDetectionSensor"; + AlexaInterfaceType["SMART_VISION_SNAPSHOT_PROVIDER"] = "Alexa.SmartVision.SnapshotProvider"; + AlexaInterfaceType["SPEAKER"] = "Alexa.Speaker"; + AlexaInterfaceType["STEP_SPEAKER"] = "Alexa.StepSpeaker"; + AlexaInterfaceType["TEMPERATURE_SENSOR"] = "Alexa.TemperatureSensor"; + AlexaInterfaceType["THERMOSTAT_CONTROLLER"] = "Alexa.ThermostatController"; + AlexaInterfaceType["THERMOSTAT_CONTROLLER_CONFIGURATION"] = "Alexa.ThermostatController.Configuration"; + AlexaInterfaceType["THERMOSTAT_CONTROLLER_HVAC_COMPONENTS"] = "Alexa.ThermostatController.HVAC.Components"; + AlexaInterfaceType["THERMOSTAT_CONTROLLER_SCHEDULE"] = "Alexa.ThermostatController.Schedule"; + AlexaInterfaceType["TIME_HOLD_CONTROLLER"] = "Alexa.TimeHoldController"; + AlexaInterfaceType["TOGGLE_CONTROLLER"] = "Alexa.ToggleController"; + AlexaInterfaceType["UI_CONTROLLER"] = "Alexa.UIController"; + AlexaInterfaceType["USER_PREFERENCE"] = "Alexa.UserPreference"; + AlexaInterfaceType["VIDEO_RECORDER"] = "Alexa.VideoRecorder"; + AlexaInterfaceType["WAKE_ON_LAN_CONTROLLER"] = "Alexa.WakeOnLANController"; + AlexaInterfaceType["UNKNOWN"] = "UNKNOWN"; +})(AlexaInterfaceType || (exports.AlexaInterfaceType = AlexaInterfaceType = {})); +class AlexaInterface { + constructor(type, retrievable = true, proactivelyReported = false, instance = "") { + this.type = type; + this.retrievable = retrievable; + this.proactivelyReported = proactivelyReported; + this.instance = instance; + this.friendlyNames = []; + this.actionMappings = []; + } + addActionMapping(mapping) { + this.actionMappings.push(mapping); + } + addFriendlyName(name, locale) { + this.friendlyNames.push({ text: name, locale }); + } + setInstance(name) { + this.instance = name; + } + getType() { + return this.type; + } + getTypeString() { + return this.type; + } + getVersion() { + switch (this.type) { + case AlexaInterfaceType.APPLICATION_STATE_REPORTER: + case AlexaInterfaceType.AUDIO_PLAY_QUEUE: + case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: + case AlexaInterfaceType.AUTOMATION_MANAGEMENT: + case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: + case AlexaInterfaceType.COMMISSIONABLE: + case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: + case AlexaInterfaceType.COOKING: + case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER: + case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR: + case AlexaInterfaceType.COOKING_PRESET_CONTROLLER: + case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER: + case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR: + case AlexaInterfaceType.COOKING_TIME_CONTROLLER: + case AlexaInterfaceType.DATA_CONTROLLER: + case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: + case AlexaInterfaceType.DEVICE_USAGE_METER: + case AlexaInterfaceType.EQUALIZER_CONTROLLER: + case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: + case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: + case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: + case AlexaInterfaceType.KEYPAD_CONTROLLER: + case AlexaInterfaceType.MEDIA_PLAYBACK: + case AlexaInterfaceType.MEDIA_PLAY_QUEUE: + case AlexaInterfaceType.MEDIA_SEARCH: + case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: + case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: + case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: + case AlexaInterfaceType.RTC_SESSION_CONTROLLER: + case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: + case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT: + case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: + case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: + case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: + case AlexaInterfaceType.SPEAKER: + case AlexaInterfaceType.STEP_SPEAKER: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: + case AlexaInterfaceType.UI_CONTROLLER: + case AlexaInterfaceType.USER_PREFERENCE: + case AlexaInterfaceType.VIDEO_RECORDER: + case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: + return "1"; + case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: + case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: + case AlexaInterfaceType.CHANNEL_CONTROLLER: + case AlexaInterfaceType.COLOR_CONTROLLER: + case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: + case AlexaInterfaceType.CONTACT_SENSOR: + case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: + case AlexaInterfaceType.INPUT_CONTROLLER: + case AlexaInterfaceType.LOCK_CONTROLLER: + case AlexaInterfaceType.MODE_CONTROLLER: + case AlexaInterfaceType.MOTION_SENSOR: + case AlexaInterfaceType.PERCENTAGE_CONTROLLER: + case AlexaInterfaceType.PLAYBACK_CONTROLLER: + case AlexaInterfaceType.POWER_CONTROLLER: + case AlexaInterfaceType.POWER_LEVEL_CONTROLLER: + case AlexaInterfaceType.RANGE_CONTROLLER: + case AlexaInterfaceType.RECORD_CONTROLLER: + case AlexaInterfaceType.SCENE_CONTROLLER: + case AlexaInterfaceType.SEEK_CONTROLLER: + case AlexaInterfaceType.TEMPERATURE_SENSOR: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + case AlexaInterfaceType.TOGGLE_CONTROLLER: + return "3"; + case AlexaInterfaceType.LAUNCHER: + return "1.1"; + case AlexaInterfaceType.ENDPOINT_HEALTH: + return "3.3"; + default: + return "UNKNOWN"; + } + } + getProps() { + switch (this.type) { + case AlexaInterfaceType.AUTOMATION_MANAGEMENT: + return ["automationStatuses"]; + case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: + return ["brightness"]; + case AlexaInterfaceType.CHANNEL_CONTROLLER: + return ["channel"]; + case AlexaInterfaceType.COLOR_CONTROLLER: + return ["color"]; + case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: + return ["colorTemperatureInKelvin"]; + case AlexaInterfaceType.CONTACT_SENSOR: + return ["detectionState"]; + case AlexaInterfaceType.POWER_CONTROLLER: + return ["powerState"]; + case AlexaInterfaceType.ENDPOINT_HEALTH: + return ["connectivity"]; + case AlexaInterfaceType.TOGGLE_CONTROLLER: + return ["toggleState"]; + case AlexaInterfaceType.TEMPERATURE_SENSOR: + return ["temperature"]; + case AlexaInterfaceType.APPLICATION_STATE_REPORTER: + case AlexaInterfaceType.AUDIO_PLAY_QUEUE: + case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: + case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: + case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER: + case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: + case AlexaInterfaceType.COMMISSIONABLE: + case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: + case AlexaInterfaceType.COOKING: + case AlexaInterfaceType.DATA_CONTROLLER: + case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: + case AlexaInterfaceType.DEVICE_USAGE_METER: + case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: + case AlexaInterfaceType.EQUALIZER_CONTROLLER: + case AlexaInterfaceType.INPUT_CONTROLLER: + case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: + case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: + case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: + case AlexaInterfaceType.KEYPAD_CONTROLLER: + case AlexaInterfaceType.LAUNCHER: + case AlexaInterfaceType.LOCK_CONTROLLER: + case AlexaInterfaceType.MEDIA_PLAYBACK: + case AlexaInterfaceType.MEDIA_SEARCH: + case AlexaInterfaceType.MODE_CONTROLLER: + case AlexaInterfaceType.MOTION_SENSOR: + case AlexaInterfaceType.PERCENTAGE_CONTROLLER: + case AlexaInterfaceType.PLAYBACK_CONTROLLER: + case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: + case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: + case AlexaInterfaceType.RANGE_CONTROLLER: + case AlexaInterfaceType.RECORD_CONTROLLER: + case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: + case AlexaInterfaceType.RTC_SESSION_CONTROLLER: + case AlexaInterfaceType.SCENE_CONTROLLER: + case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: + case AlexaInterfaceType.SEEK_CONTROLLER: + case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: + case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: + case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: + case AlexaInterfaceType.SPEAKER: + case AlexaInterfaceType.STEP_SPEAKER: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: + case AlexaInterfaceType.TIME_HOLD_CONTROLLER: + case AlexaInterfaceType.UI_CONTROLLER: + case AlexaInterfaceType.USER_PREFERENCE: + case AlexaInterfaceType.VIDEO_RECORDER: + case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: + console.error("UNSUPORTED INTERFACE TYPE"); + return []; + default: + return []; + } + } + getJSON() { + const doc = { + interface: this.getTypeString(), + version: this.getVersion(), + type: "AlexaInterface", + properties: { + retrievable: this.retrievable, + proactivelyReported: this.proactivelyReported, + supported: [{ name: this.getProps()[0] }], + }, + }; + if (this.instance) { + doc.instance = this.instance; + } + if (this.friendlyNames.length > 0) { + const capabilityResources = doc["capabilityResources"] || {}; + const friendlyNamesArray = capabilityResources["friendlyNames"] || []; + this.friendlyNames.forEach((fn) => { + const friendlyNameObj = { "@type": "text" }; + friendlyNameObj["value"] = { + text: fn.text, + locale: fn.locale, + }; + friendlyNamesArray.push(friendlyNameObj); + }); + doc["capabilityResources"] = capabilityResources; + } + if (this.actionMappings.length > 0) { + doc.semantics = { + actionMappings: this.actionMappings.map((am) => am.toJSON()), + }; + } + return doc; + } +} +exports.AlexaInterface = AlexaInterface; diff --git a/dist/AlexaStatusMessage.js b/dist/AlexaStatusMessage.js new file mode 100644 index 0000000..6bfd1fb --- /dev/null +++ b/dist/AlexaStatusMessage.js @@ -0,0 +1,105 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.AlexaStatusMessage = exports.TemperatureSensorScale = exports.PowerController = exports.EndpointHealth = void 0; +const uuid_1 = require("uuid"); +const AlexaInterface_1 = require("./AlexaInterface"); +var EndpointHealth; +(function (EndpointHealth) { + EndpointHealth["OK"] = "OK"; + EndpointHealth["UNREACHABLE"] = "UNREACHABLE"; +})(EndpointHealth || (exports.EndpointHealth = EndpointHealth = {})); +var PowerController; +(function (PowerController) { + PowerController["ON"] = "ON"; + PowerController["OFF"] = "OFF"; +})(PowerController || (exports.PowerController = PowerController = {})); +var TemperatureSensorScale; +(function (TemperatureSensorScale) { + TemperatureSensorScale["CELSIUS"] = "CELSIUS"; + TemperatureSensorScale["FAHRENHEIT"] = "FAHRENHEIT"; +})(TemperatureSensorScale || (exports.TemperatureSensorScale = TemperatureSensorScale = {})); +class AlexaStatusMessage { + constructor(correlationToken, rootTopic, endpointId, mqttClient, isResponse = false) { + this.context = { properties: [] }; + this.rootTopic = rootTopic; + this.endpointId = endpointId; + this.mqttClient = mqttClient; + this.event = { + header: { + namespace: "Alexa", + name: isResponse ? "Response" : "StateReport", + payloadVersion: "3", + messageId: this.generateMessageId(), + correlationToken, + }, + endpoint: { + endpointId, + }, + payload: {}, + }; + } + generateMessageId() { + return (0, uuid_1.v4)(); + } + getTimestamp() { + return new Date().toISOString(); + } + addProperty(namespace, name, value, uncertaintyInMilliseconds = 0, instance) { + const prop = { + namespace, + name, + value, + timeOfSample: this.getTimestamp(), + uncertaintyInMilliseconds, + }; + if (instance) + prop.instance = instance; + this.context.properties.push(prop); + return this; + } + addHealthProp(health, uncertaintyInMs = 0) { + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.ENDPOINT_HEALTH, "connectivity", { value: health }, uncertaintyInMs); + } + addPowerControllerProp(power, uncertaintyInMs = 0) { + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.POWER_CONTROLLER, "powerState", power, uncertaintyInMs); + } + addTemperatureSensorProp(scale, value, uncertaintyInMs = 0) { + const tempValue = { + value: scale === TemperatureSensorScale.FAHRENHEIT + ? (value - 32) * (5 / 9) + : value, + scale: "CELSIUS", + }; + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.TEMPERATURE_SENSOR, "temperature", tempValue, uncertaintyInMs); + } + addBrightnessControllerProp(brightness, uncertaintyInMs = 0) { + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.BRIGHTNESS_CONTROLLER, "brightness", brightness, uncertaintyInMs); + } + addColorTemperatureControllerProp(colorTemp, uncertaintyInMs = 0) { + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, "colorTemperatureInKelvin", colorTemp, uncertaintyInMs); + } + addToggleControllerProp(state, instance, uncertaintyInMs = 0) { + return this.addProperty(AlexaInterface_1.AlexaInterfaceType.TOGGLE_CONTROLLER, "toggleState", state, uncertaintyInMs, instance); + } + addContextProp(prop) { + this.context.properties.push(prop); + return this; + } + send() { + const payload = { + context: this.context, + event: this.event, + }; + const topic = `${this.rootTopic}/${this.endpointId}/alexaResponce`; //Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too + const payloadStr = JSON.stringify(payload); + this.mqttClient.publish(topic, payloadStr, (err) => { + if (err) { + console.error("[AlexaStatusMessage] Failed to publish status message:", err); + } + else { + // console.log(`[AlexaStatusMessage] Sent message to ${topic}`); + } + }); + } +} +exports.AlexaStatusMessage = AlexaStatusMessage; diff --git a/dist/Device.js b/dist/Device.js new file mode 100644 index 0000000..7decf86 --- /dev/null +++ b/dist/Device.js @@ -0,0 +1,98 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const AlexaInterface_1 = require("./AlexaInterface"); +const DisplayCategory_1 = require("./DisplayCategory"); +const events_1 = require("events"); +const AlexaStatusMessage_1 = require("./AlexaStatusMessage"); +class Device extends events_1.EventEmitter { + constructor(mqttClient, rootTopic, name, endpointId, displayCategory = DisplayCategory_1.DisplayCategory.LIGHT, description = "Alexa to Node.js bridge", manufacturerName = "Alex2Node", manufacturer = "Alex2Node", model = "Alex2Node_v1.0.0") { + super(); + this.mqttClient = mqttClient; + this.rootTopic = rootTopic; + this.name = name; + this.endpointId = endpointId; + this.displayCategory = displayCategory; + this.description = description; + this.manufacturerName = manufacturerName; + this.manufacturer = manufacturer; + this.model = model; + this.softwareVersion = "1.0.0"; + this.capabilities = []; + } + getName() { + return this.name; + } + setName(name) { + this.name = name; + } + getEndpointId() { + return this.endpointId; + } + setDisplayCategory(category) { + this.displayCategory = category; + } + getDisplayCategory() { + return this.displayCategory; + } + setDescription(description) { + this.description = description; + } + getDescription() { + return this.description; + } + getStatusMessage(correlationToken, isResponse = false) { + return new AlexaStatusMessage_1.AlexaStatusMessage(correlationToken, this.rootTopic, this.endpointId, this.mqttClient, isResponse); + } + setManufacturerName(name) { + this.manufacturerName = name; + } + getManufacturerName() { + return this.manufacturerName; + } + setManufacturer(manufacturer) { + this.manufacturer = manufacturer; + } + getManufacturer() { + return this.manufacturer; + } + setModel(model) { + this.model = model; + } + getModel() { + return this.model; + } + getSoftwareVersion() { + return this.softwareVersion; + } + addCapability(type) { + const existing = this.capabilities.find((iface) => iface.getType() === type); + if (existing) { + return existing; + } + const newCapability = new AlexaInterface_1.AlexaInterface(type); + this.capabilities.push(newCapability); + // console.log( + // `Created new interface with type: ${newCapability.getTypeString()}` + // ); + return newCapability; + } + getJSON() { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + manufacturerName: this.manufacturerName, + displayCategories: [this.displayCategory], + additionalAttributes: { + manufacturer: this.manufacturer, + model: this.model, + serialNumber: "Alex2Node", + firmwareVersion: "1.0.0", + softwareVersion: this.softwareVersion, + customIdentifier: "Alex2Node", + }, + capabilities: this.capabilities.map((cap) => cap.getJSON()), + }; + } +} +exports.default = Device; diff --git a/dist/DisplayCategory.js b/dist/DisplayCategory.js new file mode 100644 index 0000000..809458c --- /dev/null +++ b/dist/DisplayCategory.js @@ -0,0 +1,62 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DisplayCategory = void 0; +var DisplayCategory; +(function (DisplayCategory) { + DisplayCategory["ACTIVITY_TRIGGER"] = "ACTIVITY_TRIGGER"; + DisplayCategory["AIR_CONDITIONER"] = "AIR_CONDITIONER"; + DisplayCategory["AIR_FRESHENER"] = "AIR_FRESHENER"; + DisplayCategory["AIR_PURIFIER"] = "AIR_PURIFIER"; + DisplayCategory["AIR_QUALITY_MONITOR"] = "AIR_QUALITY_MONITOR"; + DisplayCategory["ALEXA_VOICE_ENABLED"] = "ALEXA_VOICE_ENABLED"; + DisplayCategory["AUTO_ACCESSORY"] = "AUTO_ACCESSORY"; + DisplayCategory["BLUETOOTH_SPEAKER"] = "BLUETOOTH_SPEAKER"; + DisplayCategory["CAMERA"] = "CAMERA"; + DisplayCategory["CHRISTMAS_TREE"] = "CHRISTMAS_TREE"; + DisplayCategory["COFFEE_MAKER"] = "COFFEE_MAKER"; + DisplayCategory["COMPUTER"] = "COMPUTER"; + DisplayCategory["CONTACT_SENSOR"] = "CONTACT_SENSOR"; + DisplayCategory["DISHWASHER"] = "DISHWASHER"; + DisplayCategory["DOOR"] = "DOOR"; + DisplayCategory["DOORBELL"] = "DOORBELL"; + DisplayCategory["DRYER"] = "DRYER"; + DisplayCategory["EXTERIOR_BLIND"] = "EXTERIOR_BLIND"; + DisplayCategory["FAN"] = "FAN"; + DisplayCategory["GAME_CONSOLE"] = "GAME_CONSOLE"; + DisplayCategory["GARAGE_DOOR"] = "GARAGE_DOOR"; + DisplayCategory["HEADPHONES"] = "HEADPHONES"; + DisplayCategory["HUB"] = "HUB"; + DisplayCategory["INTERIOR_BLIND"] = "INTERIOR_BLIND"; + DisplayCategory["LAPTOP"] = "LAPTOP"; + DisplayCategory["LIGHT"] = "LIGHT"; + DisplayCategory["MICROWAVE"] = "MICROWAVE"; + DisplayCategory["MOBILE_PHONE"] = "MOBILE_PHONE"; + DisplayCategory["MOTION_SENSOR"] = "MOTION_SENSOR"; + DisplayCategory["MUSIC_SYSTEM"] = "MUSIC_SYSTEM"; + DisplayCategory["NETWORK_HARDWARE"] = "NETWORK_HARDWARE"; + DisplayCategory["OTHER"] = "OTHER"; + DisplayCategory["OVEN"] = "OVEN"; + DisplayCategory["PHONE"] = "PHONE"; + DisplayCategory["PRINTER"] = "PRINTER"; + DisplayCategory["REMOTE"] = "REMOTE"; + DisplayCategory["ROUTER"] = "ROUTER"; + DisplayCategory["SCENE_TRIGGER"] = "SCENE_TRIGGER"; + DisplayCategory["SCREEN"] = "SCREEN"; + DisplayCategory["SECURITY_PANEL"] = "SECURITY_PANEL"; + DisplayCategory["SECURITY_SYSTEM"] = "SECURITY_SYSTEM"; + DisplayCategory["SLOW_COOKER"] = "SLOW_COOKER"; + DisplayCategory["SMARTLOCK"] = "SMARTLOCK"; + DisplayCategory["SMARTPLUG"] = "SMARTPLUG"; + DisplayCategory["SPEAKER"] = "SPEAKER"; + DisplayCategory["STREAMING_DEVICE"] = "STREAMING_DEVICE"; + DisplayCategory["SWITCH"] = "SWITCH"; + DisplayCategory["TABLET"] = "TABLET"; + DisplayCategory["TEMPERATURE_SENSOR"] = "TEMPERATURE_SENSOR"; + DisplayCategory["THERMOSTAT"] = "THERMOSTAT"; + DisplayCategory["TV"] = "TV"; + DisplayCategory["VACUUM_CLEANER"] = "VACUUM_CLEANER"; + DisplayCategory["VEHICLE"] = "VEHICLE"; + DisplayCategory["WASHER"] = "WASHER"; + DisplayCategory["WATER_HEATER"] = "WATER_HEATER"; + DisplayCategory["WEARABLE"] = "WEARABLE"; +})(DisplayCategory || (exports.DisplayCategory = DisplayCategory = {})); diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..77b248c --- /dev/null +++ b/dist/index.js @@ -0,0 +1,15 @@ +"use strict"; +var __importDefault = (this && this.__importDefault) || function (mod) { + return (mod && mod.__esModule) ? mod : { "default": mod }; +}; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.PowerController = exports.AlexaActions = exports.ActionMapping = exports.AlexaInterfaceType = exports.Alex2MQTT = void 0; +var Alex2Node_1 = require("./Alex2Node"); +Object.defineProperty(exports, "Alex2MQTT", { enumerable: true, get: function () { return __importDefault(Alex2Node_1).default; } }); +var AlexaInterface_1 = require("./AlexaInterface"); +Object.defineProperty(exports, "AlexaInterfaceType", { enumerable: true, get: function () { return AlexaInterface_1.AlexaInterfaceType; } }); +var ActionMapping_1 = require("./ActionMapping"); +Object.defineProperty(exports, "ActionMapping", { enumerable: true, get: function () { return ActionMapping_1.ActionMapping; } }); +Object.defineProperty(exports, "AlexaActions", { enumerable: true, get: function () { return ActionMapping_1.AlexaActions; } }); +var AlexaStatusMessage_1 = require("./AlexaStatusMessage"); +Object.defineProperty(exports, "PowerController", { enumerable: true, get: function () { return AlexaStatusMessage_1.PowerController; } }); diff --git a/examples/BlindControl.js b/examples/BlindControl.js new file mode 100644 index 0000000..fefd7bd --- /dev/null +++ b/examples/BlindControl.js @@ -0,0 +1,109 @@ +// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) +const { + Alex2MQTT, + AlexaInterfaceType, + ActionMapping, + AlexaActions, + PowerController + } = require("../dist/index.js"); + + +require("dotenv").config(); // Load environment variables from .env file + +// Simulated in-memory device state (used for example/demo purposes) +let outputState = PowerController.OFF; + +// Load MQTT connection credentials and root topic from environment variables +const username = process.env.MQTT_USERNAME; +const password = process.env.MQTT_PASSWORD; +const rootTopic = process.env.MQTT_ROOT_TOPIC; + +// Create and initialize a new Alexa-to-MQTT client +const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); +alex2NodeClient.connect(); // Connect to the MQTT broker + +// Define the device name +const deviceName = "Bedroom Blinds"; + +// Register the device with a unique endpoint ID +const blinds = alex2NodeClient.registerDevice(deviceName, "endpoint1"); + +// Add basic Alexa capabilities to the device +blinds.addCapability(AlexaInterfaceType.POWER_CONTROLLER); + +// Add a toggle controller capability for custom on/off-style commands +const toggle = blinds.addCapability(AlexaInterfaceType.TOGGLE_CONTROLLER); + +// Toggle controllers require an instance name to differentiate from other controllers +toggle.setInstance("NodeJS.Toggle"); + +// Optionally, add a friendly name to improve how Alexa refers to this capability +toggle.addFriendlyName(deviceName, "en-US"); + +// Define how Alexa's "Open" and "Close" actions map to "TurnOff" and "TurnOn" commands +// This allows controlling blinds via both open/close and on/off style requests +const closeMapping = new ActionMapping([AlexaActions.Close], "TurnOn"); +const openMapping = new ActionMapping([AlexaActions.Open], "TurnOff"); + +// Action mappings are only supported by generic controller interfaces. +// More details: +// https://developer.amazon.com/en-US/docs/alexa/device-apis/alexa-discovery-objects.html#action-mapping +// https://developer.amazon.com/en-US/docs/alexa/device-apis/generic-controllers.html + +toggle.addActionMapping(closeMapping); +toggle.addActionMapping(openMapping); + +// Log the device name to verify registration +console.log(blinds.getName()); + +/** + * Handle Alexa's ReportState directive. + * This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks). + */ +blinds.on("ReportState", (payload) => { + console.log("ReportState received!", payload); + + const { correlationToken } = payload; + + const status = blinds.getStatusMessage(correlationToken); + + status + .addHealthProp("OK") // Device is healthy + .addPowerControllerProp(outputState) // Report power state + .addToggleControllerProp(outputState, "NodeJS.Toggle"); // Report toggle controller state + + status.send(); // Send the state report back to Alexa +}); + +/** + * Handle incoming control directives (e.g., TurnOn, TurnOff). + * These directives come from Alexa when a user issues a command. + */ +blinds.on("Event", (directive, interfaceType) => { + console.log("Event received", { directive, interfaceType }); + + if ( + interfaceType === AlexaInterfaceType.TOGGLE_CONTROLLER || + interfaceType === AlexaInterfaceType.POWER_CONTROLLER + ) { + const name = directive.header.name; + const token = directive.header.correlationToken; + + // Update the internal state based on the command + if (name === "TurnOn") { + outputState = PowerController.ON; + console.log("Turning ON"); + } else if (name === "TurnOff") { + outputState = PowerController.OFF; + console.log("Turning OFF"); + } + + // Respond to the directive with the updated device status + const status = blinds.getStatusMessage(token, true); + status + .addHealthProp("OK") + .addToggleControllerProp(outputState, "NodeJS.Toggle") + .addPowerControllerProp(outputState) + .send(); + } +}); diff --git a/examples/ExamplePowerController.js b/examples/ExamplePowerController.js new file mode 100644 index 0000000..5658a55 --- /dev/null +++ b/examples/ExamplePowerController.js @@ -0,0 +1,80 @@ +// Import required modules and types from the compiled TypeScript distribution (via dist/index.js) +const { + Alex2MQTT, + AlexaInterfaceType, + PowerController +} = require("../dist/index.js"); + +require("dotenv").config(); // Load environment variables from .env file + +// Simulated in-memory device state (used for example/demo purposes) +let outputState = PowerController.OFF; // Initialize the power state to OFF + +// Load MQTT connection credentials and root topic from environment variables +const username = process.env.MQTT_USERNAME; +const password = process.env.MQTT_PASSWORD; +const rootTopic = process.env.MQTT_ROOT_TOPIC; + +// Create and initialize a new Alexa-to-MQTT client +const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); +alex2NodeClient.connect(); // Connect to the MQTT broker + +// Define the device name (bedroom light) +const deviceName = "Bedroom Light"; + +// Register the device with a unique endpoint ID (you can use something like "endpoint1") +const bedroomLight = alex2NodeClient.registerDevice(deviceName, "endpoint2"); + +// Add the PowerController capability (for turning on/off the device) +bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); + +// Log the device name to verify registration +console.log(bedroomLight.getName()); + +/** + * Handle Alexa's ReportState directive. + * This occurs when Alexa queries the current state of the device (e.g., during routines or device status checks). + */ +bedroomLight.on("ReportState", (payload) => { + console.log("ReportState received!", payload); + + const { correlationToken } = payload; + + const status = bedroomLight.getStatusMessage(correlationToken); + + status + .addHealthProp("OK") // Device is healthy + .addPowerControllerProp(outputState); // Report the current power state (ON/OFF) + + status.send(); // Send the state report back to Alexa +}); + +/** + * Handle incoming control directives (e.g., TurnOn, TurnOff). + * These directives come from Alexa when a user issues a command. + */ +bedroomLight.on("Event", (directive, interfaceType) => { + console.log("Event received", { directive, interfaceType }); + + // Ensure the interfaceType is either PowerController or other valid interfaces + if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) { + const name = directive.header.name; + const token = directive.header.correlationToken; + + // Update the internal state based on the command (TurnOn / TurnOff) + if (name === "TurnOn") { + outputState = PowerController.ON; + console.log("Turning ON the Bedroom Light"); + } else if (name === "TurnOff") { + outputState = PowerController.OFF; + console.log("Turning OFF the Bedroom Light"); + } + + // Respond to the directive with the updated device status + const status = bedroomLight.getStatusMessage(token, true); + status + .addHealthProp("OK") + .addPowerControllerProp(outputState) + .send(); + } +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..85b8e73 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,56 @@ +{ + "name": "alex2node", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "alex2node", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "dotenv": "^16.5.0" + }, + "devDependencies": { + "typescript": "^5.8.3" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + }, + "dependencies": { + "dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==" + }, + "typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true + } + } +} diff --git a/package.json b/package.json index 5dd769d..f451ff2 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,24 @@ "name": "alex2node", "version": "1.0.0", "description": "", - "main": "index.js", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "require": "./dist/index.js" + } + }, + "type": "commonjs", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "build": "tsc" }, "keywords": [], "author": "", - "license": "ISC" + "license": "ISC", + "devDependencies": { + "typescript": "^5.8.3" + }, + "dependencies": { + "dotenv": "^16.5.0" + } } diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..236e4dd --- /dev/null +++ b/readme.md @@ -0,0 +1,181 @@ +# Alex2Node + +Alex2Node is a lightweight Node.js library for integrating devices with Amazon Alexa smart home APIs using MQTT. The library simplifies the process of creating Alexa-compatible devices using Alex2MQTT instead of an Alexa Skill directly. + +For more details on how to configure Alex2MQTT, visit [Alex2MQTT Documentation](https://alex2mqtt.stormysdream.club/). + +--- + +## Features +- Supports "All" Alexa smart home capabilities. +- Simplifies Alexa device integration with MQTT. +- Event-driven architecture for handling Alexa directives. +- Easily configure devices and their capabilities via simple API. + + +## Quick Start + +### Installation + +You can install **Alex2Node** via npm: + +```bash +npm install alex2node +``` + +Then, import it into your JavaScript or TypeScript file: + +```js +const { Alex2MQTT, AlexaInterfaceType, PowerController } = require('alex2node'); +``` + +Make sure to include your MQTT connection credentials via a `.env` file or directly in the code. + +Credentials can be found at https://alex2mqtt.stormysdream.club/ after logging in with amazon. + +--- + +## Creating a Basic Device + +### Example: Power Control for a Light + +Here’s how to create a simple device that controls a light: + +First, create an instance of the Alexa-to-MQTT client and initialize it with your MQTT credentials: + +```js +require("dotenv").config(); // Load environment variables from .env file + +const { Alex2MQTT, AlexaInterfaceType, PowerController } = require("alex2node"); + +const username = process.env.MQTT_USERNAME; +const password = process.env.MQTT_PASSWORD; +const rootTopic = process.env.MQTT_ROOT_TOPIC; + +const alex2NodeClient = new Alex2MQTT(username, password, rootTopic, false); +alex2NodeClient.connect(); // Connect to the MQTT broker +``` + +Once initialized, you can begin to add virtual devices. In this example, we will add a PowerController for a light: + +```js +const deviceName = "Bedroom Light"; +const bedroomLight = alex2NodeClient.registerDevice(deviceName, "endpoint1"); + +// Add the PowerController capability +bedroomLight.addCapability(AlexaInterfaceType.POWER_CONTROLLER); + +// Handle Alexa's ReportState directive to report the current state +let outputState = PowerController.OFF; +bedroomLight.on("ReportState", (payload) => { + const { correlationToken } = payload; + const status = bedroomLight.getStatusMessage(correlationToken); + status + .addHealthProp("OK") + .addPowerControllerProp(outputState) + .send(); +}); + +// Handle Alexa's Event directive to process state changes +bedroomLight.on("Event", (directive, interfaceType) => { + if (interfaceType === AlexaInterfaceType.POWER_CONTROLLER) { + const name = directive.header.name; + const token = directive.header.correlationToken; + + if (name === "TurnOn") { + outputState = PowerController.ON; + console.log("Turning ON the Bedroom Light"); + } else if (name === "TurnOff") { + outputState = PowerController.OFF; + console.log("Turning OFF the Bedroom Light"); + } + + const status = bedroomLight.getStatusMessage(token, true); + status + .addHealthProp("OK") + .addPowerControllerProp(outputState) + .send(); + } +}); +``` + +--- + +## Interface Types +| Alexa Interface Type | Status | +|-----------------------------------------------------|-------------| +| AlexaInterfaceType::ENDPOINT_HEALTH | Fully Supported | +| AlexaInterfaceType::POWER_CONTROLLER | Fully Supported | +| AlexaInterfaceType::BRIGHTNESS_CONTROLLER | Fully Supported | +| AlexaInterfaceType::TOGGLE_CONTROLLER | Fully Supported | +| AlexaInterfaceType::TEMPERATURE_SENSOR | Fully Supported | +| AlexaInterfaceType::COLOR_TEMPERATURE_CONTROLLER | Fully Supported | +| AlexaInterfaceType::AUTOMATION_MANAGEMENT | Supported* | +| AlexaInterfaceType::CHANNEL_CONTROLLER | Supported* | +| AlexaInterfaceType::COLOR_CONTROLLER | Supported* | +| AlexaInterfaceType::CONTACT_SENSOR | Supported* | +| AlexaInterfaceType::APPLICATION_STATE_REPORTER | Supported* | +| AlexaInterfaceType::AUDIO_PLAY_QUEUE | Supported* | +| AlexaInterfaceType::AUTHORIZATION_CONTROLLER | Supported* | +| AlexaInterfaceType::AUTOMOTIVE_VEHICLE_DATA | Supported* | +| AlexaInterfaceType::CAMERA_LIVE_VIEW_CONTROLLER | Supported* | +| AlexaInterfaceType::CAMERA_STREAM_CONTROLLER | Supported* | +| AlexaInterfaceType::COMMISSIONABLE | Supported* | +| AlexaInterfaceType::CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER | Supported* | +| AlexaInterfaceType::COOKING | Supported* | +| AlexaInterfaceType::DATA_CONTROLLER | Supported* | +| AlexaInterfaceType::DEVICE_USAGE_ESTIMATION | Supported* | +| AlexaInterfaceType::DEVICE_USAGE_METER | Supported* | +| AlexaInterfaceType::DOORBELL_EVENT_SOURCE | Supported* | +| AlexaInterfaceType::EQUALIZER_CONTROLLER | Supported* | +| AlexaInterfaceType::INPUT_CONTROLLER | Supported* | +| AlexaInterfaceType::INVENTORY_LEVEL_SENSOR | Supported* | +| AlexaInterfaceType::INVENTORY_LEVEL_USAGE_SENSOR | Supported* | +| AlexaInterfaceType::INVENTORY_USAGE_SENSOR | Supported* | +| AlexaInterfaceType::KEYPAD_CONTROLLER | Supported* | +| AlexaInterfaceType::LAUNCHER | Supported* | +| AlexaInterfaceType::LOCK_CONTROLLER | Supported* | +| AlexaInterfaceType::MEDIA_PLAYBACK | Supported* | +| AlexaInterfaceType::MEDIA_SEARCH | Supported* | +| AlexaInterfaceType::MODE_CONTROLLER | Supported* | +| AlexaInterfaceType::MOTION_SENSOR | Supported* | +| AlexaInterfaceType::PERCENTAGE_CONTROLLER | Supported* | +| AlexaInterfaceType::PLAYBACK_CONTROLLER | Supported* | +| AlexaInterfaceType::PLAYBACK_STATE_REPORTER | Supported* | +| AlexaInterfaceType::PROACTIVE_NOTIFICATION_SOURCE | Supported* | +| AlexaInterfaceType::RANGE_CONTROLLER | Supported* | +| AlexaInterfaceType::RECORD_CONTROLLER | Supported* | +| AlexaInterfaceType::REMOTE_VIDEO_PLAYER | Supported* | +| AlexaInterfaceType::RTC_SESSION_CONTROLLER | Supported* | +| AlexaInterfaceType::SCENE_CONTROLLER | Supported* | +| AlexaInterfaceType::SECURITY_PANEL_CONTROLLER | Supported* | +| AlexaInterfaceType::SEEK_CONTROLLER | Supported* | +| AlexaInterfaceType::SIMPLE_EVENT_SOURCE | Supported* | +| AlexaInterfaceType::SMART_VISION_OBJECT_DETECTION_SENSOR | Supported* | +| AlexaInterfaceType::SMART_VISION_SNAPSHOT_PROVIDER | Supported* | +| AlexaInterfaceType::SPEAKER | Supported* | +| AlexaInterfaceType::STEP_SPEAKER | Supported* | +| AlexaInterfaceType::THERMOSTAT_CONTROLLER | Supported* | +| AlexaInterfaceType::THERMOSTAT_CONTROLLER_CONFIGURATION | Supported* | +| AlexaInterfaceType::THERMOSTAT_CONTROLLER_HVAC_COMPONENTS | Supported* | +| AlexaInterfaceType::THERMOSTAT_CONTROLLER_SCHEDULE | Supported* | +| AlexaInterfaceType::TIME_HOLD_CONTROLLER | Supported* | +| AlexaInterfaceType::UI_CONTROLLER | Supported* | +| AlexaInterfaceType::USER_PREFERENCE | Supported* | +| AlexaInterfaceType::VIDEO_RECORDER | Supported* | +| AlexaInterfaceType::WAKE_ON_LAN_CONTROLLER | Supported* | + + +(*Partial support or limited implementation advanced configuration is required) + +--- + +## Contributing + +Feel free to submit pull requests or issues for feature requests and bug fixes. + +--- + +## License + +This project is licensed under the MIT License. See the LICENSE file for details. diff --git a/src/ActionMapping.ts b/src/ActionMapping.ts new file mode 100644 index 0000000..2c28bc1 --- /dev/null +++ b/src/ActionMapping.ts @@ -0,0 +1,41 @@ +export enum AlexaActions { + Open = "Alexa.Actions.Open", + Close = "Alexa.Actions.Close", + Raise = "Alexa.Actions.Raise", + Lower = "Alexa.Actions.Lower", + SetEcoOn = "Alexa.Actions.SetEcoOn", + SetEcoOff = "Alexa.Actions.SetEcoOff", +} + +interface Directive { + name: string; + payload?: string; +} + +export class ActionMapping { + type: string = "ActionsToDirective"; + actions: AlexaActions[]; + directive: Directive; + + constructor( + actions: AlexaActions[], + directiveName: string, + directivePayload: string | undefined + ) { + this.actions = actions; + this.directive = { + name: directiveName, + }; + if (directivePayload) { + this.directive.payload = directivePayload; + } + } + + toJSON(): object { + return { + "@type": this.type, + actions: this.actions, + directive: this.directive, + }; + } +} diff --git a/src/Alex2Node.ts b/src/Alex2Node.ts new file mode 100644 index 0000000..bf18271 --- /dev/null +++ b/src/Alex2Node.ts @@ -0,0 +1,131 @@ +import mqtt, { MqttClient } from "mqtt"; +import Device from "./Device"; + +class Alex2MQTT { + private client: MqttClient | null = null; + private devices: Device[] = []; + private MqttHost = "mqtt://Alex2MQTT.stormysdream.club"; + + constructor( + private username: string, + private password: string, + private rootTopic: string, + private debugLogging: boolean + ) {} + + connect(): void { + const options = { + username: this.username, + password: this.password, + }; + + this.client = mqtt.connect(this.MqttHost, options); + + this.client.on("connect", () => { + if (this.debugLogging) { + console.log("[Alex2Node.ts] Connected to MQTT broker"); + } + + this.client!.subscribe(this.rootTopic + "/#", (err) => { + if (this.debugLogging && !err) { + console.log("[Alex2Node.ts] Subscribed to topics"); + } + if (err) { + throw err; + } + }); + }); + + this.client.on("message", (topic: string, message: Buffer) => { + if (this.debugLogging) { + console.log(`[Alex2Node.ts] MQTT Message Received`); + console.log(` Topic: ${topic}`); + console.log(` Payload: ${message.toString()}`); + } + if (topic == `${this.rootTopic}/discover`) { + if (this.debugLogging) { + console.log( + "[Alex2Node.ts] Discovery request received, getting device json..." + ); + } + const deviceArray = this.devices.map((device) => device.getJSON()); + + if (deviceArray.length > 0) { + this.client!.publish( + topic + "_r", + JSON.stringify(deviceArray), + (err) => { + if (err) { + throw err; + } else if (this.debugLogging) { + console.log( + `[Alex2Node.ts] Discovery payloads published to ${ + topic + "_r" + }` + ); + console.log(deviceArray); + } + } + ); + } + } else if (topic.split("/").length == 3) { + const [root, endpointId, directiveType] = topic.split("/"); + if (directiveType != "alexaDirective") { + return; + } + const device = this.devices.find((d) => d.endpointId === endpointId); + if (!device) { + if (this.debugLogging) { + console.warn( + `[Alex2Node.ts] No device found for endpointId: ${endpointId}` + ); + } + return; + } + let payload; + try { + payload = JSON.parse(message.toString()); + } catch (e) { + console.error(`[Alex2Node.ts] Failed to parse payload JSON`, e); + return; + } + if ( + payload.header && + payload.header.namespace === "Alexa" && + payload.header.name === "ReportState" + ) { + if (this.debugLogging) { + console.log( + `[Alex2Node.ts] ReportState directive for device ${endpointId}` + ); + } + device.emit("ReportState", payload); + }else{ + device.emit("Event", payload,payload.header.namespace); + } + } + }); + this.client.on("error", (err: Error) => { + if (this.debugLogging) { + console.error("[Alex2Node.ts] Connection failed:", err); + } + throw err; + }); + } + + registerDevice(name: string, endpointId: string): Device { + if (!this.client) { + throw new Error("Must call connect before creating devices"); + } + if (this.debugLogging) { + console.log( + `[Alex2Node.ts] Creating new device with endpoint: ${endpointId}` + ); + } + const device = new Device(this.client!, this.rootTopic, name, endpointId); + this.devices.push(device); + return device; + } +} + +export default Alex2MQTT; diff --git a/src/AlexaInterface.ts b/src/AlexaInterface.ts new file mode 100644 index 0000000..9cdc58d --- /dev/null +++ b/src/AlexaInterface.ts @@ -0,0 +1,307 @@ +import { ActionMapping, AlexaActions } from "./ActionMapping"; + +export enum AlexaInterfaceType { + APPLICATION_STATE_REPORTER = "Alexa.ApplicationStateReporter", + AUDIO_PLAY_QUEUE = "Alexa.Audio.PlayQueue", + AUTHORIZATION_CONTROLLER = "Alexa.AuthorizationController", + AUTOMATION_MANAGEMENT = "Alexa.AutomationManagement", + AUTOMOTIVE_VEHICLE_DATA = "Alexa.Automotive.VehicleData", + BRIGHTNESS_CONTROLLER = "Alexa.BrightnessController", + CAMERA_LIVE_VIEW_CONTROLLER = "Alexa.Camera.LiveViewController", + CAMERA_STREAM_CONTROLLER = "Alexa.CameraStreamController", + CHANNEL_CONTROLLER = "Alexa.ChannelController", + COLOR_CONTROLLER = "Alexa.ColorController", + COLOR_TEMPERATURE_CONTROLLER = "Alexa.ColorTemperatureController", + COMMISSIONABLE = "Alexa.Commissionable", + CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER = "Alexa.ConsentManagement.ConsentRequiredReporter", + CONTACT_SENSOR = "Alexa.ContactSensor", + COOKING = "Alexa.Cooking", + COOKING_FOOD_TEMPERATURE_CONTROLLER = "Alexa.Cooking.FoodTemperatureController", + COOKING_FOOD_TEMPERATURE_SENSOR = "Alexa.Cooking.FoodTemperatureSensor", + COOKING_PRESET_CONTROLLER = "Alexa.Cooking.PresetController", + COOKING_TEMPERATURE_CONTROLLER = "Alexa.Cooking.TemperatureController", + COOKING_TEMPERATURE_SENSOR = "Alexa.Cooking.TemperatureSensor", + COOKING_TIME_CONTROLLER = "Alexa.Cooking.TimeController", + DATA_CONTROLLER = "Alexa.DataController", + DEVICE_USAGE_ESTIMATION = "Alexa.DeviceUsage.Estimation", + DEVICE_USAGE_METER = "Alexa.DeviceUsage.Meter", + DOORBELL_EVENT_SOURCE = "Alexa.DoorbellEventSource", + ENDPOINT_HEALTH = "Alexa.EndpointHealth", + EQUALIZER_CONTROLLER = "Alexa.EqualizerController", + INPUT_CONTROLLER = "Alexa.InputController", + INVENTORY_LEVEL_SENSOR = "Alexa.InventoryLevelSensor", + INVENTORY_LEVEL_USAGE_SENSOR = "Alexa.InventoryLevelUsageSensor", + INVENTORY_USAGE_SENSOR = "Alexa.InventoryUsageSensor", + KEYPAD_CONTROLLER = "Alexa.KeypadController", + LAUNCHER = "Alexa.Launcher", + LOCK_CONTROLLER = "Alexa.LockController", + MEDIA_PLAYBACK = "Alexa.Media.Playback", + MEDIA_PLAY_QUEUE = "Alexa.Media.PlayQueue", + MEDIA_SEARCH = "Alexa.Media.Search", + MODE_CONTROLLER = "Alexa.ModeController", + MOTION_SENSOR = "Alexa.MotionSensor", + PERCENTAGE_CONTROLLER = "Alexa.PercentageController", + PLAYBACK_CONTROLLER = "Alexa.PlaybackController", + PLAYBACK_STATE_REPORTER = "Alexa.PlaybackStateReporter", + POWER_CONTROLLER = "Alexa.PowerController", + POWER_LEVEL_CONTROLLER = "Alexa.PowerLevelController", + PROACTIVE_NOTIFICATION_SOURCE = "Alexa.ProactiveNotificationSource", + RANGE_CONTROLLER = "Alexa.RangeController", + RECORD_CONTROLLER = "Alexa.RecordController", + REMOTE_VIDEO_PLAYER = "Alexa.RemoteVideoPlayer", + RTC_SESSION_CONTROLLER = "Alexa.RTCSessionController", + SCENE_CONTROLLER = "Alexa.SceneController", + SECURITY_PANEL_CONTROLLER = "Alexa.SecurityPanelController", + SECURITY_PANEL_CONTROLLER_ALERT = "Alexa.SecurityPanelController.Alert", + SEEK_CONTROLLER = "Alexa.SeekController", + SIMPLE_EVENT_SOURCE = "Alexa.SimpleEventSource", + SMART_VISION_OBJECT_DETECTION_SENSOR = "Alexa.SmartVision.ObjectDetectionSensor", + SMART_VISION_SNAPSHOT_PROVIDER = "Alexa.SmartVision.SnapshotProvider", + SPEAKER = "Alexa.Speaker", + STEP_SPEAKER = "Alexa.StepSpeaker", + TEMPERATURE_SENSOR = "Alexa.TemperatureSensor", + THERMOSTAT_CONTROLLER = "Alexa.ThermostatController", + THERMOSTAT_CONTROLLER_CONFIGURATION = "Alexa.ThermostatController.Configuration", + THERMOSTAT_CONTROLLER_HVAC_COMPONENTS = "Alexa.ThermostatController.HVAC.Components", + THERMOSTAT_CONTROLLER_SCHEDULE = "Alexa.ThermostatController.Schedule", + TIME_HOLD_CONTROLLER = "Alexa.TimeHoldController", + TOGGLE_CONTROLLER = "Alexa.ToggleController", + UI_CONTROLLER = "Alexa.UIController", + USER_PREFERENCE = "Alexa.UserPreference", + VIDEO_RECORDER = "Alexa.VideoRecorder", + WAKE_ON_LAN_CONTROLLER = "Alexa.WakeOnLANController", + UNKNOWN = "UNKNOWN", +} + +interface FriendlyName { + text: string; + locale: string; +} + +export class AlexaInterface { + private friendlyNames: FriendlyName[] = []; + private actionMappings: ActionMapping[] = []; + + constructor( + public type: AlexaInterfaceType, + public retrievable: boolean = true, + public proactivelyReported: boolean = false, + public instance = "" + ) {} + + addActionMapping(mapping: ActionMapping): void { + this.actionMappings.push(mapping); + } + + addFriendlyName(name: string, locale: string): void { + this.friendlyNames.push({ text: name, locale }); + } + + setInstance(name: string): void { + this.instance = name; + } + + getType(): AlexaInterfaceType { + return this.type; + } + + getTypeString(): string { + return this.type; + } + + getVersion(): string { + switch (this.type) { + case AlexaInterfaceType.APPLICATION_STATE_REPORTER: + case AlexaInterfaceType.AUDIO_PLAY_QUEUE: + case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: + case AlexaInterfaceType.AUTOMATION_MANAGEMENT: + case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: + case AlexaInterfaceType.COMMISSIONABLE: + case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: + case AlexaInterfaceType.COOKING: + case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_CONTROLLER: + case AlexaInterfaceType.COOKING_FOOD_TEMPERATURE_SENSOR: + case AlexaInterfaceType.COOKING_PRESET_CONTROLLER: + case AlexaInterfaceType.COOKING_TEMPERATURE_CONTROLLER: + case AlexaInterfaceType.COOKING_TEMPERATURE_SENSOR: + case AlexaInterfaceType.COOKING_TIME_CONTROLLER: + case AlexaInterfaceType.DATA_CONTROLLER: + case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: + case AlexaInterfaceType.DEVICE_USAGE_METER: + case AlexaInterfaceType.EQUALIZER_CONTROLLER: + case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: + case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: + case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: + case AlexaInterfaceType.KEYPAD_CONTROLLER: + case AlexaInterfaceType.MEDIA_PLAYBACK: + case AlexaInterfaceType.MEDIA_PLAY_QUEUE: + case AlexaInterfaceType.MEDIA_SEARCH: + case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: + case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: + case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: + case AlexaInterfaceType.RTC_SESSION_CONTROLLER: + case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: + case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER_ALERT: + case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: + case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: + case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: + case AlexaInterfaceType.SPEAKER: + case AlexaInterfaceType.STEP_SPEAKER: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: + case AlexaInterfaceType.UI_CONTROLLER: + case AlexaInterfaceType.USER_PREFERENCE: + case AlexaInterfaceType.VIDEO_RECORDER: + case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: + return "1"; + + case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: + case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: + case AlexaInterfaceType.CHANNEL_CONTROLLER: + case AlexaInterfaceType.COLOR_CONTROLLER: + case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: + case AlexaInterfaceType.CONTACT_SENSOR: + case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: + case AlexaInterfaceType.INPUT_CONTROLLER: + case AlexaInterfaceType.LOCK_CONTROLLER: + case AlexaInterfaceType.MODE_CONTROLLER: + case AlexaInterfaceType.MOTION_SENSOR: + case AlexaInterfaceType.PERCENTAGE_CONTROLLER: + case AlexaInterfaceType.PLAYBACK_CONTROLLER: + case AlexaInterfaceType.POWER_CONTROLLER: + case AlexaInterfaceType.POWER_LEVEL_CONTROLLER: + case AlexaInterfaceType.RANGE_CONTROLLER: + case AlexaInterfaceType.RECORD_CONTROLLER: + case AlexaInterfaceType.SCENE_CONTROLLER: + case AlexaInterfaceType.SEEK_CONTROLLER: + case AlexaInterfaceType.TEMPERATURE_SENSOR: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + case AlexaInterfaceType.TOGGLE_CONTROLLER: + return "3"; + case AlexaInterfaceType.LAUNCHER: + return "1.1"; + case AlexaInterfaceType.ENDPOINT_HEALTH: + return "3.3"; + default: + return "UNKNOWN"; + } + } + + getProps(): string[] { + switch (this.type) { + case AlexaInterfaceType.AUTOMATION_MANAGEMENT: + return ["automationStatuses"]; + case AlexaInterfaceType.BRIGHTNESS_CONTROLLER: + return ["brightness"]; + case AlexaInterfaceType.CHANNEL_CONTROLLER: + return ["channel"]; + case AlexaInterfaceType.COLOR_CONTROLLER: + return ["color"]; + case AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER: + return ["colorTemperatureInKelvin"]; + case AlexaInterfaceType.CONTACT_SENSOR: + return ["detectionState"]; + case AlexaInterfaceType.POWER_CONTROLLER: + return ["powerState"]; + case AlexaInterfaceType.ENDPOINT_HEALTH: + return ["connectivity"]; + case AlexaInterfaceType.TOGGLE_CONTROLLER: + return ["toggleState"]; + case AlexaInterfaceType.TEMPERATURE_SENSOR: + return ["temperature"]; + + case AlexaInterfaceType.APPLICATION_STATE_REPORTER: + case AlexaInterfaceType.AUDIO_PLAY_QUEUE: + case AlexaInterfaceType.AUTHORIZATION_CONTROLLER: + case AlexaInterfaceType.AUTOMOTIVE_VEHICLE_DATA: + case AlexaInterfaceType.CAMERA_LIVE_VIEW_CONTROLLER: + case AlexaInterfaceType.CAMERA_STREAM_CONTROLLER: + case AlexaInterfaceType.COMMISSIONABLE: + case AlexaInterfaceType.CONSENT_MANAGEMENT_CONSENT_REQUIRED_REPORTER: + case AlexaInterfaceType.COOKING: + case AlexaInterfaceType.DATA_CONTROLLER: + case AlexaInterfaceType.DEVICE_USAGE_ESTIMATION: + case AlexaInterfaceType.DEVICE_USAGE_METER: + case AlexaInterfaceType.DOORBELL_EVENT_SOURCE: + case AlexaInterfaceType.EQUALIZER_CONTROLLER: + case AlexaInterfaceType.INPUT_CONTROLLER: + case AlexaInterfaceType.INVENTORY_LEVEL_SENSOR: + case AlexaInterfaceType.INVENTORY_LEVEL_USAGE_SENSOR: + case AlexaInterfaceType.INVENTORY_USAGE_SENSOR: + case AlexaInterfaceType.KEYPAD_CONTROLLER: + case AlexaInterfaceType.LAUNCHER: + case AlexaInterfaceType.LOCK_CONTROLLER: + case AlexaInterfaceType.MEDIA_PLAYBACK: + case AlexaInterfaceType.MEDIA_SEARCH: + case AlexaInterfaceType.MODE_CONTROLLER: + case AlexaInterfaceType.MOTION_SENSOR: + case AlexaInterfaceType.PERCENTAGE_CONTROLLER: + case AlexaInterfaceType.PLAYBACK_CONTROLLER: + case AlexaInterfaceType.PLAYBACK_STATE_REPORTER: + case AlexaInterfaceType.PROACTIVE_NOTIFICATION_SOURCE: + case AlexaInterfaceType.RANGE_CONTROLLER: + case AlexaInterfaceType.RECORD_CONTROLLER: + case AlexaInterfaceType.REMOTE_VIDEO_PLAYER: + case AlexaInterfaceType.RTC_SESSION_CONTROLLER: + case AlexaInterfaceType.SCENE_CONTROLLER: + case AlexaInterfaceType.SECURITY_PANEL_CONTROLLER: + case AlexaInterfaceType.SEEK_CONTROLLER: + case AlexaInterfaceType.SIMPLE_EVENT_SOURCE: + case AlexaInterfaceType.SMART_VISION_OBJECT_DETECTION_SENSOR: + case AlexaInterfaceType.SMART_VISION_SNAPSHOT_PROVIDER: + case AlexaInterfaceType.SPEAKER: + case AlexaInterfaceType.STEP_SPEAKER: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_CONFIGURATION: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_HVAC_COMPONENTS: + case AlexaInterfaceType.THERMOSTAT_CONTROLLER_SCHEDULE: + case AlexaInterfaceType.TIME_HOLD_CONTROLLER: + case AlexaInterfaceType.UI_CONTROLLER: + case AlexaInterfaceType.USER_PREFERENCE: + case AlexaInterfaceType.VIDEO_RECORDER: + case AlexaInterfaceType.WAKE_ON_LAN_CONTROLLER: + console.error("UNSUPORTED INTERFACE TYPE"); + return []; + + default: + return []; + } + } + + getJSON(): object { + const doc: any = { + interface: this.getTypeString(), + version: this.getVersion(), + type: "AlexaInterface", + properties: { + retrievable: this.retrievable, + proactivelyReported: this.proactivelyReported, + supported: [{ name: this.getProps()[0] }], + }, + }; + if (this.instance) { + doc.instance = this.instance; + } + if (this.friendlyNames.length > 0) { + const capabilityResources = doc["capabilityResources"] || {}; + const friendlyNamesArray = capabilityResources["friendlyNames"] || []; + this.friendlyNames.forEach((fn) => { + const friendlyNameObj: any = { "@type": "text" }; + friendlyNameObj["value"] = { + text: fn.text, + locale: fn.locale, + }; + friendlyNamesArray.push(friendlyNameObj); + }); + doc["capabilityResources"] = capabilityResources; + } + if (this.actionMappings.length > 0) { + doc.semantics = { + actionMappings: this.actionMappings.map((am) => am.toJSON()), + }; + } + + return doc; + } +} diff --git a/src/AlexaStatusMessage.ts b/src/AlexaStatusMessage.ts new file mode 100644 index 0000000..e8f4f38 --- /dev/null +++ b/src/AlexaStatusMessage.ts @@ -0,0 +1,208 @@ +import { v4 as uuidv4 } from "uuid"; +import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; +import { MqttClient } from "mqtt"; + +export enum EndpointHealth { + OK = "OK", + UNREACHABLE = "UNREACHABLE", +} + +export enum PowerController { + ON = "ON", + OFF = "OFF", +} + +export enum TemperatureSensorScale { + CELSIUS = "CELSIUS", + FAHRENHEIT = "FAHRENHEIT", +} + +interface DirectiveHeader { + namespace: string; + name: string; + payloadVersion: string; + messageId: string; + correlationToken: string; +} + +interface DirectiveEndpoint { + endpointId: string; +} + +interface ContextProperty { + namespace: string; + name: string; + value: any; + timeOfSample: string; + uncertaintyInMilliseconds: number; + instance?: string; +} + +export class AlexaStatusMessage { + private context: { properties: ContextProperty[] } = { properties: [] }; + private event: { + header: DirectiveHeader; + endpoint: DirectiveEndpoint; + payload: {}; + }; + private rootTopic: string; + private endpointId: string; + private mqttClient: MqttClient; + + constructor( + correlationToken: string, + rootTopic: string, + endpointId: string, + mqttClient: MqttClient, + isResponse: boolean = false + ) { + this.rootTopic = rootTopic; + this.endpointId = endpointId; + this.mqttClient = mqttClient; + + this.event = { + header: { + namespace: "Alexa", + name: isResponse ? "Response" : "StateReport", + payloadVersion: "3", + messageId: this.generateMessageId(), + correlationToken, + }, + endpoint: { + endpointId, + }, + payload: {}, + }; + } + + private generateMessageId(): string { + return uuidv4(); + } + + private getTimestamp(): string { + return new Date().toISOString(); + } + + private addProperty( + namespace: AlexaInterfaceType, + name: string, + value: any, + uncertaintyInMilliseconds = 0, + instance?: string + ): this { + const prop: ContextProperty = { + namespace, + name, + value, + timeOfSample: this.getTimestamp(), + uncertaintyInMilliseconds, + }; + if (instance) prop.instance = instance; + this.context.properties.push(prop); + return this; + } + + public addHealthProp(health: EndpointHealth, uncertaintyInMs = 0): this { + return this.addProperty( + AlexaInterfaceType.ENDPOINT_HEALTH, + "connectivity", + { value: health }, + uncertaintyInMs + ); + } + + public addPowerControllerProp( + power: PowerController, + uncertaintyInMs = 0 + ): this { + return this.addProperty( + AlexaInterfaceType.POWER_CONTROLLER, + "powerState", + power, + uncertaintyInMs + ); + } + + public addTemperatureSensorProp( + scale: TemperatureSensorScale, + value: number, + uncertaintyInMs = 0 + ): this { + const tempValue = { + value: + scale === TemperatureSensorScale.FAHRENHEIT + ? (value - 32) * (5 / 9) + : value, + scale: "CELSIUS", + }; + return this.addProperty( + AlexaInterfaceType.TEMPERATURE_SENSOR, + "temperature", + tempValue, + uncertaintyInMs + ); + } + + public addBrightnessControllerProp( + brightness: number, + uncertaintyInMs = 0 + ): this { + return this.addProperty( + AlexaInterfaceType.BRIGHTNESS_CONTROLLER, + "brightness", + brightness, + uncertaintyInMs + ); + } + + public addColorTemperatureControllerProp( + colorTemp: number, + uncertaintyInMs = 0 + ): this { + return this.addProperty( + AlexaInterfaceType.COLOR_TEMPERATURE_CONTROLLER, + "colorTemperatureInKelvin", + colorTemp, + uncertaintyInMs + ); + } + + public addToggleControllerProp( + state: PowerController, + instance: string, + uncertaintyInMs = 0 + ): this { + return this.addProperty( + AlexaInterfaceType.TOGGLE_CONTROLLER, + "toggleState", + state, + uncertaintyInMs, + instance + ); + } + + public addContextProp(prop: ContextProperty): this { + this.context.properties.push(prop); + return this; + } + + public send(): void { + const payload = { + context: this.context, + event: this.event, + }; + const topic = `${this.rootTopic}/${this.endpointId}/alexaResponce`;//Yes this should be response but it is incorrect in both Alex2MQTT and Alex2ESP so for consistency is is wrong here too + const payloadStr = JSON.stringify(payload); + + this.mqttClient.publish(topic, payloadStr, (err) => { + if (err) { + console.error( + "[AlexaStatusMessage] Failed to publish status message:", + err + ); + } else { + // console.log(`[AlexaStatusMessage] Sent message to ${topic}`); + } + }); + } +} diff --git a/src/Device.ts b/src/Device.ts new file mode 100644 index 0000000..80fa436 --- /dev/null +++ b/src/Device.ts @@ -0,0 +1,128 @@ +import { AlexaInterface, AlexaInterfaceType } from "./AlexaInterface"; +import { DisplayCategory } from "./DisplayCategory"; +import { EventEmitter } from "events"; +import { MqttClient } from "mqtt"; +import { AlexaStatusMessage } from "./AlexaStatusMessage"; + +class Device extends EventEmitter { + protected softwareVersion: string = "1.0.0"; + private capabilities: AlexaInterface[] = []; + + constructor( + private mqttClient: MqttClient, + private rootTopic: string, + public name: string, + public endpointId: string, + public displayCategory: DisplayCategory = DisplayCategory.LIGHT, + public description: string = "Alexa to Node.js bridge", + public manufacturerName: string = "Alex2Node", + public manufacturer: string = "Alex2Node", + public model: string = "Alex2Node_v1.0.0" + ) { + super(); + } + + getName(): string { + return this.name; + } + + setName(name: string): void { + this.name = name; + } + + getEndpointId(): string { + return this.endpointId; + } + + setDisplayCategory(category: DisplayCategory): void { + this.displayCategory = category; + } + + getDisplayCategory(): DisplayCategory { + return this.displayCategory; + } + + setDescription(description: string): void { + this.description = description; + } + + getDescription(): string { + return this.description; + } + getStatusMessage( + correlationToken: string, + isResponse = false + ): AlexaStatusMessage { + return new AlexaStatusMessage( + correlationToken, + this.rootTopic, + this.endpointId, + this.mqttClient, + isResponse + ); + } + setManufacturerName(name: string): void { + this.manufacturerName = name; + } + + getManufacturerName(): string { + return this.manufacturerName; + } + + setManufacturer(manufacturer: string): void { + this.manufacturer = manufacturer; + } + + getManufacturer(): string { + return this.manufacturer; + } + + setModel(model: string): void { + this.model = model; + } + + getModel(): string { + return this.model; + } + + getSoftwareVersion(): string { + return this.softwareVersion; + } + + addCapability(type: AlexaInterfaceType): AlexaInterface { + const existing = this.capabilities.find( + (iface) => iface.getType() === type + ); + if (existing) { + return existing; + } + + const newCapability = new AlexaInterface(type); + this.capabilities.push(newCapability); + + // console.log( + // `Created new interface with type: ${newCapability.getTypeString()}` + // ); + return newCapability; + } + getJSON(): Record { + return { + endpointId: this.endpointId, + friendlyName: this.name, + description: this.description, + manufacturerName: this.manufacturerName, + displayCategories: [this.displayCategory], + additionalAttributes: { + manufacturer: this.manufacturer, + model: this.model, + serialNumber: "Alex2Node", + firmwareVersion: "1.0.0", + softwareVersion: this.softwareVersion, + customIdentifier: "Alex2Node", + }, + capabilities: this.capabilities.map((cap) => cap.getJSON()), + }; + } +} + +export default Device; diff --git a/src/DisplayCategory.ts b/src/DisplayCategory.ts new file mode 100644 index 0000000..7398650 --- /dev/null +++ b/src/DisplayCategory.ts @@ -0,0 +1,58 @@ +export enum DisplayCategory { + ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER", + AIR_CONDITIONER = "AIR_CONDITIONER", + AIR_FRESHENER = "AIR_FRESHENER", + AIR_PURIFIER = "AIR_PURIFIER", + AIR_QUALITY_MONITOR = "AIR_QUALITY_MONITOR", + ALEXA_VOICE_ENABLED = "ALEXA_VOICE_ENABLED", + AUTO_ACCESSORY = "AUTO_ACCESSORY", + BLUETOOTH_SPEAKER = "BLUETOOTH_SPEAKER", + CAMERA = "CAMERA", + CHRISTMAS_TREE = "CHRISTMAS_TREE", + COFFEE_MAKER = "COFFEE_MAKER", + COMPUTER = "COMPUTER", + CONTACT_SENSOR = "CONTACT_SENSOR", + DISHWASHER = "DISHWASHER", + DOOR = "DOOR", + DOORBELL = "DOORBELL", + DRYER = "DRYER", + EXTERIOR_BLIND = "EXTERIOR_BLIND", + FAN = "FAN", + GAME_CONSOLE = "GAME_CONSOLE", + GARAGE_DOOR = "GARAGE_DOOR", + HEADPHONES = "HEADPHONES", + HUB = "HUB", + INTERIOR_BLIND = "INTERIOR_BLIND", + LAPTOP = "LAPTOP", + LIGHT = "LIGHT", + MICROWAVE = "MICROWAVE", + MOBILE_PHONE = "MOBILE_PHONE", + MOTION_SENSOR = "MOTION_SENSOR", + MUSIC_SYSTEM = "MUSIC_SYSTEM", + NETWORK_HARDWARE = "NETWORK_HARDWARE", + OTHER = "OTHER", + OVEN = "OVEN", + PHONE = "PHONE", + PRINTER = "PRINTER", + REMOTE = "REMOTE", + ROUTER = "ROUTER", + SCENE_TRIGGER = "SCENE_TRIGGER", + SCREEN = "SCREEN", + SECURITY_PANEL = "SECURITY_PANEL", + SECURITY_SYSTEM = "SECURITY_SYSTEM", + SLOW_COOKER = "SLOW_COOKER", + SMARTLOCK = "SMARTLOCK", + SMARTPLUG = "SMARTPLUG", + SPEAKER = "SPEAKER", + STREAMING_DEVICE = "STREAMING_DEVICE", + SWITCH = "SWITCH", + TABLET = "TABLET", + TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR", + THERMOSTAT = "THERMOSTAT", + TV = "TV", + VACUUM_CLEANER = "VACUUM_CLEANER", + VEHICLE = "VEHICLE", + WASHER = "WASHER", + WATER_HEATER = "WATER_HEATER", + WEARABLE = "WEARABLE", +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..77b9e07 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,4 @@ +export { default as Alex2MQTT } from './Alex2Node'; +export { AlexaInterfaceType } from './AlexaInterface'; +export { ActionMapping, AlexaActions } from './ActionMapping'; +export { PowerController } from './AlexaStatusMessage'; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a8fefa8 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES6", + "module": "CommonJS", + "moduleResolution": "node", + "outDir": "./dist", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] + } + \ No newline at end of file