UNPKG

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
'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 };