homebridge-unifi-smartpower
Version:
UniFi SmartPower plugin for Homebridge.
356 lines • 17.1 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.UniFiSmartPower = exports.UniFiSwitchPortPoeCaps = exports.UniFiDeviceKind = exports.UniFiRpsPortAction = exports.UniFiSmartPowerOutletAction = exports.UniFiPortOrOutletInUse = exports.UniFiSmartPowerOutletState = void 0;
const pubsub_js_1 = __importDefault(require("pubsub-js"));
const cache_manager_1 = require("cache-manager");
const async_lock_1 = __importDefault(require("async-lock"));
const node_unifi_1 = require("node-unifi");
const cacheable_1 = require("cacheable");
var UniFiSmartPowerOutletState;
(function (UniFiSmartPowerOutletState) {
UniFiSmartPowerOutletState[UniFiSmartPowerOutletState["UNKNOWN"] = -1] = "UNKNOWN";
UniFiSmartPowerOutletState[UniFiSmartPowerOutletState["OFF"] = 0] = "OFF";
UniFiSmartPowerOutletState[UniFiSmartPowerOutletState["ON"] = 1] = "ON";
})(UniFiSmartPowerOutletState || (exports.UniFiSmartPowerOutletState = UniFiSmartPowerOutletState = {}));
var UniFiPortOrOutletInUse;
(function (UniFiPortOrOutletInUse) {
UniFiPortOrOutletInUse[UniFiPortOrOutletInUse["UNKNOWN"] = -1] = "UNKNOWN";
UniFiPortOrOutletInUse[UniFiPortOrOutletInUse["NO"] = 0] = "NO";
UniFiPortOrOutletInUse[UniFiPortOrOutletInUse["YES"] = 1] = "YES";
})(UniFiPortOrOutletInUse || (exports.UniFiPortOrOutletInUse = UniFiPortOrOutletInUse = {}));
var UniFiSmartPowerOutletAction;
(function (UniFiSmartPowerOutletAction) {
UniFiSmartPowerOutletAction[UniFiSmartPowerOutletAction["OFF"] = 0] = "OFF";
UniFiSmartPowerOutletAction[UniFiSmartPowerOutletAction["ON"] = 1] = "ON";
})(UniFiSmartPowerOutletAction || (exports.UniFiSmartPowerOutletAction = UniFiSmartPowerOutletAction = {}));
var UniFiRpsPortAction;
(function (UniFiRpsPortAction) {
UniFiRpsPortAction[UniFiRpsPortAction["OFF"] = 0] = "OFF";
UniFiRpsPortAction[UniFiRpsPortAction["ON"] = 1] = "ON";
})(UniFiRpsPortAction || (exports.UniFiRpsPortAction = UniFiRpsPortAction = {}));
var UniFiDeviceKind;
(function (UniFiDeviceKind) {
UniFiDeviceKind[UniFiDeviceKind["OUTLET"] = 0] = "OUTLET";
UniFiDeviceKind[UniFiDeviceKind["PORT"] = 1] = "PORT";
UniFiDeviceKind[UniFiDeviceKind["RPS_PORT"] = 2] = "RPS_PORT";
})(UniFiDeviceKind || (exports.UniFiDeviceKind = UniFiDeviceKind = {}));
exports.UniFiSwitchPortPoeCaps = {
'8023AF': 1,
'8023AT': 2,
PASV24: 4,
PASSTHROUGHABLE: 8,
PASSTHROUGH: 16,
'8023BT': 32,
};
class UniFiSmartPower {
log;
config;
static PUB_SUB_OUTLET_TOPIC = 'outlet';
static STATUS_CACHE_KEY = 'outlet-status';
static STATUS_CACHE_TTL_MS_DEFAULT = 15 * 1000;
static STATUS_CACHE_TTL_MS_MIN = 5 * 1000;
static STATUS_CACHE_TTL_MS_MAX = 60 * 1000;
static STATUS_POLL_INTERVAL_MS_DEFAULT = 15 * 1000;
static STATUS_POLL_INTERVAL_MS_MIN = 5 * 1000;
static STATUS_POLL_INTERVAL_MS_MAX = 60 * 1000;
static CONTROLLER_LOCK = 'CONTROLLER_LOCK';
lock = new async_lock_1.default({ domainReentrant: true });
cache;
controller;
constructor(log, config) {
this.log = log;
this.config = config;
const store = new cacheable_1.KeyvCacheableMemory({
ttl: undefined, // No default ttl
lruSize: 0, // Infinite capacity
});
const keyv = new cacheable_1.Keyv({ store });
this.cache = (0, cache_manager_1.createCache)({ stores: [keyv] });
this.controller = new node_unifi_1.Controller({
host: this.config.host,
port: this.config.port,
username: this.config.username,
password: this.config.password,
sslverify: false,
});
}
reset() {
pubsub_js_1.default.clearAllSubscriptions();
}
subscribe(device, kind, index, func) {
const topic = UniFiSmartPower.statusTopic(device, kind, index);
const token = pubsub_js_1.default.subscribe(topic, async (_, data) => {
if (!data) {
return;
}
func(data);
});
this.log.debug('[API] Status subscription added for%s %s.%s [token=%s]', {
[UniFiDeviceKind.OUTLET]: ' outlet',
[UniFiDeviceKind.PORT]: ' port',
[UniFiDeviceKind.RPS_PORT]: ' RPS port',
}[kind] ?? '', device.mac, index, token);
// When this is the first subscription, start polling to publish updates.
if (pubsub_js_1.default.countSubscriptions(topic) === 1) {
const poll = async () => {
// Stop polling when there are no active subscriptions.
if (pubsub_js_1.default.countSubscriptions(topic) === 0) {
this.log.debug('[API] There are no status subscriptions; skipping poll');
return;
}
// Acquire the status lock before emitting any new events.
try {
switch (kind) {
case UniFiDeviceKind.OUTLET:
this.log.debug('[API] Polling status for outlet %s.%s', device.mac, index);
pubsub_js_1.default.publish(topic, await this.getOutletStatus(device, index));
break;
case UniFiDeviceKind.PORT:
this.log.debug('[API] Polling status for port %s.%s', device.mac, index);
pubsub_js_1.default.publish(topic, await this.getPortStatus(device, index));
break;
case UniFiDeviceKind.RPS_PORT:
this.log.debug('[API] Polling status for RPS port %s.%s', device.mac, index);
pubsub_js_1.default.publish(topic, await this.getRpsPortStatus(device, index));
break;
default:
// noinspection ExceptionCaughtLocallyJS
throw new Error('unknown device status kind=%d', kind);
}
}
catch (error) {
// Nothing to do here. The error has already been logged.
}
setTimeout(poll, this.statusPollIntervalMs);
};
setTimeout(poll, 0);
}
return token;
}
unsubscribe(token) {
pubsub_js_1.default.unsubscribe(token);
this.log.debug('[API] Status subscription removed for token %s', token);
}
async getSites() {
return this.lock.acquire(UniFiSmartPower.CONTROLLER_LOCK, async () => {
this.log.debug('[API] Fetching sites from UniFi API');
await this.controller.login();
return (await this.controller.getSitesStats())
.filter(({ name }) => !!name)
.map(({ name: id, desc: name }) => ({
id,
name,
}));
});
}
async getDeviceStatuses(siteOrDevice, acquireLock = true) {
let site;
let device;
let errMsg = '';
if (typeof siteOrDevice === 'string') {
site = siteOrDevice;
errMsg = `site ${site} `;
device = null;
}
else if (siteOrDevice) {
device = siteOrDevice;
site = device.site;
errMsg = `device ${device.name} [${device.mac}] `;
}
const fetch = async () => {
const result = await this.cache.wrap(UniFiSmartPower.deviceCacheKey(device), async () => {
this.log.debug('[API] Fetching status from UniFi API');
this.controller.opts.site = site;
let result = [], error = null;
try {
await this.controller.login();
result = await this.controller.getAccessDevices(device?.mac ?? '');
}
catch (e) {
error = e;
this.log.error('[API] An error occurred polling %sfor a status update; %s', errMsg, error.message);
}
return (error ??
result
.filter((device) => (device.outlet_table ?? []).length > 0 ||
(device.port_table ?? []).length > 0 ||
(device?.rps?.rps_port_table ?? []).length > 0)
.map((device) => UniFiSmartPower.transformDeviceStatusResponse(site, device)));
}, this.statusCacheTtlMs);
if (result instanceof Error) {
throw result;
}
return result;
};
return acquireLock ? this.lock.acquire(UniFiSmartPower.CONTROLLER_LOCK, fetch) : fetch();
}
static transformDeviceStatusResponse(site, { _id: id, ip, mac, model, version, serial: serialNumber, name, port_table: ports, port_overrides: portOverrides, outlet_table: outlets, outlet_overrides: outletOverrides, rps: rps, rps_override: rpsPortOverride, }) {
return {
device: {
site,
id,
ip,
mac,
model,
version,
serialNumber,
name: name || model || serialNumber,
},
ports: (ports
?.filter(({ port_poe: isPoePort }) => !!isPoePort)
.map((entry) => ({
index: entry.port_idx,
name: entry.name ?? `Port ${entry.port_idx}`,
poeMode: entry.poe_mode || 'off',
inUse: !entry.poe_power ? -1 : parseFloat(entry.poe_power) > 0 ? 1 : 0,
active: !!entry.poe_enable,
poeOnAction: this.getPortPoeOnMode(entry.poe_caps),
entry,
override: portOverrides?.find((p) => p?.port_idx === entry.port_idx) ??
Object.fromEntries(Object.entries(entry).filter(([k]) => ['port_idx', 'name', 'poe_mode', 'portconf_id'].includes(k))),
})) ?? []).filter((p) => p.poeOnAction !== 'off' && p.override && p.entry),
outlets: (outlets?.map((entry) => ({
index: entry.index,
name: entry.name ?? `Outlet ${entry.index}`,
relayState: entry.relay_state ? 1 : 0,
inUse: !entry.outlet_power ? -1 : parseFloat(entry.outlet_power) > 0 ? 1 : 0,
entry,
override: outletOverrides?.find((o) => o?.index === entry.index) ??
Object.fromEntries(Object.entries(entry).filter(([k]) => ['index', 'name', 'relay_state'].includes(k))),
})) ?? []).filter((o) => o.override && o.entry),
rpsPorts: (rps?.rps_port_table.map((entry) => ({
index: entry.port_idx,
name: entry.name ?? `RPS Port ${entry.port_idx}`,
portMode: entry.port_mode || 'disabled',
inUse: entry.power_delivering ? 1 : 0,
active: !!entry.power_active,
entry,
override: rpsPortOverride?.rps_port_table?.find((p) => p?.port_idx === entry.port_idx) ??
Object.fromEntries(Object.entries(entry).filter(([k]) => ['port_idx', 'name', 'port_mode'].includes(k))),
})) ?? []).filter((p) => p.override && p.entry),
};
}
static getPortPoeOnMode(poeCaps) {
if (
// We already filter out non-poe ports and if it doesn't have cps then it supports auto
poeCaps === undefined ||
poeCaps === null ||
this.poeSupportsMode(poeCaps, exports.UniFiSwitchPortPoeCaps['8023AF']) ||
this.poeSupportsMode(poeCaps, exports.UniFiSwitchPortPoeCaps['8023AT']) ||
this.poeSupportsMode(poeCaps, exports.UniFiSwitchPortPoeCaps['8028023BT3AF'])) {
return 'auto';
}
if (this.poeSupportsMode(poeCaps, exports.UniFiSwitchPortPoeCaps.PASSTHROUGH) ||
this.poeSupportsMode(poeCaps, exports.UniFiSwitchPortPoeCaps.PASSTHROUGHABLE)) {
return 'passthrough';
}
if (this.poeSupportsMode(poeCaps, exports.UniFiSwitchPortPoeCaps.PASV24)) {
return 'pasv24';
}
return 'off';
}
static poeSupportsMode(poeCaps, cap) {
return !!poeCaps && (poeCaps & cap) === cap;
}
async getDeviceStatus(device, acquireLock = true) {
const devices = await this.getDeviceStatuses(device, acquireLock);
if (devices.length !== 1) {
throw new Error(`unknown device with id=${device.id}`);
}
return devices[0];
}
async getPortStatus(device, portIndex) {
const { ports } = await this.getDeviceStatus(device);
const portInfo = ports.find(({ index }) => index === portIndex) ?? null;
if (portInfo === null) {
throw new Error(`unknown port with id=${portIndex}`);
}
return portInfo;
}
async getOutletStatus(device, outletIndex) {
const { outlets } = await this.getDeviceStatus(device);
const outletInfo = outlets.find(({ index }) => index === outletIndex) ?? null;
if (outletInfo === null) {
throw new Error(`unknown outlet with id=${outletIndex}`);
}
return outletInfo;
}
async getRpsPortStatus(device, portIndex) {
const { rpsPorts } = await this.getDeviceStatus(device);
const portInfo = rpsPorts.find(({ index }) => index === portIndex) ?? null;
if (portInfo === null) {
throw new Error(`unknown rps port with id=${portIndex}`);
}
return portInfo;
}
async commandOutlet(device, outletIndex, command) {
return this.lock.acquire(UniFiSmartPower.CONTROLLER_LOCK, async () => {
const { outlets } = await this.getDeviceStatus(device, false);
this.controller.opts.site = device.site;
await this.controller.login();
await this.controller.setDeviceSettingsBase(device.id, {
outlet_overrides: outlets.map((outlet) => ({
...outlet.override,
relay_state: outlet.index === outletIndex ? !!command : !!outlet.relayState,
})),
});
await this.cache.del(UniFiSmartPower.deviceCacheKey(device));
await this.cache.del(UniFiSmartPower.deviceCacheKey());
});
}
async commandPort(device, portIndex, poeMode) {
return this.lock.acquire(UniFiSmartPower.CONTROLLER_LOCK, async () => {
const { ports } = await this.getDeviceStatus(device, false);
this.controller.opts.site = device.site;
await this.controller.login();
await this.controller.setDeviceSettingsBase(device.id, {
port_overrides: ports.map((port) => ({
...port.override,
poe_mode: port.index === portIndex ? poeMode : port.poeMode,
})),
});
await this.cache.del(UniFiSmartPower.deviceCacheKey(device));
await this.cache.del(UniFiSmartPower.deviceCacheKey());
});
}
async commandRpsPort(device, portIndex, command) {
return this.lock.acquire(UniFiSmartPower.CONTROLLER_LOCK, async () => {
const { rpsPorts } = await this.getDeviceStatus(device, false);
this.controller.opts.site = device.site;
await this.controller.login();
await this.controller.setDeviceSettingsBase(device.id, {
rps_override: {
rps_port_table: rpsPorts.map((port) => ({
...port.override,
port_mode: port.index === portIndex
? command === UniFiRpsPortAction.ON
? 'auto'
: 'disabled'
: port.portMode,
})),
},
});
await this.cache.del(UniFiSmartPower.deviceCacheKey(device));
await this.cache.del(UniFiSmartPower.deviceCacheKey());
});
}
get statusCacheTtlMs() {
return Math.max(UniFiSmartPower.STATUS_CACHE_TTL_MS_MIN, Math.min(UniFiSmartPower.STATUS_CACHE_TTL_MS_MAX, (this.config.outletStatusCacheTtl ?? 0) * 1000 ||
UniFiSmartPower.STATUS_CACHE_TTL_MS_DEFAULT));
}
get statusPollIntervalMs() {
return Math.max(UniFiSmartPower.STATUS_POLL_INTERVAL_MS_MIN, Math.min(UniFiSmartPower.STATUS_POLL_INTERVAL_MS_MAX, (this.config.outletStatusPollInterval ?? 0) * 1000 ||
UniFiSmartPower.STATUS_POLL_INTERVAL_MS_DEFAULT));
}
static statusTopic(device, kind, index) {
return `${UniFiSmartPower.PUB_SUB_OUTLET_TOPIC}.${device.id}.${kind}.${index}`;
}
static deviceCacheKey(device = null) {
return `${UniFiSmartPower.STATUS_CACHE_KEY}.${device?.id ?? 'ALL'}`;
}
}
exports.UniFiSmartPower = UniFiSmartPower;
//# sourceMappingURL=uniFiSmartPower.js.map