homebridge-meraki-control
Version:
Homebridge plugin to control Meraki devices.
227 lines (190 loc) • 8.32 kB
JavaScript
import { join } from 'path';
import { mkdirSync } from 'fs';
import axios from 'axios';
import DeviceDb from './src/devicedb.js';
import DeviceMr from './src/devicemr.js';
import DeviceMs from './src/devicems.js';
import ImpulseGenerator from './src/impulsegenerator.js';
import { PluginName, PlatformName, ApiUrls } from './src/constants.js';
class MerakiPlatform {
constructor(log, config, api) {
if (!config || !Array.isArray(config.devices)) {
log.warn(`No configuration found for ${PluginName}`);
return;
}
this.accessories = [];
const prefDir = join(api.user.storagePath(), 'meraki');
try {
mkdirSync(prefDir, { recursive: true });
} catch (error) {
log.error(`Prepare directory error: ${error.message ?? error}`);
return;
}
api.on('didFinishLaunching', () => {
// Each account is set up independently — a failure in one does not
// block the others. Promise.allSettled runs all in parallel.
Promise.allSettled(
config.devices.map(account =>
this.setupAccount(account, log, api)
)
).then(results => {
results.forEach((result, i) => {
if (result.status === 'rejected') {
log.error(`Account[${i}] setup error: ${result.reason?.message ?? result.reason}`);
}
});
});
});
}
// ── Per-account setup ───────────────────────────────────────────────────────
async setupAccount(account, log, api) {
if (account.disableAccessory) return;
const { name: accountName, apiKey, organizationId, networkId } = account;
if (!accountName || !apiKey || !organizationId || !networkId) {
log.warn(
`Name: ${accountName ? 'OK' : accountName}, ` +
`api key: ${apiKey ? 'OK' : apiKey}, ` +
`organization Id: ${organizationId ? 'OK' : organizationId}, ` +
`network Id: ${networkId ? 'OK' : networkId} in config missing.`
);
return;
}
const logLevel = {
devInfo: account.log?.deviceInfo ?? false,
success: account.log?.success ?? false,
info: account.log?.info ?? false,
warn: account.log?.warn ?? false,
error: account.log?.error ?? false,
debug: account.log?.debug ?? false
};
if (logLevel.debug) {
log.info(`Network: ${accountName}, did finish launching.`);
const safeConfig = { ...account, apiKey: 'removed', organizationId: 'removed', networkId: 'removed' };
log.info(`Network: ${accountName}, Config: ${JSON.stringify(safeConfig, null, 2)}`);
}
// The startup impulse generator retries the full discover+publish cycle
// every 120 s until it succeeds, then hands off to the device impulse
// generators and stops itself.
const impulseGenerator = new ImpulseGenerator()
.on('start', async () => {
try {
await this.startAccount(account, accountName, logLevel, log, api, impulseGenerator);
} catch (error) {
if (logLevel.error) log.error(`${accountName}, Start impulse generator error, ${error.message ?? error}, trying again.`);
}
})
.on('state', (state) => {
if (logLevel.debug) log.info(`${accountName}, Start impulse generator ${state ? 'started' : 'stopped'}.`);
});
await impulseGenerator.state(true, [{ name: 'start', sampling: 120_000 }]);
}
// ── Discover and register accessories for one account ──────────────────────
async startAccount(account, accountName, logLevel, log, api, impulseGenerator) {
const { apiKey, organizationId, networkId } = account;
const allDevices = this.buildDeviceList(account, organizationId, networkId);
if (allDevices.length === 0) {
if (logLevel.warn) log.warn(`Network: ${accountName}, no configured devices found, skipping.`);
return;
}
const client = axios.create({
baseURL: `${account.host}${ApiUrls.Base}`,
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Cisco-Meraki-API-Key': apiKey
}
});
// Register each device as a Homebridge accessory
for (const device of allDevices) {
await this.registerDevice(account, accountName, device, client, logLevel, log, api);
}
// Stop startup generator — each device now manages its own impulse generator
await impulseGenerator.state(false);
}
// ── Build the flat list of devices configured for one account ───────────────
buildDeviceList(account, organizationId, networkId) {
const allDevices = [];
// dashboard clients
if (account.dashboardClientsControl) {
const configuredClientsPolicy = (account.clientsPolicy ?? [])
.filter(p => !!p.name && !!p.mac && !!p.type && !!p.mode)
.map(p => ({ ...p, mac: p.mac.split(':').join('') }));
if (configuredClientsPolicy.length > 0) {
allDevices.push({
type: 0,
name: 'Dashboard',
uuid: organizationId,
deviceData: configuredClientsPolicy
});
}
}
// access points
if (account.accessPointsControl) {
const configuredHiddenSsidNames = (account.hideSsids ?? [])
.filter(s => !!s.name && !!s.mode)
.map(s => s.name);
if (configuredHiddenSsidNames.length > 0) {
allDevices.push({
type: 1,
name: 'Access Points',
uuid: networkId,
deviceData: configuredHiddenSsidNames
});
}
}
// switches
for (const sw of (account.switches ?? [])) {
if (sw.serialNumber && sw.mode) {
allDevices.push({
type: 2,
name: sw.name,
uuid: sw.serialNumber,
deviceData: sw
});
}
}
return allDevices;
}
// ── Register a single device as a Homebridge accessory ─────────────────────
async registerDevice(account, accountName, device, client, logLevel, log, api) {
const { type: deviceType, name: deviceName, uuid: deviceUuid, deviceData } = device;
let configuredDevice;
switch (deviceType) {
case 0: // dashboard clients
configuredDevice = new DeviceDb(api, account, deviceName, deviceUuid, deviceData, client);
break;
case 1: // access points
configuredDevice = new DeviceMr(api, account, deviceName, deviceUuid, deviceData, client);
break;
case 2: // switches
configuredDevice = new DeviceMs(api, account, deviceName, deviceUuid, deviceData, client);
break;
default:
if (logLevel.warn) log.warn(`${accountName}, Unknown device type: ${deviceType}, skipping.`);
return;
}
configuredDevice
.on('devInfo', (info) => logLevel.devInfo && log.info(info))
.on('success', (msg) => logLevel.success && log.success(`${accountName} ${deviceName}, ${msg}`))
.on('info', (msg) => logLevel.info && log.info(`${accountName} ${deviceName}, ${msg}`))
.on('debug', (msg) => logLevel.debug && log.info(`${accountName} ${deviceName}, debug: ${msg}`))
.on('warn', (msg) => logLevel.warn && log.warn(`${accountName} ${deviceName}, ${msg}`))
.on('error', (msg) => logLevel.error && log.error(`${accountName} ${deviceName}, ${msg}`));
const accessory = await configuredDevice.start();
if (!accessory) {
if (logLevel.warn) log.warn(`${accountName} ${deviceName}, start() returned no accessory, skipping.`);
return;
}
api.publishExternalAccessories(PluginName, [accessory]);
if (logLevel.success) log.success(`Device: ${accountName} ${deviceName}, Published as external accessory.`);
// Each device manages its own impulse generator lifecycle
await configuredDevice.startImpulseGenerator();
}
// ── Homebridge accessory cache ──────────────────────────────────────────────
configureAccessory(accessory) {
this.accessories.push(accessory);
}
}
export default (api) => {
api.registerPlatform(PluginName, PlatformName, MerakiPlatform);
};