homebridge-smartsystem
Version:
SmartServer (Proxy Websockets to TCP sockets, Smappee MQTT, Duotecno IP Nodes, Homekit interface)
1,281 lines (1,049 loc) • 48 kB
text/typescript
// SmartApp implementation with Webapp
// Purpose:
// - select IP nodes & units to include -> generate config file
// - attach Smappee and rules -> update config file
// - control units from the IP nodes as test
//
// Johan Coppieters, Feb 2019.
//
// v2.0: mar/apr 2020
import { Context, HttpResponse, WebApp } from "./webapp";
import { Sanitizers, Rule, kEmptyRule, actionValue, Action, kEmptySwitch, Switch, SwitchType, hex, Binding, NetworkConfig, SettingsConfig, kEmptySettings, Link, LinkType, kEmptyLink } from "../duotecno/types";
import { log, debug, err } from "../duotecno/logger";
import { System } from "../duotecno/system";
import { Node, Unit } from "../duotecno/protocol";
import { Smappee } from "./smappee";
import { Master } from "../duotecno/master";
import { Platform } from "./platform";
import * as somfy from "./somfy";
import { existsSync, truncateSync, writeFileSync } from 'fs';
import { Shelly } from './shelly';
import { P1 } from './p1';
import { setToken } from "../duotecno/config";
import { PowerBase } from "./powerbase";
import { dt2hbvalue, hb2dtvalue, hbAccessories, hbGetValue, hbLogin, hbName, hbService, hbSetState, hbStop, hbSubscribe } from "./HB";
import { httpRequest, ContentType } from "./HA-API";
import { cleanStart, kEmptyProxy, ProxyConfig, setProxyConfig } from "./proxy";
const kMaster = {name: "master", type: "string", default: "0.0.0.0:5001"} as const;
const kAddress = {name: "address", type: "string", default: "0.0.0.0"} as const;
const kAddresses = {name: "addresses", type: "string", default: "[0.0.0.0]"} as const;
const kAddressOld = {name: "addressX", type: "string", default: "0.0.0.0"} as const;
const kPort = {name: "port", type: "integer", default: 80} as const;
const kPortOld = {name: "portX", type: "integer", default: 80} as const;
const kActive = {name: "active", type: "string", default: "N"} as const;
const kUID = {name: "uid", type: "string", default: ""} as const;
const kName = {name: "name", type: "string", default: "no name"} as const;
const kPassword = {name: "password", type: "string", default: "no password"} as const;
const kNode = {name: "node", type: "integer", default: 0} as const;
const kUnit = {name: "unit", type: "integer", default: 0} as const;
const kValue = {name: "value"} as const;
const kIntValue = {name: "value", type: "integer", default: 0} as const;
const kPin = {name: "pin", type: "string", default: "577-03-001"} as const;
// Mac file system -> write locally
const isMac = existsSync("/Volumes");
const kDHCPConfigFile = isMac ? "./config.dhcpcd" : "/etc/dhcpcd.conf";
export interface Power {
smappee?: Smappee;
shelly?: Shelly;
p1?: P1;
}
export class SmartApp extends WebApp {
system: System;
power: Power;
platform: Platform;
switches: Array<Switch>;
links: Array<Link>;
proxy: ProxyConfig;
constructor(system: System, power: Power, platform: Platform) {
super("smartapp");
this.system = system;
// get status change updates
this.system.emitter.on('update', this.informChange.bind(this));
this.system.emitter.on('update', this.informLinks.bind(this));
this.system.emitter.on('plug', this.alertSwitch.bind(this));
// get some configurated params
this.port = this.config.port || this.port || 80;
this.password = platform.config.password || this.config.password || "";
this.user = platform.config.user || this.config.user || "pi";
this.system = system;
this.power = power;
this.platform = platform;
// when all masters are loaded -> attach units to the switches
this.system.emitter.on('ready', this.initSwitchUnits.bind(this));
this.system.emitter.on('ready', this.initLinks.bind(this));
this.addFile("unitList", __dirname + "/views/unit-list.ejs", "application/json");
this.addFile("masterList", __dirname + "/views/master-list.ejs", "text/html");
this.addFile("masterDetail", __dirname + "/views/master-detail.ejs", "text/html");
this.addFile("nodeDetail", __dirname + "/views/node-details.ejs", "text/html");
this.addFile("serviceList", __dirname + "/views/service-list.ejs", "text/html");
this.addFile("power", __dirname + "/views/power.ejs", "text/html");
this.addFile("login", __dirname + "/views/login.ejs", "text/html");
this.addFile("settings", __dirname + "/views/settings.ejs", "text/html");
this.addFile("switchDetail", __dirname + "/views/switch-details.ejs", "text/html");
this.addFile("switchList", __dirname + "/views/switch-list.ejs", "text/html");
this.addFile("linkDetail", __dirname + "/views/link-details.ejs", "text/html");
this.addFile("linkList", __dirname + "/views/link-list.ejs", "text/html");
this.addFile("powerRule", __dirname + "/views/power-rule.ejs", "text/html");
this.addFile("powerBinding", __dirname + "/views/power-binding.ejs", "text/html");
this.addFile("materializeCSS", __dirname + "/views/assets/materialize.min.css", "text/css");
this.addFile("materializeJS", __dirname + "/views/assets/materialize.min.js", "text/javascript");
this.addFile("favicon", __dirname + "/views/assets/favicon.ico", "image/x-icon");
this.addFile("proxy", __dirname + "/views/proxy.ejs", "text/html");
}
writeConfig() {
// copy switches into config, eliminate the runtime stuff (like unit)
this.config.switches = this.switches.map(s => Sanitizers.makeSwitchConfig(s));
this.config.links = this.links.map(s => Sanitizers.makeLinkConfig(s));
super.writeConfig();
}
readConfig() {
super.readConfig();
// copy switches from config
this.switches = this.config.switches.map(s => Sanitizers.switchConfig(s));
this.links = this.config.links.map(s => Sanitizers.linkConfig(s));
}
checkReady(context: Context) {
if (this.platform && !this.platform.ready) {
context["notReady"] = true;
context["notReadyMessage"] = "=== waiting >> found " +
this.system.allActiveUnits().length + " units out of " + this.system.activeUnitsConfig().length + " selected after " +
this.platform.startWaiting + " sec ===";
log("smartapp", "Platform not ready -> " + context["notReadyMessage"])
} else {
context["notReady"] = false;
context["notReadyMessage"] = "";
}
}
//////////////
// password //
//////////////
async pwOK(context: Context, user: string, pw: string): Promise<boolean> {
// to bypass user/pw ->
//return true;
this.token = "";
setToken("");
try {
const access_token = await this.checkLogin(this.config.apiHost +":"+ this.config.apiPort, user, pw);
if (access_token) {
// save username / password in config
this.config.username = user;
this.config.password = pw;
this.writeConfig();
this.token = access_token;
setToken(this.token);
return true;
}
} catch(e) {
debug("smartapp", "Login API failed " + JSON.stringify(e));
}
return false;
}
//////////////////////////////
// Router //
//////////////////////////////
needsLogin(context: Context): boolean {
const noNeed = ((context.request === "units") || (context.request === "switches") || (context.request === "links")) &&
((context.action == "press") || (context.action == "get") || (context.action == "set"));
return super.needsLogin(context) && (context.request != "files") && (!noNeed);
}
async doRequest(context: Context): Promise<HttpResponse> {
this.checkReady(context);
if (context.request === "") context.request= "masters";
context["hasSmappee"] = !!this.power.smappee;
context["hasP1"] = !!this.power.p1;
context["hasShelly"] = !!this.power.shelly;
// try "[get]/set" url: node/unit[/status]
const res = await this.tryNumURL(context);
if (res) {
return res;
} else if (context.request === "files") {
return this.renderAssets(context);
} else if (context.request === "images") {
return this.renderImage(context);
} else if (context.request === "masters") {
return this.doMasters(context);
} else if (context.request === "units") {
return this.doUnits(context);
} else if (context.request === "services") {
return this.doServices(context);
} else if (context.request === "power") {
return this.doPower(context);
} else if (context.request === "links") {
return this.doLinks(context);
} else if (context.request === "switches") {
return this.doSwitches(context);
} else if (context.request === "settings") {
return this.doSettings(context);
} else if (context.request === "proxy") {
return this.doProxy(context);
} else {
return super.doRequest(context);
}
}
async tryNumURL(context: Context): Promise<HttpResponse> {
const node = context.nums[0];
const master = this.system.masters[0];
if ((!node) || (!master)) return null;
const unit = context.nums[1];
const state = context.nums[2];
if (typeof state === "undefined")
return this.json(await this.getState(master, node, unit))
else
return this.json(await this.setState(master, node, unit, state));
}
scrapeUnit(context: Context, boundary: string): Action {
context.getMaster("action");
const master = this.system.findMaster(context["masterAddress"], context["masterPort"]);
let unit: Unit = null;
let logicalNodeAddress: number, logicalAddress: number;
let name = context.getParam({ name: "unit"+boundary, type: "string", default: "--" });
const value = actionValue(context.getParam({ name: "value"+boundary, type: "string", default: "0" }));
// hex addresses or name
if ((name[0] === "0") && (name[1] === "x")) {
({ logicalNodeAddress, logicalAddress } = context.addr(name));
unit = this.system.findUnit(master, logicalNodeAddress, logicalAddress);
name = (unit) ? unit.displayName : "--";
} else {
unit = this.system.findUnitByName(master, name);
logicalNodeAddress = (unit) ? unit.node.logicalAddress : 0;
logicalAddress = (unit) ? unit.logicalAddress : 0;
}
return { name, value, masterAddress: context["masterAddress"], masterPort: context["masterPort"],
logicalAddress, logicalNodeAddress };
}
///////////
// Proxy //
///////////
async doProxy(context: Context): Promise<HttpResponse> {
let config: ProxyConfig = this.config.proxy || kEmptyProxy;
if (context.action === "save") {
// save the proxy config
config.cloudServer = context.getParam({name: "cloudServer", type: "string"});
config.cloudPort = context.getParam({name: "cloudPort", type: "integer"});
config.masterAddress = context.getParam({name: "masterAddress", type: "string"});
config.masterPort = context.getParam({name: "masterPort", type: "integer"});
config.uniqueId = context.getParam({name: "uniqueId", type: "string"});
this.write("proxy", config);
setProxyConfig(config);
} else if (context.action === "restart") {
cleanStart(true);
} else {
// just show the proxy config
}
return this.ejs("proxy", context, { config });
}
//////////////
// Settings //
//////////////
async doSettings(context: Context): Promise<HttpResponse> {
let config = this.read("settings");
if (context.action === "save") {
config = this.scrapeSettings(context);
this.write("settings", config);
} else if (context.action === "restart") {
context.request = "restart";
return this.doRestart(false)
} else if (context.action === "install") {
config = this.scrapeSettings(context);
context.request = "install";
this.write("settings", config);
this.writeDHCP(context, <SettingsConfig>config);
} else if (context.action === "reset") {
config = { ...kEmptySettings };
this.resetDHCP(context);
this.write("settings", config);
return this.doReboot(context, false);
} else if (context.action === "reboot") {
return this.doReboot(context, false);
} else {
}
return this.ejs("settings", context, { config });
}
scrapeSettings(context: Context): SettingsConfig {
return Sanitizers.settings({
network: {
ip1: context.getParam({name: "ip1", type: "string"}),
gateway1: context.getParam({name: "gateway1", type: "string"}),
nameservers: context.getParam({name: "nameservers", type: "string"}),
ip2: context.getParam({name: "ip2", type: "string"}),
gateway2: context.getParam({name: "gateway2", type: "string"})
}
});
}
writeDHCP(context: Context, config: SettingsConfig) {
try {
this.resetDHCP(context);
writeFileSync(kDHCPConfigFile, this.DHCPdFile(config.network));
} catch(err) {
context.message = JSON.stringify(err);
}
}
resetDHCP(context: Context) {
try {
if (existsSync(kDHCPConfigFile))
truncateSync(kDHCPConfigFile);
} catch(err) {
context.message = JSON.stringify(err);
}
}
DHCPdFile(config: NetworkConfig): string {
const ip1 = "interface eth0\n" +
"static ip_address=" + config.ip1 + "\n" +
"static routers="+ config.gateway1 + "\n\n" +
"static domain_name_servers=" + config.nameservers;
if (config.ip2) {
return ip1 + "\n\n" +
"interface eth0:0\n" +
"static ip_address=" + config.ip2 + "\n" +
"static routers=" + config.gateway2
} else {
return ip1;
}
}
//////////////////////////////
// Power Stuff //
//////////////////////////////
async doPower(context: Context): Promise<HttpResponse> {
let message: string;
const id: number = parseInt(context.id);
const type = context.getParam({name: "type", type: "string", default: "smappee"});
const power: PowerBase = this.power[type];
if (!power) {
return this.error(context, "Power type not implemented", false);
}
// remember the power-type
context["type"] = type;
// if not found or action done -> drop through and list again the power attributes + all rules
try {
if (context.action === "add") {
const rule = power.newRule();
return this.ejs("powerRule", context, { rule, id: power.rules.length-1, masters: this.system.masters, type });
} else if (context.action === "rule") {
if (id >= 0)
return this.ejs("powerRule", context, { rule: power.rules[id], id, masters: this.system.masters, type });
} else if (context.action === "delete") {
if (id >= 0)
power.deleteRule( id );
} else if (context.action === "change") {
power.updateRule( id, this.scrapeRule(context) );
} else if (context.action === "save") {
power.updateConfig({address: context.getParam(kAddress), uid: context.getParam(kUID), addresses: context.getParam(kAddresses)});
} else if (context.action === "badd") {
const binding = power.newBinding();
return this.ejs("powerBinding", context, { binding, id: power.bindings.length-1, masters: this.system.masters, type });
} else if (context.action === "binding") {
if (id >= 0)
return this.ejs("powerBinding", context, { binding: power.bindings[id], id, masters: this.system.masters, type });
} else if (context.action === "bdelete") {
if (id >= 0)
power.deleteBinding( id );
} else if (context.action === "bchange") {
power.updateBinding( id, this.scrapeBinding(context) );
} else {
return this.ejs("power", context, await power.getData(context, ""));
}
} catch(e) {
message = e.toString();
}
return this.ejs("power", context, await power.getData(context, ""));
}
scrapeBinding(context: Context): Binding {
context.getMaster("action");
const register = context.getParam({ name: "register", type: "integer", default: 0 });
const channel = context.getParam({name: "channel", type: "string", default: "0"})
return { masterAddress: context["masterAddress"], masterPort: context["masterPort"],
register, channel };
}
scrapeAction(context: Context, boundary: string): Action {
const { name, value, masterAddress, masterPort, logicalAddress, logicalNodeAddress } = this.scrapeUnit(context, boundary);
return {
name, value, masterAddress, masterPort, logicalAddress, logicalNodeAddress
}
}
scrapeRule(context: Context): Rule {
// deep copy an empty rule
let rule = {... kEmptyRule}; // = shallow copy, re-assign actions later
// get the form values
rule.type = context.getParam({name: "rtype", type: "string", default: rule.type });
rule.channel = context.getParam({name: "channel", type: "string", default: rule.channel });
rule.low = context.getParam({name: "low", type: "integer", default: rule.low });
rule.high = context.getParam({name: "high", type: "integer", default: rule.high });
rule.actions = [this.scrapeUnit(context, "low"),
this.scrapeUnit(context, "mid"),
this.scrapeUnit(context, "high")];
return rule;
}
///////////
// Links //
///////////
initLinks() {
log("smartapp", "Init " + this.links.length + " Links -> add units");
this.links.forEach(link => {
link.unit ||= this.system.findUnit(this.system.findMaster(link.masterAddress, link.masterPort),
link.logicalNodeAddress, link.logicalAddress);
if (link.unit) {
link.dtValue = link.unit.value;
} else {
log("smartapp", "** error ** missing unit: " + hex(link.logicalNodeAddress)+"/"+hex(link.logicalAddress) + " **");
}
});
hbSubscribe(async () => {
// Get all values for subscribed accessories (links)
if (this.links.length && await this.checkToken()) {
for (const link of this.links) {
if ((link.type === LinkType.kAccessory) && link.unit) {
const hbValue = await hbGetValue(this.config.apiHost +":"+ this.config.apiPort, link.accId, link.accService, this.token);
debug("smartapp", "Received HB value for accessory: " + link.accName + " -> " + hbValue + " from accessory");
if (typeof hbValue != "undefined") {
const {dtValue, dtTarget} = hb2dtvalue(link.unit, hbValue);
if (dtValue !== link.dtValue) { // || state !== link.status
log("smartapp", "-> Setting value of Duotecno unit: " + link.unit.getDisplayName() + " to " + dtTarget + " of HB accessory: " + link.accName + " with value " + hbValue);
link.dtValue = dtValue;
link.unit.setState(dtTarget);
} else {
debug("smartapp", "-> No change for unit: " + link.unit.getDisplayName() + " to " + dtValue);
}
}
}
}
}
});
}
async informLinks(u: Unit, kind: string) {
debug("smartapp", "Received DT value from unit: " + u.getDisplayName() + " -> " + u.value + " / " + u.status + " from unit");
for (const link of this.links) {
if (u.isUnit(link.masterAddress, link.masterPort, link.logicalNodeAddress, link.logicalAddress)) {
const hbValue = dt2hbvalue(u, link.hbValue as number, link.min, link.max);
if (typeof hbValue != "undefined") {
if (hbValue === -1) {
log("smartapp", "-> Virtual unit send 'stop' for accessory: " + link.accName + " of Duotecno unit: " + u.getDisplayName());
const val = await hbStop(this.config.apiHost +":"+ this.config.apiPort, link.accId, link.accService, this.token);
if (typeof val != "undefined") link.hbValue = val;
} else if (hbValue !== link.hbValue) {
log("smartapp", "-> Setting HB value for accessory: " + link.accName + " to " + hbValue + " of Duotecno unit: " + u.getDisplayName() + " with value " + u.value);
link.hbValue = hbValue;
await hbSetState(this.config.apiHost +":"+ this.config.apiPort, link.accId, link.accService, hbValue, this.token);
} else {
debug("smartapp", "-> No change for accessory: " + link.accName + " to " + u.value + " / " + u.status);
}
}
}
};
}
async checkToken(): Promise<boolean> {
if (!this.token) {
const access_token = await hbLogin(this.config.apiHost +":"+ this.config.apiPort,
this.config.username, this.config.password);
if (!access_token) {
err("smartapp", "checkToken: failed to get token");
return false;
}
this.token = access_token;
setToken(this.token);
}
return true;
}
async getAccessories(): Promise<Array<{name: string, manufacturer: string, id: string}>> {
if (await this.checkToken())
return hbAccessories(this.config.apiHost +":"+ this.config.apiPort, this.token);
else
return [];
}
async doLinks(context: Context): Promise<HttpResponse> {
let inx: number = parseInt(context.id);
const accessories = await this.getAccessories();
const units = this.system.allUsedUnits();
let message: string;
try {
if (context.action === "add") {
this.links.push({...kEmptyLink, type: LinkType.kAccessory});
inx = this.links.length-1;
return this.ejs("linkDetail", context, { link: this.links[inx], id: inx,
masters: this.system.masters, accessories, units });
} else if (context.action === "edit") {
return this.ejs("linkDetail", context, { link: this.links[inx], id: inx,
masters: this.system.masters, accessories, units });
} else if (context.action === "delete") {
this.deleteLink(inx);
} else if (context.action === "change") {
this.updateLink(inx, this.scrapeLink(context));
} else {
// possible new IP Nodes, hence Units could be online
this.initLinks();
}
} catch(e) {
message = e.toString();
}
return this.ejs("linkList", context, { masters: this.system.masters, links: this.links, message });
}
scrapeLink(context: Context): Link {
// don't take the name of the unit
const { masterAddress, masterPort, logicalAddress, logicalNodeAddress } = this.scrapeUnit(context, '');
// for now only accessory links.
const stype = context.getParam({name: "type", type: "string", default: LinkType.kAccessory });
const name = context.getParam({name: "name", type: "string", default: "--" });
// max & min values
const min = context.getParam({name: "min", type: "integer", default: 0 });
const max = context.getParam({name: "max", type: "integer", default: 100 });
// uniqiue id for the accessory
const accId = context.getParam({name: "accId", type: "string", default: "" });
const accName = hbName(accId);
const accService = hbService(accId);
return { accId, accName, accService, name, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: stype, min, max};;
}
updateLink(inx: number, link: Link) {
if ((inx >= 0) && (inx < this.links.length)) {
this.links[inx] = link;
this.initLinks();
this.writeConfig();
}
}
deleteLink(inx: number) {
if ((inx >= 0) && (inx < this.switches.length)) {
this.links.splice(inx, 1);
this.initLinks();
this.writeConfig();
}
}
// not used / tested for the moment
async setLink(inx: number, newstate?: boolean, newvalue?: number)
async setLink(inx: Link, newstate?: boolean, newvalue?: number)
async setLink(inx: number | Link, newstate?: boolean, newvalue?: number) {
// find the switch if an index is given
let link = null;
if (typeof inx === "number") {
if ((inx >= 0) && (inx < this.links.length)) {
link = this.links[inx];
}
} else {
// a Link was passed as first param
link = inx;
}
// check if state is given
if (typeof newstate != "undefined") {
link.unit.status = !!newstate;
}
if (typeof newvalue != "undefined") {
link.unit.value = +newvalue;
}
if (!link) {
err("smartapp", "Didn't find link with inx: " + inx);
} else if (!link.unit) {
err("smartapp", "Don't have unit for link: "+link.unitname);
} else {
if (link.type == LinkType.kAccessory) {
if (await this.checkToken())
await hbSetState(this.config.apiHost +":"+ this.config.apiPort, link.id, link.service, link.unit.value, this.token)
} else {
err("smartapp", "Don't know how to set a link of type " + link.type);
}
}
}
//////////////
// Switches //
//////////////
initSwitchUnits() {
log("smartapp", "Init " + this.switches.length + " Switches -> add units");
const smappee = this.power.smappee;
this.switches.forEach(swtch => {
swtch.unit = swtch.unit ||
this.system.findUnit(this.system.findMaster(swtch.masterAddress, swtch.masterPort),
swtch.logicalNodeAddress, swtch.logicalAddress);
if ((smappee) && (swtch.type === SwitchType.kSmappee)) {
for (let key in smappee.plugs) {
// convert to numbers, better be safe then missing one...
const p = (typeof swtch.plug === "string") ? parseInt(swtch.plug) : swtch.plug;
const k = (typeof key === "string") ? parseInt(key) : key;
if (k === p)
swtch.value = smappee.plugs[key].value;
};
} else if ((swtch.type === SwitchType.kHTTPDimmer) ||
(swtch.type === SwitchType.kHTTPSwitch) ||
(swtch.type === SwitchType.kHTTPUpDown)) {
if (swtch.unit) {
swtch.value = swtch.unit.value;
swtch.status = swtch.unit.status;
} else {
log("smartapp", "** error ** missing unit: " + hex(swtch.logicalNodeAddress)+"/"+hex(swtch.logicalAddress) + " **");
}
}
});
}
alertSwitch(type: SwitchType, plugNr: number, value: number | boolean) {
debug("smartapp", "Received " + type + " switch status change: " + plugNr + " -> " + value);
if ((this.power.smappee) && (type === SwitchType.kSmappee)) {
this.switches.forEach(swtch => {
if ((swtch.type == SwitchType.kSmappee) && (swtch.plug == plugNr) && swtch.unit) {
debug("smartapp", " -> Switch was attached to unit = " + swtch.unit.getDisplayName() + " -> setting state to " + value);
swtch.unit.setState(value);
}
});
}
}
async doSwitches(context: Context): Promise<HttpResponse> {
let inx: number = parseInt(context.id);
let message: string;
try {
if (context.action === "add") {
this.switches.push(kEmptySwitch);
inx = this.switches.length-1;
return this.ejs("switchDetail", context, { config: this.config, swtch: this.switches[inx],
masters: this.system.masters, id: inx });
} else if (context.action === "edit") {
return this.ejs("switchDetail", context, { rule: kEmptyRule, swtch: this.switches[inx],
masters: this.system.masters });
} else if (context.action === "delete") {
this.deleteSwitch( inx );
} else if (context.action === "change") {
this.updateSwitch( inx, this.scrapeSwitch(context) );
} else if (context.action === "set") {
const state = context.getParam({name: "state", type: "string", default: "N"})
const value = context.getParam({name: "value", type: "integer", default: 0})
this.setSwitch( inx, (state === "Y"), value );
return this.json({switch: inx, state, value});
} else {
// possible new IP Nodes, hence Units could be online
this.initSwitchUnits();
}
} catch(e) {
message = e.toString();
}
return this.ejs("switchList", context, { masters: this.system.masters, switches: this.switches, message });
}
informChange(u: Unit, kind: string) {
// kind should be "-", "S"tatus, "M"acro
this.switches.forEach(swtch => {
if (u.isUnit(swtch.masterAddress, swtch.masterPort, swtch.logicalNodeAddress, swtch.logicalAddress)) {
if (! ((kind === "M") && (swtch.nomacro === "Y")))
this.setSwitch(swtch);
else
debug("smartapp", "*** macro changed blocked by switch");
}
});
}
scrapeSwitch(context: Context): Switch {
const { name: unitName, masterAddress, masterPort, logicalAddress, logicalNodeAddress } = this.scrapeUnit(context, '');
const plug = context.getParam({name: "plug", type: "string", default: "0" });
const nomacro = context.getParam({name: "nomacro", type: "string", default: "N" });
const nostop = context.getParam({name: "nostop", type: "string", default: "N" });
const stype = context.getParam({name: "type", type: "string", default: SwitchType.kNoType });
const name = context.getParam({name: "name", type: "string", default: "--" });
const data = context.getParam({name: "data", type: "string", default: "" });
const header = context.getParam({name: "header", type: "string", default: "" });
const method = context.getParam({name: "method", type: "string", default: "GET" });
return { name, unitName, masterAddress, masterPort, logicalAddress, logicalNodeAddress, type: stype, plug, data, header, method, nomacro, nostop };
}
updateSwitch(inx: number, swtch: Switch) {
if ((inx >= 0) && (inx < this.switches.length)) {
this.switches[inx] = swtch;
this.initSwitchUnits();
this.writeConfig();
}
}
deleteSwitch(inx: number) {
if ((inx >= 0) && (inx < this.switches.length)) {
this.switches.splice(inx, 1);
this.initSwitchUnits();
this.writeConfig();
}
}
setSwitch(inx: number, newstate?: boolean, newvalue?: number)
setSwitch(inx: Switch, newstate?: boolean, newvalue?: number)
setSwitch(inx: number | Switch, newstate?: boolean, newvalue?: number) {
// find the switch if an index is given
let swtch = null;
if (typeof inx === "number") {
if ((inx >= 0) && (inx < this.switches.length)) {
swtch = this.switches[inx];
}
} else {
// a Switch was passed as first param
swtch = inx;
}
// check if state is given
if (typeof newstate != "undefined") {
swtch.unit.status = !!newstate;
}
if (typeof newvalue != "undefined") {
swtch.unit.value = +newvalue;
}
if (!swtch) {
err("smartapp", "Didn't find switch with inx: " + inx);
} else if (!swtch.unit) {
err("smartapp", "Don't have unit for switch: "+swtch.unitname);
} else {
if ((swtch.type === SwitchType.kSmappee) && (this.power.smappee)) {
this.power.smappee.setPlug(parseInt(swtch.plug), swtch.unit.value);
} else if (swtch.type === SwitchType.kHTTPSwitch) {
this.httpSwitch(swtch);
} else if (swtch.type === SwitchType.kHTTPDimmer) {
this.httpDimmer(swtch);
} else if (swtch.type === SwitchType.kHTTPUpDown) {
this.httpUpDown(swtch);
} else if (swtch.type === SwitchType.kOhSwitch) {
this.ohSwitch(swtch);
} else if (swtch.type === SwitchType.kOhDimmer) {
this.ohDimmer(swtch);
} else if (swtch.type === SwitchType.kOhUpDown) {
this.ohUpDown(swtch);
} else if (swtch.type === SwitchType.kSomfy) {
this.somfy(swtch);
} else {
err("smartapp", "Don't know how to set a switch of type " + swtch.type);
}
}
}
///////////
// Somfy //
///////////
somfy(swtch: Switch) {
let nr = swtch.plug;
if (typeof nr === "string") nr = parseInt(nr);
nr = Math.max(0, Math.min(4,nr));
if (swtch.unit) {
if (swtch.unit.status === 3)
somfy.down(nr); // 3 = going down
else if (swtch.unit.status === 4)
somfy.up(nr); // 4 = going up
else if (swtch.unit.status != 0)
somfy.stop(nr); // 1 = stopped down, 2 = stopped up
}
}
//////////////////////////
// http driven switches //
//////////////////////////
makeVariableURL(url, state: boolean, value: number) {
// support legacy on/off
const parts = url.split("|");
let base = parts[0];
if (parts.length > 2) {
base += parts[state ? 2 : 1];
}
// do the dimmer value and on/off
return base
.replace("#B", state ? "true" : "false")
.replace("#O", state ? "on" : "off")
.replace("#1", state ? '1' : '0')
.replace("#", state ? "on" : "off")
.replace("$B", "" + Math.round(value / 100 * 256))
.replace("$T", "" + Math.round(value / 100 * 512))
.replace("$W", "" + Math.round(value / 100 * 256 * 256))
.replace("$1", "" + (value / 100))
.replace("$", "" + value);
}
httpSwitch(swtch: Switch) {
const req = this.makeVariableURL(swtch.plug, !!swtch.unit.status, +swtch.unit.value);
let data = "";
if (swtch.data) {
// we have body data
data = this.makeVariableURL(swtch.data, !!swtch.unit.status, +swtch.unit.value);
}
log("smartapp", "HTTP-Switch(" + !!swtch.unit.status + ") -> " + req);
httpRequest(req, swtch.method, data, ContentType.form, swtch.header);
}
httpUpDown(swtch: Switch) {
let url = swtch.plug + "";
// support legacy on/off
const parts = url.split("|");
let base = parts[0];
const val = 1 + <number>swtch.unit.value;
if (val < parts.length) {
base += parts[val];
}
let data = "";
if (swtch.data) {
// we have body data
data = this.makeVariableURL(swtch.data, !!swtch.unit.status, +swtch.unit.value);
}
if ((val === 1) && (swtch.nostop == 'Y')) {
debug("smartapp", "Blocked stop for HTTP-UpDown " + swtch.name);
} else {
log("smartapp", "UpDown(" + val + ") -> " + base);
httpRequest(base, swtch.method, data, ContentType.form, swtch.header);
}
}
httpDimmer(swtch: Switch) {
// do the possible on/off + value part
let req = this.makeVariableURL(swtch.plug, !!swtch.unit.status, +swtch.unit.value);
let data = "";
if (swtch.data) {
// we have body data
data = this.makeVariableURL(swtch.data, !!swtch.unit.status, +swtch.unit.value);
}
debug("smartapp", "Dimmer(" + !!swtch.unit.status + "," + swtch.unit.value + ") -> " + req + " + " + data);
httpRequest(req, swtch.method, data, ContentType.form, swtch.header);
}
////////////////////////
// http request stuff //
////////////////////////
// overrides webapp.login -> login into HB
async checkLogin(url: string, username: string, password: string): Promise<string> {
return hbLogin(url, username, password);
}
//////////////
// open HAB //
//////////////
ohSwitch(swtch: Switch) {
const req = this.makeVariableURL(swtch.plug, !!swtch.unit.status, +swtch.unit.value);
debug("smartapp", "OH-Switch(" + !!swtch.unit.status + ") -> " + req);
httpRequest(req, swtch.method, swtch.unit.status ? "ON" : "OFF", ContentType.plain, swtch.header);
}
ohDimmer(swtch: Switch) {
const req = this.makeVariableURL(swtch.plug, !!swtch.unit.status, +swtch.unit.value);
const val = (swtch.unit.value == 1) ? "ON" : swtch.unit.value.toString();
debug("smartapp", "OH-Dimmer(" + !!swtch.unit.status + ", " + val + ") -> " + req);
httpRequest(req, swtch.method, swtch.unit.status ? val : "OFF", ContentType.plain, swtch.header);
}
ohUpDown(swtch: Switch) {
let data;
// 1=stopped, 2-closed, 3=opened, 4=closing, 5=opening
if ((swtch.unit.status == 0) || (swtch.unit.status == 1) || (swtch.unit.status == 2)) {
if (swtch.nostop == 'Y')
debug("smartapp", "Blocked stop for OH-UpDown " + swtch.name);
else
data = "STOP";
} else if (swtch.unit.status == 3) {
data = "DOWN";
} else if (swtch.unit.status == 4) {
data = "UP";
}
if (data) {
const req = this.makeVariableURL(swtch.plug, !!swtch.unit.status, +swtch.unit.value);
debug("smartapp", "OH-UpDown(" + <number>swtch.unit.value +", " + data + ") -> " + req);
httpRequest(req, swtch.method, data, ContentType.plain, swtch.header);
}
}
//////////////////////////////
// Services //
//////////////////////////////
async doServices(context: Context): Promise<HttpResponse> {
let units = this.system.allUsedUnits();
for (let u of units) {
await u.node.master.requestUnitStatus(u);
}
return this.ejs("serviceList", context, { units, nrActive: this.system.allActiveUnits().length });
}
//////////////////////////////
// Masters //
//////////////////////////////
async doMasters(context: Context): Promise<HttpResponse> {
let message: string;
try {
if (context.action === "new") {
return this.ejs("masterDetail", context, { config: Sanitizers.masterConfig(null), registers: [], nodes: [] });
} else if (context.action === "list") {
return this.ejs("unitList", context, { masters: this.system.masters });
} else if (context.action === "services") {
return this.serviceList(context);
} else if (context.action === "edit") {
context.getMaster("id");
const master = this.system.findMaster(context["masterAddress"], context["masterPort"]);
if (master)
return this.ejs("masterDetail", context, { nodes: master.nodes, config: master.getConfig(), registers: master.registers });
else
message = "Error: Master not found";
} else if (context.action === "delete") {
const master = this.system.findMaster(context.getParam(kAddress), context.getParam(kPort));
if (master)
await this.system.deleteMaster(master);
else
message = "Error: Master not found";
// drop through and list all masters
} else if (context.action === "save") {
const master = await this.system.addMaster(context.getParam(kAddressOld), context.getParam(kPortOld),
{ address: context.getParam(kAddress), port: context.getParam(kPort),
password: context.getParam(kPassword), name: context.getParam(kName),
active: context.getParam(kActive) != "N", nodenames: {} });
this.updateNodes(master, context.params.nodes || "", context.params);
this.system.writeConfig();
} else if (context.action === "close") {
context.getMaster("id");
const master = this.system.findMaster(context["masterAddress"], context["masterPort"]);
if (master)
await master.close(false);
else
message = "Error: Master not found";
}
} catch(e) {
message = e.toString();
}
return this.ejs("masterList", context, { masters: this.system.masters, message });
}
async serviceList(context: Context) {
let units = this.system.allActiveUnits();
for (let u of units) {
await u.node.master.requestUnitStatus(u);
}
return this.ejs("unitList", context, { services: this.system.allActiveUnits() });
}
async updateNodes(master: Master, nodes, params) {
if (nodes) {
let nodeArr = nodes.map(s => parseInt(s));
nodeArr.forEach(adr => {
let node = this.system.findNode(master, adr);
if (node) {
node.active = (params["active_"+adr] === "Y");
this.system.setActiveState(node);
}
});
}
}
//////////////////////////////
// Units //
//////////////////////////////
async doUnits(context: Context): Promise<HttpResponse> {
// get masterAddress and Port
context.getMaster("action", "node");
const master = this.system.findMaster(context["masterAddress"], context["masterPort"]);
if (context.action === "save") {
// store changes in units into master config
this.updateUnits(master, context.getParam(kNode), context.params);
this.system.writeConfig();
context.action = "cancel";
}
if (context.action === "cancel") {
// return to previous screen -> show master info + list of nodes.
context.id = context["masterAddress"] + ":" + context["masterPort"];
context.request = "masters";
context.action = "edit";
return this.doRequest(context);
} else if (context.action === "set") {
if (!master) return this.error(context, "master not found", true);
const { logicalNodeAddress, logicalAddress } = context.getUnit();
const response = await this.setState(master, logicalNodeAddress, logicalAddress, context.getParam(kValue));
return this.json(response);
} else if (context.action === "get") {
if (!master) return this.error(context, "master not found", true);
const { logicalNodeAddress, logicalAddress } = context.getUnit();
const response = await this.getState(master, logicalNodeAddress, logicalAddress);
return this.json(response);
} else if (context.action === "press") {
if (!master) return this.error(context, "master not found", true);
const { logicalNodeAddress, logicalAddress } = context.getUnit();
const response = await this.doPress(master, logicalNodeAddress, logicalAddress, context.getParam(kIntValue));
return this.json(response);
} else { // context.action === "node"
// reponding to /units/[master ip address:port]/[logical node address]
const nodeLogicalAddress = parseInt(context.id);
let response = await this.getNodeInfo(master, nodeLogicalAddress, true);
this.adjustUnitsToConfig(response.node);
let N = this.sortCopy(response.node, context);
return this.ejs("nodeDetail", context, { message: response.message, node: N });
}
}
adjustUnitsToConfig(node: Node) {
// check if units are still in config
node.units.forEach(unit => {
//console.log("Searching: ", unit.node.master.config.address, unit.node.master.config.port, unit.logicalNodeAddress, unit.logicalAddress)
const config = this.system.config.cunits.find(u => {
// console.log(u.masterAddress, u.masterPort, u.logicalNodeAddress, u.logicalAddress, ((unit.node.master.hasAddress(u.masterAddress) &&
// unit.node.master.hasPort(u.masterPort) &&
// (u.logicalNodeAddress == unit.logicalNodeAddress) &&
// (u.logicalAddress === unit.logicalAddress))));
return (unit.node.master.hasAddress(u.masterAddress) &&
unit.node.master.hasPort(u.masterPort) &&
(u.logicalNodeAddress == unit.logicalNodeAddress) &&
(u.logicalAddress === unit.logicalAddress));
});
if (config) {
unit.extendedType = config.extendedType;
unit.name = config.displayName;
}
});
}
sortCopy(N: Node, context: Context): Node {
// only need to re-sort if onAddress is requested, units should always be sorted on name
const S = context["sortOnAddr"] = context.getParam({name: "sortOnAddr", type:"string", default: "N"});
if (S === "Y") {
// don't sort the original array / node
if (N.units) {
const units = N.units.map(u => u);
N = new Node(N.master, N);
N.units = units; N.nrUnits = units.length;
N.units.sort((a,b) => a.logicalAddress - b.logicalAddress);
}
}
return N;
}
updateUnits(master: Master, nodeLogicalAddress, params) {
let node = this.system.findNode(master, nodeLogicalAddress);
if (node) {
node.units.forEach(unit => {
unit.active = (params["active_"+unit.logicalAddress] === "Y");
unit.used = unit.active || (params["used_"+unit.logicalAddress] === "Y");
unit.displayName = params["name_"+unit.logicalAddress];
unit.extendedType = parseInt(params["extended_"+unit.logicalAddress]);
});
this.system.updateSystem(true);
}
}
async getNodeInfo(master: Master, nodeLogicalAddress: number, doFetch = false) {
let node = this.system.findNode(master, nodeLogicalAddress);
if (! node) {
// if no node found, make some dummy node for the display system. (should not occur !)
return { node: new Node(this.system.masters[0], {name: "No node"}), message: "Node not found" };
} else {
if ((node.nrUnits != node.units.length) || doFetch)
await master.fetchAllUnits(node);
await node.master.requestNodeStatus(node);
return { node };
}
}
async doPress(master: Master, nodeLogicalAddress: number, unitLogicalAddress: number, val: any) {
let unit = this.system.findUnit(master, nodeLogicalAddress, unitLogicalAddress);
if (! unit) return { message: "Unit not found " + master.getName() + "/" + nodeLogicalAddress + "/" + unitLogicalAddress };
if (val === -1) {
// mood click
await unit.setState(-1);
unit.value = false; // simulate push button
return { node: nodeLogicalAddress, unit: unitLogicalAddress, value: true };
} else if ((val === 0) || (val === 1)) {
// mood/input long clicks
await unit.setState(val);
return { node: nodeLogicalAddress, unit: unitLogicalAddress, value: val }
} else if ((val === 3) || (val === 4) || (val === 5)) {
// switching motor
await unit.setState(val);
return { node: nodeLogicalAddress, unit: unitLogicalAddress, value: val }; // await unit.reqState()
}
return { message: "Strange press " + val };
}
async setState(master: Master, nodeLogicalAddress: number, unitLogicalAddress: number, value: any) {
log("smartapp", "setState requested with node = " + nodeLogicalAddress + ", unit = " + unitLogicalAddress + " -> " + value);
if ((value === "Y") || (value === "N"))
value = (value === "Y")
else {
value = parseInt(value);
if (isNaN(value)) return { message: "Illegal value" };
}
let unit = this.system.findUnit(master, nodeLogicalAddress, unitLogicalAddress);
if (! unit) return { message: "Unit not found " + master.getName() + "/" + nodeLogicalAddress + "/" + unitLogicalAddress };
await unit.setState(value);
return { node: nodeLogicalAddress, unit: unitLogicalAddress, value };
}
async getState(master: Master, nodeLogicalAddress: number, unitLogicalAddress: number) {
log("smartapp", "getState requested with node = " + nodeLogicalAddress + ", unit = " + unitLogicalAddress);
let unit = this.system.findUnit(master, nodeLogicalAddress, unitLogicalAddress);
if (! unit) return { message: "Unit not found " + master.getName() + "/" + nodeLogicalAddress + "/" + unitLogicalAddress };
await master.requestUnitStatus(unit);
return { node: nodeLogicalAddress, unit: unitLogicalAddress, value: unit.value, status: unit.status, active: unit.active };
}
//////////////////////////////
// Assets & Images //
//////////////////////////////
renderAssets(context: Context) {
const file = context.action;
if ((file === "min.css") || (file === "materialize.min.css") || (file === "materialize.css")) {
return this.file("materializeCSS");
} else if ((file === "min.js") || (file === "materialize.min.js") || (file === "materialize.js")) {
return this.file("materializeJS");
} else if (file === "favicon.ico") {
return this.file("favicon");
} else
return this.notFound();
}
renderImage(context: Context) {
const file = context.action;
return this.image("./server/views/images/" + file, "jpeg");
}
}