homebridge-netro
Version:
Homebridge plugin for Netro devices (Spark, Sprite, Pixie, Stream, Whisperer, Lapland S1). English-only UI; manual watering by minutes; zone filtering; multi-device; auto-detection; stable UUIDs.
311 lines (273 loc) • 14.2 kB
JavaScript
'use strict';
const https = require('https');
class NetroPlatform {
constructor(log, config, api) {
this.log = log;
this.config = config || {};
this.api = api;
this.Service = api.hap.Service;
this.Characteristic = api.hap.Characteristic;
this.accessories = [];
this.deviceAccessories = {};
this.activeTimers = {};
this.typeCounters = {};
// Config
this.defaultDurationSec = Math.max(60, Math.round((this.config.defaultDurationMinutes ?? 5) * 60));
this.pollIntervalSeconds = Math.max(30, Math.round(this.config.pollInterval ?? 60));
this.autoPrune = this.config.autoPruneAccessories !== false;
// Strong startup logs
this.log.info('[Init] homebridge-netro v1.1.4 starting…', '1.1.4');
this.log.info('[Init] Config keys: devices=%s, defaultDurationMinutes=%s, pollInterval=%s, autoPrune=%s',
Array.isArray(this.config.devices) ? this.config.devices.length : 'missing',
this.config.defaultDurationMinutes, this.config.pollInterval, String(this.autoPrune));
// Pre-create containers
if (Array.isArray(this.config.devices)) {
for (const dev of this.config.devices) {
const serial = String(dev.serial);
this.deviceAccessories[serial] = [];
}
}
// Discovery on didFinishLaunching + fallback timer (in case event missed)
api.on('didFinishLaunching', async () => {
this.log.info('[Launch] didFinishLaunching received → discovering devices…');
await this._safeDiscover();
this._armPolling();
});
setTimeout(async () => {
// Fallback discovery after 3s if nothing created
if ((this.accessories?.length || 0) === 0) {
this.log.warn('[Fallback] No accessories created yet → triggering discovery fallback…');
await this._safeDiscover();
this._armPolling();
}
}, 3000);
api.on('shutdown', () => {
if (this.pollInterval) clearInterval(this.pollInterval);
for (const k of Object.keys(this.activeTimers)) { clearTimeout(this.activeTimers[k]); }
this.log.info('[Shutdown] Timers cleared.');
});
}
configureAccessory(accessory) {
this.accessories.push(accessory);
}
_armPolling() {
if (this.pollIntervalSeconds > 0 && !this.pollInterval) {
this.pollInterval = setInterval(() => this.pollStatus(), this.pollIntervalSeconds * 1000);
this.log.info('[Polling] Armed every %ss.', this.pollIntervalSeconds);
}
}
async _safeDiscover() {
try {
await this.discoverDevices();
} catch (e) {
this.log.error('[Discover] Exception: %s', e?.stack || e?.message || e);
}
}
_nextDeviceIndex(kind) {
this.typeCounters[kind] = (this.typeCounters[kind] ?? 0) + 1;
return this.typeCounters[kind];
}
_deviceLabel(kind, idx) {
const map = { controller: 'Netro Spark', sensor: 'Netro Whisperer', lamp: 'Netro Lapland S1', auto: 'Netro Device' };
return `${map[kind] || 'Netro Device'} ${idx}`;
}
async discoverDevices() {
const devices = Array.isArray(this.config.devices) ? this.config.devices : [];
if (devices.length === 0) {
this.log.warn('[Discover] No devices in config.');
return;
}
const desiredAll = new Set();
for (const dev of devices) {
const serial = String(dev.serial || '').trim();
if (!serial) { this.log.warn('[Discover] Skipping device with empty serial'); continue; }
const includeZones = Array.isArray(dev.includeZones) ? dev.includeZones.filter(n => Number.isFinite(n)) : null;
const filterActive = Array.isArray(includeZones) && includeZones.length > 0;
const includeSet = filterActive ? new Set(includeZones.map(n => parseInt(String(n),10))) : null;
const info = await this._netroGet(`/npa/v1/info.json?key=${encodeURIComponent(serial)}`).catch(e => {
this.log.error('[API] info error for %s: %s', serial, e?.message || e);
return undefined;
});
this.log.info('[API] info %s → %s', serial, info ? 'OK' : 'MISSING');
if (!info || info.status !== 'OK' || !info.data || !info.data.device) {
this.log.warn('[API] invalid info for %s: %j', serial, info);
continue;
}
const deviceApi = info.data.device;
const zones = Array.isArray(deviceApi.zones) ? deviceApi.zones : [];
let kind = dev.type && dev.type !== 'auto' ? dev.type : (zones.length > 0 ? 'controller' : (deviceApi.type === 'sensor' ? 'sensor' : (deviceApi.type === 'lamp' ? 'lamp' : 'controller')));
const idx = this._nextDeviceIndex(kind);
const baseName = this._deviceLabel(kind, idx);
const perDevDefaultSec = Math.max(60, Math.round((dev.defaultDurationMinutes ?? (this.config.defaultDurationMinutes ?? 5)) * 60));
this.log.info('[Discover] Device %s (%s) with %d zone(s); filter=%s', serial, kind, zones.length, filterActive ? JSON.stringify(includeZones) : 'all');
if (kind === 'controller') {
const desired = new Set();
for (const zone of zones) {
const zIndex = zone.ith ?? zone.index ?? zone.id ?? 0;
if (filterActive && !includeSet.has(parseInt(String(zIndex),10))) continue;
const zoneName = String(zone.name || `Zone ${zIndex}`);
const accName = zoneName;
const uuid = this.api.hap.uuid.generate(`netro-${serial}-zone-${zIndex}`);
desired.add(uuid); desiredAll.add(uuid);
let accessory = this.accessories.find(a => a.UUID === uuid);
if (!accessory) {
accessory = new this.api.platformAccessory(accName, uuid, this.api.hap.Categories.SPRINKLER);
this.api.registerPlatformAccessories('homebridge-netro', 'Netro', [accessory]);
this.accessories.push(accessory);
this.log.info('[Add] + Valve "%s" (UUID %s)', accName, uuid);
} else {
this.log.info('[Reuse] Valve "%s" (UUID %s)', accName, uuid);
}
accessory.displayName = accName;
accessory.context.deviceSerial = serial;
accessory.context.zoneIndex = zIndex;
accessory.context.kind = 'controller';
accessory.context.baseName = baseName;
let svc = accessory.getService(this.Service.Valve) || accessory.addService(this.Service.Valve);
svc.setCharacteristic(this.Characteristic.Name, accName);
svc.setCharacteristic(this.Characteristic.ValveType, 1);
svc.setCharacteristic(this.Characteristic.SetDuration, perDevDefaultSec);
svc.setCharacteristic(this.Characteristic.RemainingDuration, 0);
svc.setCharacteristic(this.Characteristic.InUse, 0);
svc.setCharacteristic(this.Characteristic.Active, 0);
svc.getCharacteristic(this.Characteristic.Active).onSet(async (value) => this._handleActiveSet(accessory, value));
svc.getCharacteristic(this.Characteristic.SetDuration).onSet(async (sec) => {
const active = svc.getCharacteristic(this.Characteristic.Active).value;
if (active === 1) this._armEndTimer(serial, zIndex, svc, Math.max(1, Math.ceil((sec || this.defaultDurationSec) / 60)));
});
if (!this.deviceAccessories[serial]) this.deviceAccessories[serial] = [];
if (!this.deviceAccessories[serial].includes(accessory)) this.deviceAccessories[serial].push(accessory);
}
if (this.autoPrune) {
const current = [...(this.deviceAccessories[serial] || [])];
const toRemove = current.filter(a => !desired.has(a.UUID));
if (toRemove.length) {
this.api.unregisterPlatformAccessories('homebridge-netro', 'Netro', toRemove);
this.deviceAccessories[serial] = (this.deviceAccessories[serial] || []).filter(a => !toRemove.includes(a));
toRemove.forEach(a => {
const i = this.accessories.findIndex(x => x.UUID === a.UUID);
if (i >= 0) this.accessories.splice(i, 1);
});
this.log.warn('[Prune] Removed %d accessories no longer desired.', toRemove.length);
}
}
} else if (kind === 'sensor') {
const uuid = this.api.hap.uuid.generate(`netro-${serial}-sensor`);
desiredAll.add(uuid);
let accessory = this.accessories.find(a => a.UUID === uuid);
if (!accessory) {
accessory = new this.api.platformAccessory(baseName, uuid, this.api.hap.Categories.SENSOR);
this.api.registerPlatformAccessories('homebridge-netro', 'Netro', [accessory]);
this.accessories.push(accessory);
this.log.info('[Add] + Sensor "%s" (UUID %s)', baseName, uuid);
}
} else if (kind === 'lamp') {
const uuid = this.api.hap.uuid.generate(`netro-${serial}-lamp`);
desiredAll.add(uuid);
let accessory = this.accessories.find(a => a.UUID === uuid);
if (!accessory) {
accessory = new this.api.platformAccessory(baseName, uuid, this.api.hap.Categories.LIGHTBULB);
this.api.registerPlatformAccessories('homebridge-netro', 'Netro', [accessory]);
this.accessories.push(accessory);
this.log.info('[Add] + Lamp "%s" (UUID %s)', baseName, uuid);
}
}
}
if (this.autoPrune) {
const toRemove = this.accessories.filter(a => !desiredAll.has(a.UUID));
if (toRemove.length) {
this.api.unregisterPlatformAccessories('homebridge-netro', 'Netro', toRemove);
toRemove.forEach(a => {
const i = this.accessories.findIndex(x => x.UUID === a.UUID);
if (i >= 0) this.accessories.splice(i, 1);
});
this.log.warn('[Prune] Globally removed %d stale accessories.', toRemove.length);
}
}
this.log.info('[Discover] Done. Total accessories now: %d.', this.accessories.length);
}
async _handleActiveSet(accessory, value) {
const serial = accessory.context.deviceSerial;
const zoneIndex = accessory.context.zoneIndex;
const service = accessory.getService(this.Service.Valve);
if (!service) return;
if (value === 1) {
const durationSec = service.getCharacteristic(this.Characteristic.SetDuration).value || this.defaultDurationSec;
const durationMin = Math.max(1, Math.ceil(durationSec / 60));
const payload = { key: serial, zones: [Number(zoneIndex)], duration: durationMin };
try {
const res = await this._netroPost('/npa/v1/water.json', payload);
if (!res || res.status !== 'OK') throw new Error('API status not OK');
service.updateCharacteristic(this.Characteristic.InUse, 1);
service.updateCharacteristic(this.Characteristic.RemainingDuration, durationMin * 60);
this._armEndTimer(serial, zoneIndex, service, durationMin);
this.log.info('[Watering] Started zone %s for %s min.', zoneIndex, durationMin);
} catch (e) {
this.log.error('[Watering] Failed start zone %s: %s', zoneIndex, e?.message || e);
service.updateCharacteristic(this.Characteristic.Active, 0);
service.updateCharacteristic(this.Characteristic.InUse, 0);
service.updateCharacteristic(this.Characteristic.RemainingDuration, 0);
}
} else {
await this._stopNow(serial, zoneIndex, service);
}
}
_armEndTimer(serial, zoneIndex, service, durationMin) {
const key = `${serial}:${zoneIndex}`;
if (this.activeTimers[key]) clearTimeout(this.activeTimers[key]);
this.activeTimers[key] = setTimeout(async () => {
await this._stopNow(serial, zoneIndex, service);
}, durationMin * 60 * 1000 + 3000);
}
async _stopNow(serial, zoneIndex, service) {
try { await this._netroPost('/npa/v1/stop_water.json', { key: serial }); } catch(e) {}
const key = `${serial}:${zoneIndex}`;
if (this.activeTimers[key]) { clearTimeout(this.activeTimers[key]); delete this.activeTimers[key]; }
service.updateCharacteristic(this.Characteristic.Active, 0);
service.updateCharacteristic(this.Characteristic.InUse, 0);
service.updateCharacteristic(this.Characteristic.RemainingDuration, 0);
this.log.info('[Watering] Stopped zone %s.', zoneIndex);
}
pollStatus() {
const devices = Array.isArray(this.config.devices) ? this.config.devices : [];
for (const dev of devices) {
const serial = String(dev.serial);
this._netroGet(`/npa/v1/info.json?key=${encodeURIComponent(serial)}`).then(data => {
if (!data || data.status !== 'OK' || !data.data) return;
const deviceInfo = data.data.device;
if (!deviceInfo) return;
if (deviceInfo.status !== 'WATERING') {
const accessories = this.deviceAccessories[serial] || [];
accessories.forEach(acc => {
const svc = acc.getService(this.Service.Valve); if (!svc) return;
svc.updateCharacteristic(this.Characteristic.Active, 0);
svc.updateCharacteristic(this.Characteristic.InUse, 0);
svc.updateCharacteristic(this.Characteristic.RemainingDuration, 0);
const key = `${serial}:${acc.context.zoneIndex}`;
if (this.activeTimers[key]) { clearTimeout(this.activeTimers[key]); delete this.activeTimers[key]; }
});
}
}).catch(err => {
this.log.warn('[Poll] API error for %s: %s', serial, err?.message || err);
});
}
}
_netroGet(path) {
return new Promise((resolve,reject)=>{
const req=https.request({method:'GET',hostname:'api.netrohome.com',path}, res=>{
let data=''; res.on('data',c=>data+=c);
res.on('end',()=>{ try{ resolve(JSON.parse(data)); } catch{ reject(new Error('Invalid JSON')); } });
}); req.on('error', reject); req.end();
});
}
_netroPost(path, payload){
return new Promise((resolve,reject)=>{
const body=JSON.stringify(payload);
const req=https.request({method:'POST',hostname:'api.netrohome.com',path,headers:{'Content-Type':'application/json','Content-Length': Buffer.byteLength(body)}}, res=>{
let data=''; res.on('data',c=>data+=c);
res.on('end',()=>{ try{ resolve(JSON.parse(data)); } catch{ reject(new Error('Invalid JSON')); } });
}); req.on('error', reject); req.write(body); req.end();
});
}
}
module.exports = { NetroPlatform };