Created
March 5, 2023 13:50
-
-
Save sroebert/6d6f8071872b50d89119d173361fc66c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*global Shelly, MQTT */ | |
/** | |
* Variables | |
*/ | |
let FIRMWARE_VERSION; | |
let MQTT_TOPIC_PREFIX; | |
let COMPONENT_CONFIG_DICT = {}; | |
let DEVICE_COMPONENT_DICT = {}; | |
let DEVICE_COMPONENT_ARRAY = []; | |
let DEVICE_INFO = {}; | |
/** | |
* Utils | |
*/ | |
function toString(value) { | |
let type = typeof value; | |
if (type === "string") { | |
return value; | |
} | |
if (type === "undefined") { | |
return ""; | |
} | |
return JSON.stringify(value); | |
} | |
function removePrefix(string, prefix) { | |
if (string.indexOf(prefix) !== 0) { | |
return string; | |
} | |
return string.slice(prefix.length); | |
} | |
function splitString(string, separator) { | |
if (string.length === 0) { | |
return []; | |
} | |
let offset = 0; | |
let parts = []; | |
while (offset <= string.length) { | |
let nextOffset = -1; | |
if (offset < string.length) { | |
nextOffset = string.indexOf(separator, offset); | |
} | |
if (nextOffset === -1) { | |
parts.push(string.slice(offset, string.length)); | |
return parts; | |
} | |
parts.push(string.slice(offset, nextOffset)); | |
offset = nextOffset + 1; | |
} | |
return parts; | |
} | |
function joinArray(array, separator) { | |
if (array.length === 0) { | |
return ""; | |
} | |
let result = ""; | |
for (let i = 0; i < array.length - 1; i += 1) { | |
result += toString(array[i]) + separator; | |
} | |
result += toString(array[array.length - 1]); | |
return result; | |
} | |
function parseInstanceIdString(instanceId) { | |
if (typeof instanceId !== "string" || instanceId.length === 0) { | |
return undefined; | |
} | |
for (let i = 0; i < instanceId.length; i += 1) { | |
let character = instanceId.at(i); | |
if ( | |
character < 48 || // <0 | |
character > 57 || // >9 | |
(character === 48 && i === 0 && instanceId.length > 1) // 0[0-9]+ | |
) { | |
return undefined; | |
} | |
} | |
return JSON.parse(instanceId); | |
} | |
function parseComponentId(id) { | |
if (typeof id !== "string") { | |
return undefined; | |
} | |
let parts = splitString(id, ":"); | |
let type = parts[0]; | |
let instanceId = parseInstanceIdString(parts[1]); | |
if ( | |
parts.length !== 1 && | |
(parts.length !== 2 || typeof instanceId !== "number") | |
) { | |
return undefined; | |
} | |
let instanceIdString; | |
if (instanceId !== undefined) { | |
instanceIdString = toString(instanceId); | |
} | |
return { | |
id: id, | |
type: type, | |
instanceId: instanceId, | |
instanceIdString: instanceIdString, | |
}; | |
} | |
function mqttPayloadForPowerValue(value) { | |
if (typeof value !== "number") { | |
return undefined; | |
} | |
let roundedValue = Math.round(value * 100); | |
let payload = toString(roundedValue); | |
while (payload.length < 3) { | |
payload = "0" + payload; | |
} | |
payload = payload.slice(0, -2) + "." + payload.slice(-2); | |
return payload; | |
} | |
function mqttPayloadForTempValue(value) { | |
if (typeof value !== "object" || typeof value.tC !== "number") { | |
return undefined; | |
} | |
let roundedValue = Math.round(value.tC * 10); | |
let payload = toString(roundedValue); | |
while (payload.length < 3) { | |
payload = "0" + payload; | |
} | |
payload = payload.slice(0, -1) + "." + payload.slice(-1); | |
return payload; | |
} | |
/** | |
* General Data | |
*/ | |
function onSystemStatusChange(status) { | |
if ( | |
typeof status.available_updates === "object" && | |
status.available_updates.stable !== undefined | |
) { | |
if (status.available_updates.stable !== null) { | |
DEVICE_INFO.firmwareUpdateVersion = | |
status.available_updates.stable.version; | |
} else { | |
DEVICE_INFO.firmwareUpdateVersion = null; | |
} | |
} else if (status.available_updates === null) { | |
DEVICE_INFO.firmwareUpdateVersion = null; | |
} | |
} | |
function onWifiStatusChange(status) { | |
if (status.sta_ip !== undefined) { | |
DEVICE_INFO.wifiIp = status.sta_ip; | |
} | |
if (status.ssid !== undefined) { | |
DEVICE_INFO.wifiSsid = status.ssid; | |
} | |
} | |
function publishInfo() { | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/info", | |
JSON.stringify({ | |
wifi_sta: { | |
connected: DEVICE_INFO.wifiSsid ? true : false, | |
ip: DEVICE_INFO.wifiIp, | |
ssid: DEVICE_INFO.wifiSsid, | |
}, | |
update: { | |
old_version: FIRMWARE_VERSION, | |
new_version: DEVICE_INFO.firmwareUpdateVersion | |
? DEVICE_INFO.firmwareUpdateVersion | |
: FIRMWARE_VERSION, | |
}, | |
}) | |
); | |
} | |
/** | |
* Input | |
*/ | |
function publishInput(status, component) { | |
if (typeof status.state === "boolean" || status.state === null) { | |
let payload; | |
if (status.state === null) { | |
payload = "null"; | |
} else { | |
payload = status.state ? "1" : "0"; | |
} | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/input/" + component.instanceIdString, | |
payload | |
); | |
} | |
} | |
/** | |
* Switch | |
*/ | |
function onSwitchCommand(command, component) { | |
if (command !== "on" && command !== "off") { | |
return; | |
} | |
Shelly.call("Switch.Set", { | |
id: component.instanceId, | |
on: command === "on", | |
}); | |
} | |
function publishSwitch(status, component) { | |
let instanceId = component.instanceIdString; | |
if (typeof status.output === "boolean") { | |
let payload = status.output ? "on" : "off"; | |
MQTT.publish(MQTT_TOPIC_PREFIX + "/switch/" + instanceId, payload); | |
} | |
let powerPayload = mqttPayloadForPowerValue(status.apower); | |
if (powerPayload !== undefined) { | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/switch/" + instanceId + "/power", | |
powerPayload | |
); | |
} | |
if ( | |
typeof status.aenergy === "object" && | |
typeof status.aenergy.total === "number" | |
) { | |
let value = Math.round(status.aenergy.total * 60); | |
let payload = toString(value); | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/switch/" + instanceId + "/energy", | |
payload | |
); | |
} | |
let tempPayload = mqttPayloadForTempValue(status.temperature); | |
if (tempPayload !== undefined) { | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/switch/" + instanceId + "/temperature", | |
tempPayload | |
); | |
} | |
} | |
/** | |
* Energy Meter | |
*/ | |
function publishEnergyMeter(status, component) { | |
let instanceId = component.instanceIdString; | |
let ids = ["a", "b", "c", "total"]; | |
for (let i = 0; i < ids.length; i += 1) { | |
let id = ids[i]; | |
let key = id + "_act_power"; | |
let payload = mqttPayloadForPowerValue(status[key]); | |
if (payload !== undefined) { | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/energy_meter/" + instanceId + "/power_" + id, | |
payload | |
); | |
} | |
} | |
} | |
function publishEnergyMeterData(status, component) { | |
let instanceId = component.instanceIdString; | |
let ids = ["a", "b", "c", "total"]; | |
for (let i = 0; i < ids.length; i += 1) { | |
let id = ids[i]; | |
let usedKey = id + "_total_act_energy"; | |
let returnedKey = id + "_total_act_ret_energy"; | |
if (id === "total") { | |
usedKey = "total_act"; | |
returnedKey = "total_act_ret"; | |
} | |
let usedPayload = mqttPayloadForPowerValue(status[usedKey]); | |
if (usedPayload !== undefined) { | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + "/energy_meter/" + instanceId + "/energy_" + id, | |
usedPayload | |
); | |
} | |
let returnedPayload = mqttPayloadForPowerValue(status[returnedKey]); | |
if (returnedPayload !== undefined) { | |
MQTT.publish( | |
MQTT_TOPIC_PREFIX + | |
"/energy_meter/" + | |
instanceId + | |
"/energy_returned_" + | |
id, | |
returnedPayload | |
); | |
} | |
} | |
} | |
/** | |
* Events | |
*/ | |
function onStatusEvent(event) { | |
if ( | |
typeof event !== "object" || | |
typeof event.delta !== "object" || | |
typeof event.component !== "string" | |
) { | |
return; | |
} | |
let component = DEVICE_COMPONENT_DICT[event.component]; | |
if (!component) { | |
return; | |
} | |
let config = COMPONENT_CONFIG_DICT[component.type]; | |
if (config.isPartOfInfo) { | |
config.changeHandler(event.delta, component); | |
publishInfo(); | |
} else { | |
config.publishHandler(event.delta, component); | |
} | |
} | |
/** | |
* MQTT | |
*/ | |
function announce() { | |
MQTT.publish(MQTT_TOPIC_PREFIX + "/online", "true"); | |
publishInfo(); | |
for (let i = 0; i < DEVICE_COMPONENT_ARRAY.length; i += 1) { | |
let component = DEVICE_COMPONENT_ARRAY[i]; | |
let config = COMPONENT_CONFIG_DICT[component.type]; | |
if (config.isPartOfInfo) { | |
continue; | |
} | |
let status = Shelly.getComponentStatus(component.id); | |
config.publishHandler(status, component); | |
} | |
} | |
function onMqttCommand(topic, command) { | |
if (command === "announce") { | |
announce(); | |
} else if (command === "reboot") { | |
Shelly.call("Shelly.Reboot", {}); | |
} else if (command === "update_fw") { | |
Shelly.call("Shelly.Update", {}); | |
} | |
} | |
function onComponentMqttCommand(topic, command) { | |
let suffix = removePrefix(topic, MQTT_TOPIC_PREFIX + "/"); | |
let parts = splitString(suffix, "/"); | |
parts.splice(-1); | |
let id = joinArray(parts, ":"); | |
let component = DEVICE_COMPONENT_DICT[id]; | |
if (!component) { | |
return; | |
} | |
let config = COMPONENT_CONFIG_DICT[component.type]; | |
if (!config.commandHandler) { | |
return; | |
} | |
config.commandHandler(command, component); | |
} | |
function setupMqttSubscriptions() { | |
MQTT.subscribe("shellies/command", onMqttCommand); | |
MQTT.subscribe(MQTT_TOPIC_PREFIX + "/command", onMqttCommand); | |
MQTT.subscribe(MQTT_TOPIC_PREFIX + "/+/command", onComponentMqttCommand); | |
MQTT.subscribe(MQTT_TOPIC_PREFIX + "/+/+/command", onComponentMqttCommand); | |
} | |
function onMqttConnect() { | |
setupMqttSubscriptions(); | |
announce(); | |
} | |
/** | |
* Setup | |
*/ | |
function configureDevice() { | |
Shelly.call("Sys.SetConfig", { | |
config: { | |
device: { | |
discoverable: false, | |
}, | |
}, | |
}); | |
} | |
function setupConstants() { | |
let deviceInfo = Shelly.getDeviceInfo(); | |
FIRMWARE_VERSION = deviceInfo.fw_id; | |
let mqttConfig = Shelly.getComponentConfig("mqtt"); | |
MQTT_TOPIC_PREFIX = mqttConfig.topic_prefix; | |
} | |
function setupComponentConfigs() { | |
let configs = [ | |
{ | |
type: "sys", | |
isPartOfInfo: true, | |
changeHandler: onSystemStatusChange, | |
}, | |
{ | |
type: "wifi", | |
isPartOfInfo: true, | |
changeHandler: onWifiStatusChange, | |
}, | |
{ | |
type: "input", | |
hasInstanceId: true, | |
publishHandler: publishInput, | |
}, | |
{ | |
type: "switch", | |
hasInstanceId: true, | |
commandHandler: onSwitchCommand, | |
publishHandler: publishSwitch, | |
}, | |
{ | |
type: "em", | |
hasInstanceId: true, | |
publishHandler: publishEnergyMeter, | |
}, | |
{ | |
type: "emdata", | |
hasInstanceId: true, | |
publishHandler: publishEnergyMeterData, | |
}, | |
]; | |
for (let i = 0; i < configs.length; i += 1) { | |
let config = configs[i]; | |
COMPONENT_CONFIG_DICT[config.type] = config; | |
} | |
} | |
function setupDeviceComponents(deviceStatus) { | |
for (let id in deviceStatus) { | |
let component = parseComponentId(id); | |
if (!component) { | |
continue; | |
} | |
let config = COMPONENT_CONFIG_DICT[component.type]; | |
if ( | |
!config || | |
(config.hasInstanceId && typeof component.instanceId === "undefined") | |
) { | |
continue; | |
} | |
DEVICE_COMPONENT_DICT[id] = component; | |
DEVICE_COMPONENT_ARRAY.push(component); | |
if (config.isPartOfInfo) { | |
config.changeHandler(deviceStatus[id]); | |
} | |
} | |
} | |
/** | |
* Start | |
*/ | |
Shelly.call("Shelly.GetStatus", {}, function (deviceStatus) { | |
configureDevice(); | |
setupConstants(); | |
setupComponentConfigs(); | |
setupDeviceComponents(deviceStatus); | |
if (MQTT.isConnected()) { | |
onMqttConnect(); | |
} | |
Shelly.addStatusHandler(onStatusEvent); | |
MQTT.setConnectHandler(onMqttConnect); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment