homebridge-unifi-occupancy-lite
Version:
Lightweight UniFi occupancy sensor plugin for Homebridge using API tokens only. Works with UDM Pro/SE, UDM, UDR and other UniFi OS devices.
434 lines (433 loc) • 20.7 kB
JavaScript
"use strict";
/*
* Based on homebridge-unifi-occupancy by DouweM
* Original: https://github.com/DouweM/homebridge-unifi-occupancy
* Licensed under Apache-2.0
*
* Modified for homebridge-unifi-occupancy-lite - simplified, resident-based presence detection
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.UnifiOccupancyPlatform = void 0;
const settings_1 = require("./settings");
const unifi_client_1 = require("./unifi-client");
const resident_1 = require("./resident");
const global_presence_accessory_handler_1 = require("./global_presence_accessory_handler");
const wifi_point_accessory_handler_1 = require("./wifi_point_accessory_handler");
class UnifiOccupancyPlatform {
constructor(log, config, api) {
this.log = log;
this.config = config;
this.api = api;
this.Service = this.api.hap.Service;
this.Characteristic = this.api.hap.Characteristic;
this.residents = [];
this.wifiPoints = [];
this.accessories = new Map();
this.wifiPointHandlers = new Map();
this.log.info('Initializing UniFi Occupancy Lite platform...');
try {
if (!this.parseConfig()) {
this.log.warn('Configuration incomplete - plugin will wait for proper configuration');
return;
}
this.api.on('didFinishLaunching', () => {
this.log.info('Homebridge finished launching, setting up UniFi Occupancy Lite...');
try {
this.connect();
this.setupAccessories();
// Test connection before starting refresh
this.testConnectionAndStart();
}
catch (error) {
this.log.error('Failed to setup platform:', error);
}
});
}
catch (error) {
this.log.error('Failed to initialize platform:', error);
}
}
parseConfig() {
try {
// Check if minimal config exists
if (!this.config) {
this.log.warn('No configuration provided');
return false;
}
if (!this.config.unifi) {
this.log.warn('UniFi Controller is not configured - please configure in plugin settings');
return false;
}
if (!this.config.unifi.apiKey) {
this.log.warn('UniFi API Key is required - please add API key in plugin settings');
return false;
}
if (this.config.unifi.useSiteManagerApi && !this.config.unifi.hostId) {
this.log.error('Host ID is required when using Site Manager API');
return false;
}
// Set defaults safely
this.config.interval = this.config.interval || 180;
this.config.globalPresenceSensor = this.config.globalPresenceSensor !== false; // Default to true
this.config.residents = this.config.residents || [];
this.config.wifiPoints = this.config.wifiPoints || [];
// Initialize residents safely
try {
this.residents = this.config.residents.map((residentConfig) => {
if (!residentConfig.name) {
throw new Error('Resident name is required');
}
return new resident_1.Resident(residentConfig);
});
}
catch (error) {
this.log.error('Error initializing residents:', error);
this.residents = [];
}
// Initialize wifi points safely
try {
this.wifiPoints = this.config.wifiPoints.map((wifiPointConfig) => {
if (!wifiPointConfig.name) {
throw new Error('WiFi point name is required');
}
return new resident_1.WifiPoint(wifiPointConfig);
});
}
catch (error) {
this.log.error('Error initializing WiFi points:', error);
this.wifiPoints = [];
}
const totalDevices = this.residents.reduce((sum, r) => sum + r.devices.length, 0);
this.log.info(`Configuration loaded: ${this.residents.length} residents, ${totalDevices} devices, ${this.wifiPoints.length} WiFi points`);
return true;
}
catch (error) {
this.log.error('Error parsing configuration:', error);
return false;
}
}
connect() {
this.log.debug('Connecting to UniFi Controller...');
try {
// Log configuration for debugging (without sensitive info)
this.log.info(`UniFi Controller: ${this.config.unifi.controller}`);
this.log.info(`Site: ${this.config.unifi.site || 'default'}`);
this.log.info(`Secure: ${this.config.unifi.secure || false}`);
this.log.info(`Using Site Manager API: ${this.config.unifi.useSiteManagerApi || false}`);
if (this.config.unifi.hostId) {
this.log.info(`Host ID: ${this.config.unifi.hostId}`);
}
this.log.info(`API Key provided: ${this.config.unifi.apiKey ? 'Yes' : 'No'}`);
this.unifi = new unifi_client_1.UniFiLiteClient({
controller: this.config.unifi.controller,
apiKey: this.config.unifi.apiKey,
site: this.config.unifi.site || 'default',
secure: this.config.unifi.secure || false,
useSiteManagerApi: this.config.unifi.useSiteManagerApi || false,
hostId: this.config.unifi.hostId
});
// Log detected controller type
this.log.info(`Controller type detected: ${this.unifi.controllerType}`);
this.log.info('UniFi API Client initialized successfully');
}
catch (error) {
this.log.error('Failed to initialize UniFi client:', error);
throw error;
}
}
setupAccessories() {
try {
// Setup global presence sensor
if (this.config.globalPresenceSensor) {
this.setupGlobalPresenceAccessory();
}
// Setup WiFi point accessories
this.wifiPoints.forEach(wifiPoint => {
try {
this.setupWifiPointAccessory(wifiPoint);
}
catch (error) {
this.log.error(`Failed to setup WiFi point accessory for ${wifiPoint.name}:`, error);
}
});
this.log.info('Accessories setup completed');
}
catch (error) {
this.log.error('Failed to setup accessories:', error);
}
}
setupGlobalPresenceAccessory() {
try {
const uuid = this.api.hap.uuid.generate('global-presence');
let accessory = this.accessories.get(uuid);
if (!accessory) {
accessory = new this.api.platformAccessory('Global Presence', uuid);
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
this.accessories.set(uuid, accessory);
this.log.info('Created new global presence accessory');
}
this.globalPresenceHandler = new global_presence_accessory_handler_1.GlobalPresenceAccessoryHandler(this, accessory, this.residents);
}
catch (error) {
this.log.error('Failed to setup global presence accessory:', error);
}
}
setupWifiPointAccessory(wifiPoint) {
try {
const uuid = this.api.hap.uuid.generate(`wifi-point-${wifiPoint.name}`);
let accessory = this.accessories.get(uuid);
if (!accessory) {
accessory = new this.api.platformAccessory(`${wifiPoint.name} Presence`, uuid);
this.api.registerPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, [accessory]);
this.accessories.set(uuid, accessory);
this.log.info(`Created new wifi point accessory: ${wifiPoint.name}`);
}
const handler = new wifi_point_accessory_handler_1.WifiPointAccessoryHandler(this, accessory, wifiPoint, this.residents);
this.wifiPointHandlers.set(wifiPoint.name, handler);
}
catch (error) {
this.log.error(`Failed to setup WiFi point accessory for ${wifiPoint.name}:`, error);
}
}
refreshPeriodically() {
const interval = Math.max(30, Math.min(3600, this.config.interval)) * 1000; // Clamp between 30s and 1hour
this.log.debug(`Setting up periodic refresh every ${interval / 1000} seconds`);
setInterval(() => this.refresh(), interval);
}
configureAccessory(accessory) {
this.log.debug(`Configuring cached accessory: ${accessory.displayName}`);
this.accessories.set(accessory.UUID, accessory);
}
async refresh() {
try {
this.log.debug('Refreshing device presence...');
if (!this.unifi) {
this.log.warn('UniFi client not initialized, skipping refresh');
return;
}
// Get clients from UniFi using configured site
const clients = await this.unifi.getClients();
const accessPoints = await this.unifi.getNetworkDevices();
this.log.debug(`Found ${clients.length} clients and ${accessPoints.length} access points`);
// Update device status for each resident
for (const resident of this.residents) {
this.log.debug(`Checking devices for resident: ${resident.name}`);
for (const device of resident.devices) {
// Reset device status
device.isOnline = false;
device.currentAccessPoint = undefined;
// Find matching client
const matchingClient = clients.find(client => device.matchesClient(client));
if (matchingClient) {
// Get traffic data if needed
let trafficData = null;
if (device.minTrafficAmount && device.minTrafficAmount > 0) {
trafficData = await this.unifi.getClientTrafficLast15Min(matchingClient.mac);
}
device.updateFromClient(matchingClient, trafficData);
this.log.info(`Device ${device.name} (${resident.name}) online at AP: ${device.currentAccessPoint} - ${device.isOnline ? 'ONLINE' : 'OFFLINE'}`);
}
else {
this.log.debug(`No matching client found for device: ${device.name} (MAC: ${device.mac})`);
}
}
// Update resident presence based on device status
const wasHome = resident.isHome;
resident.updatePresence();
const onlineDevices = resident.devices.filter(d => d.isOnline).length;
this.log.debug(`Resident ${resident.name}: ${onlineDevices}/${resident.devices.length} devices online -> ${resident.isHome ? 'HOME' : 'AWAY'}`);
if (wasHome !== resident.isHome) {
this.log.info(`${resident.name} presence changed: ${wasHome ? 'HOME' : 'AWAY'} -> ${resident.isHome ? 'HOME' : 'AWAY'}`);
}
}
// Update WiFi points with current access point information
this.log.info(`Processing ${this.wifiPoints.length} WiFi points with ${accessPoints.length} access points`);
// Log access points for debugging
if (accessPoints.length > 0) {
this.log.info(`Access points found: ${accessPoints.map(ap => `${ap.name || 'unnamed'} (${ap.mac})`).join(', ')}`);
}
else {
this.log.warn('No access points returned from UniFi API');
}
for (const wifiPoint of this.wifiPoints) {
this.log.info(`Processing WiFi Point: ${wifiPoint.name} (${wifiPoint.mac})`);
const matchingAP = accessPoints.find(ap => wifiPoint.matchesAccessPoint(ap));
if (matchingAP) {
// Ensure wifiPoint has the correct MAC from the access point
if (!wifiPoint.mac) {
wifiPoint.mac = matchingAP.mac;
}
this.log.info(`WiFi Point ${wifiPoint.name} matched to AP: ${matchingAP.name || matchingAP.mac}`);
}
else {
this.log.warn(`WiFi Point ${wifiPoint.name} (${wifiPoint.mac}) not found in access points list`);
}
// Check which residents have devices at this access point
const residentsAtLocation = this.residents
.filter(resident => resident.getDevicesAtAccessPoint(wifiPoint.mac || '').length > 0)
.map(resident => {
const devicesAtAP = resident.getDevicesAtAccessPoint(wifiPoint.mac || '');
return `${resident.name} (${devicesAtAP.map(d => d.name).join(', ')})`;
});
this.log.info(`WiFi Point ${wifiPoint.name}: devices at AP ${wifiPoint.mac}: ${residentsAtLocation.length > 0 ? residentsAtLocation.join('; ') : 'none'}`);
// Update presence based on residents at this access point
const previousState = wifiPoint.hasResidents;
wifiPoint.updatePresence(this.residents);
if (previousState !== wifiPoint.hasResidents) {
if (wifiPoint.hasResidents) {
this.log.info(`WiFi Point ${wifiPoint.name}: occupied by ${residentsAtLocation.join(', ')}`);
}
else {
this.log.info(`WiFi Point ${wifiPoint.name}: now empty`);
}
}
this.log.info(`WiFi Point ${wifiPoint.name} status: ${wifiPoint.hasResidents ? 'occupied' : 'empty'}`);
}
// Update accessory handlers
this.globalPresenceHandler?.updateResidents(this.residents);
this.globalPresenceHandler?.refresh();
this.wifiPointHandlers.forEach(handler => {
handler.updateResidents(this.residents);
handler.refresh();
});
// Log presence summary
const homeResidents = this.residents.filter(r => r.isHome).map(r => r.name);
if (homeResidents.length > 0) {
this.log.info(`Residents at home: ${homeResidents.join(', ')}`);
}
else {
this.log.debug('No residents detected at home');
}
}
catch (error) {
this.log.error('Error refreshing device presence:', error);
}
}
// Remove unused accessories
removeUnusedAccessories() {
try {
const expectedAccessories = new Set();
// Add global presence if enabled
if (this.config.globalPresenceSensor) {
expectedAccessories.add(this.api.hap.uuid.generate('global-presence'));
}
// Add wifi point accessories
this.wifiPoints.forEach(wifiPoint => {
expectedAccessories.add(this.api.hap.uuid.generate(`wifi-point-${wifiPoint.name}`));
});
// Remove accessories that are no longer needed
const accessoriesToRemove = [];
this.accessories.forEach((accessory, uuid) => {
if (!expectedAccessories.has(uuid)) {
accessoriesToRemove.push(accessory);
}
});
if (accessoriesToRemove.length > 0) {
this.log.info(`Removing ${accessoriesToRemove.length} unused accessories`);
this.api.unregisterPlatformAccessories(settings_1.PLUGIN_NAME, settings_1.PLATFORM_NAME, accessoriesToRemove);
accessoriesToRemove.forEach(accessory => {
this.accessories.delete(accessory.UUID);
});
}
}
catch (error) {
this.log.error('Error removing unused accessories:', error);
}
}
async testConnectionAndStart() {
try {
this.log.info('Testing UniFi API connection...');
// Test connection first
let isConnected = await this.unifi.testConnection();
if (!isConnected) {
this.log.warn('Primary API endpoint failed, trying alternative configurations...');
// Try alternative configurations for different UniFi device types
const alternatives = this.getAlternativeConfigs();
for (const altConfig of alternatives) {
this.log.info(`Trying alternative configuration: ${altConfig.description}`);
const altClient = new unifi_client_1.UniFiLiteClient({
...this.config.unifi,
...altConfig.config
});
if (await altClient.testConnection()) {
this.log.info(`Successfully connected using: ${altConfig.description}`);
this.unifi = altClient;
isConnected = true;
break;
}
}
}
if (!isConnected) {
this.log.warn('Failed to connect to UniFi API - will retry periodically');
// Still start the refresh cycle, it will keep trying
this.refreshPeriodically();
return;
}
this.log.info('UniFi API connection successful');
// Only start refresh if we have residents configured or global sensor enabled
if (this.residents.length > 0 || this.config.globalPresenceSensor) {
await this.refresh();
this.refreshPeriodically();
this.log.info('Device presence monitoring started');
}
else {
this.log.info('No residents or sensors configured - plugin running in standby mode');
}
}
catch (error) {
this.log.error('Failed to test connection and start refresh:', error);
// Still start the refresh cycle, it will keep trying
this.refreshPeriodically();
}
}
getAlternativeConfigs() {
const baseController = this.config.unifi.controller.replace(/\/$/, '');
const isHttps = baseController.startsWith('https://');
const baseWithoutProtocol = baseController.replace(/^https?:\/\//, '');
return [
{
description: 'Legacy controller without proxy prefix',
config: {
controller: baseController.replace('/proxy/network', ''),
secure: false
}
},
{
description: 'UniFi OS with HTTPS and SSL verification disabled',
config: {
controller: `https://${baseWithoutProtocol}`.replace('/proxy/network', ''),
secure: false
}
},
{
description: 'Direct UniFi controller (port 8443)',
config: {
controller: `https://${baseWithoutProtocol.split(':')[0]}:8443`,
secure: false
}
},
{
description: 'UniFi OS on port 443 without proxy',
config: {
controller: `https://${baseWithoutProtocol.split(':')[0]}:443`,
secure: false
}
},
{
description: 'Legacy controller on port 8080',
config: {
controller: `http://${baseWithoutProtocol.split(':')[0]}:8080`,
secure: false
}
},
{
description: 'Cloud Key on port 8443',
config: {
controller: `https://${baseWithoutProtocol.split(':')[0]}:8443`,
secure: false
}
}
];
}
}
exports.UnifiOccupancyPlatform = UnifiOccupancyPlatform;