Skip to content
This repository has been archived by the owner on Oct 23, 2024. It is now read-only.

Commit

Permalink
refactor!: switched plugin to be a dynamic platform (from static) so …
Browse files Browse the repository at this point in the history
…accesories will persist on homebridge restarts, fixes #8

BREAKING CHANGE: acessories will reset 1 last time and break automations, but should be cached after that
  • Loading branch information
apexad committed Apr 8, 2021
1 parent 53af200 commit 5c9c442
Show file tree
Hide file tree
Showing 7 changed files with 291 additions and 194 deletions.
214 changes: 139 additions & 75 deletions package-lock.json

Large diffs are not rendered by default.

13 changes: 7 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "homebridge-airport-express-playing",
"version": "1.4.9",
"description": "Homebridge plugin that uses mDNS request data to show a smart speaker for playing/paused for airport express devices.",
"main": "dist/platform.js",
"main": "dist/index.js",
"scripts": {
"clean": "rimraf ./dist",
"build": "rimraf ./dist && tsc",
Expand All @@ -15,8 +15,8 @@
"name": "Alex 'apexad' Martin"
},
"engines": {
"node": ">=10.17.0",
"homebridge": ">=1.0.0"
"homebridge": ">=1.3.0",
"node": ">=14.15.0"
},
"keywords": [
"homebridge-plugin"
Expand Down Expand Up @@ -46,10 +46,11 @@
],
"devDependencies": {
"@types/node": "10.17.19",
"homebridge": "^1.3.0",
"homebridge": "^1.3.4",
"rimraf": "^3.0.2",
"standard-version": "^9.1.1",
"typescript": "^3.9.9"
"standard-version": "^9.2.0",
"ts-node": "^9.1.1",
"typescript": "^4.2.2"
},
"dependencies": {
"mdns-js": "^1.0.3"
Expand Down
11 changes: 11 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { API } from 'homebridge';

import { PLATFORM_NAME } from './settings';
import AirportExpressPlayingPlatform from './platform';

/**
* This method registers the platform with Homebridge
*/
export = (api: API) => {
api.registerPlatform(PLATFORM_NAME, AirportExpressPlayingPlatform);
};
92 changes: 57 additions & 35 deletions src/platform.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,82 @@
import {
AccessoryPlugin,
API,
HAP,
Logging,
DynamicPlatformPlugin,
Logger,
PlatformAccessory,
PlatformConfig,
StaticPlatformPlugin,
} from "homebridge";
Service,
Characteristic,
} from 'homebridge';
import mdns from 'mdns-js';
import {
mDNSReply,
PLATFORM_NAME,
PLUGIN_NAME,
} from './settings';
import AirportExpress from "./platformAccessory";
import AirportExpress from './platformAccessory';

mdns.excludeInterface('0.0.0.0')
export default class AirportExpressPlayingPlatform implements DynamicPlatformPlugin {
public readonly Service: typeof Service = this.api.hap.Service;
public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic;

let hap: HAP;
public readonly accessories: PlatformAccessory[] = [];

export = (api: API) => {
hap = api.hap;
api.registerPlatform(PLATFORM_NAME, AirportExpressPlayingPlatform);
};
constructor(
public readonly log: Logger,
public readonly config: PlatformConfig,
public readonly api: API,
) {
this.config = config;
this.log.debug('Finished initializing platform:', this.config.name);

class AirportExpressPlayingPlatform implements StaticPlatformPlugin {
private readonly log: Logging;
private readonly config: PlatformConfig;
this.api.on('didFinishLaunching', () => {
log.debug('Executed didFinishLaunching callback');
this.discoverDevices();
});
}

constructor(log: Logging, config: PlatformConfig, api: API) {
this.log = log;
this.config = config;
log.info('platform finished initializing!');
configureAccessory(accessory: PlatformAccessory) {
this.log.info('Loading accessory from cache:', accessory.displayName);
this.accessories.push(accessory);
}

accessories(callback: (foundAccessories: AccessoryPlugin[]) => void): void {
const foundAccessories: AirportExpress[] = [];
discoverDevices() {
const mdnsBrowser = mdns.createBrowser(mdns.tcp("airplay"));

mdnsBrowser.on('ready', () => {
this.log('Searching for Airport Express devices')
this.log.info('Searching for Airport Express devices')
mdnsBrowser.discover();
});

mdnsBrowser.on('update', (data: mDNSReply) => {
if (data.txt.includes('model=AirPort10,115') && foundAccessories.findIndex(acc => data.txt.includes(`serialNumber=${acc.serialNumber}`)) === -1) {
foundAccessories.push(
new AirportExpress(hap, mdns, this.log, this.config, data)
)
if (data && data.txt && data.txt.includes('model=AirPort10,115')) {
const serialNumber = data.txt.find((str) => str.indexOf('serialNumber') > -1)?.replace('serialNumber=', '') || '';
const displayName = data.fullname.replace('._airplay._tcp.local', '');
const uuid = this.api.hap.uuid.generate(serialNumber);

const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);

if (existingAccessory) {
// the accessory already exists
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
new AirportExpress(this, existingAccessory);
} else {
this.log.info('Adding new accessory:', displayName);

const accessory = new this.api.platformAccessory(displayName, uuid);
accessory.context.device = {
serialNumber,
displayName,
data,
};

new AirportExpress(this, accessory);

this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
}
}
});

setTimeout(
() => {
mdnsBrowser.stop();
callback(foundAccessories);
},
5000
);

setTimeout(() => mdnsBrowser.stop(), 5000);
}
}
}
129 changes: 61 additions & 68 deletions src/platformAccessory.ts
Original file line number Diff line number Diff line change
@@ -1,103 +1,96 @@
import {
AccessoryPlugin,
CharacteristicSetCallback,
CharacteristicValue,
HAP,
Logging,
Service,
CharacteristicEventTypes,
PlatformConfig,
} from "homebridge";
PlatformAccessory,
CharacteristicValue,
} from 'homebridge';
import mdns from 'mdns-js';
import AirportExpressPlayingPlatform from './platform';
import { mDNSReply } from './settings';

export default class AirportExpress implements AccessoryPlugin {
private readonly log: Logging;
export default class ExamplePlatformAccessory {
private service: Service;
private readonly name: string;
public readonly serialNumber: string;
private readonly speakerService: Service;
private readonly informationService: Service;
private readonly switchService!: Service;
private readonly hap: HAP;
private readonly mdns: any;
private readonly serialNumber: string;
private readonly showSwitch: boolean;
private readonly switchService!: Service;

constructor(hap: HAP, mdns: any, log: Logging, config: PlatformConfig, data: mDNSReply) {
this.log = log;
this.name = data.fullname.replace('._airplay._tcp.local', '');
this.showSwitch = config.hasOwnProperty('showSwitch') ? config.showSwitch : true;
this.serialNumber = data.txt.find((str) => str.indexOf('serialNumber') > -1)?.replace('serialNumber=', '') || '';
this.hap = hap;
this.mdns = mdns;
this.speakerService = new hap.Service.SmartSpeaker(this.name);

this.speakerService
.setCharacteristic(this.hap.Characteristic.ConfiguredName, this.name);

this.speakerService
.getCharacteristic(this.hap.Characteristic.CurrentMediaState) /* ignore attempts to set media state */
.on(CharacteristicEventTypes.SET, (state: CharacteristicValue, callback: CharacteristicSetCallback) => callback(null));
constructor(
private readonly platform: AirportExpressPlayingPlatform,
private readonly accessory: PlatformAccessory,
) {
this.platform = platform;
this.showSwitch = this.platform.config.showSwitch;
this.name = accessory.context.device.displayName;
this.serialNumber = accessory.context.device.serialNumber

this.speakerService
.getCharacteristic(this.hap.Characteristic.TargetMediaState) /* ignore attempts to set media state */
.on(CharacteristicEventTypes.SET, (state: CharacteristicValue, callback: CharacteristicSetCallback) => callback(null));
// set accessory information
this.accessory.getService(this.platform.Service.AccessoryInformation)!
.setCharacteristic(this.platform.Characteristic.Manufacturer, 'Apple Inc. via apexad')
.setCharacteristic(this.platform.Characteristic.Model, 'AirPort10,115')
.setCharacteristic(this.platform.Characteristic.SerialNumber, this.serialNumber);

if(this.showSwitch) { this.switchService = new hap.Service.Switch(this.name); }
this.service = this.accessory.getService(this.platform.Service.SmartSpeaker) || this.accessory.addService(this.platform.Service.SmartSpeaker);
this.service
.setCharacteristic(this.platform.Characteristic.Name, this.name)
.setCharacteristic(this.platform.Characteristic.ConfiguredName, this.name);

this.informationService = new this.hap.Service.AccessoryInformation()
.setCharacteristic(this.hap.Characteristic.Manufacturer, 'Apple Inc. via apexad')
.setCharacteristic(this.hap.Characteristic.Model, 'AirPort10,115')
.setCharacteristic(this.hap.Characteristic.SerialNumber, this.serialNumber);
if(this.showSwitch) {
this.switchService = accessory.getService(this.platform.Service.Switch)
|| accessory.addService(this.platform.Service.Switch, `${this.name} Switch`, `${this.serialNumber} Switch`);
}

this.log.info(`Airport Express device ${this.name} (serial number: ${this.serialNumber} created!`);
this.platform.log.info(`Airport Express device ${this.name} (serial number: ${this.serialNumber} created!`);

this.setMediaState(this.convertMediaState(data.txt));
this.setMediaState(this.convertMediaState(accessory.context.device.data.txt));
setInterval(this.updateMediaState.bind(this), 5000);
}

convertMediaState(mDNS_TXT_record: Array<string>) {
const bit11 = (parseInt(mDNS_TXT_record.find((r: string) => r.indexOf('flag') > -1)!.replace('flags=', ''), 16).toString(2)).padStart(12, '0').charAt(0);
if (bit11 === '0') {
return this.hap.Characteristic.CurrentMediaState.STOP;
} else if (bit11 === '1') { /* bit11 correspponds to playing https://github.com/openairplay/airplay-spec/blob/master/src/status_flags.md */
return this.hap.Characteristic.CurrentMediaState.PLAY;
}
return this.hap.Characteristic.CurrentMediaState.INTERRUPTED;
}

updateMediaState() {
this.log.debug(`Updating Airport Exrpess with serial number ${this.serialNumber}`);
const mdnsBrowser = this.mdns.createBrowser(this.mdns.tcp("airplay"));
this.platform.log.debug(`Updating Airport Exrpess with serial number ${this.serialNumber}`);
const mdnsBrowser = mdns.createBrowser(mdns.tcp("airplay"));
mdnsBrowser.on('ready', () => mdnsBrowser.discover());
mdnsBrowser.on('update', (data: mDNSReply) => {
try {
const foundSerialNumber = data.txt.find((str) => str.indexOf('serialNumber') > -1)?.replace('serialNumber=', '');
if (data && data.txt) {
const foundSerialNumber = data.txt.find((str) => str.indexOf('serialNumber') > -1)?.replace('serialNumber=', '');

if (data.txt.includes('model=AirPort10,115') && foundSerialNumber && this.serialNumber === foundSerialNumber) {
this.log.debug(`txt record contents: ${data.txt}`)
this.setMediaState(this.convertMediaState(data.txt));
mdnsBrowser.stop();
if (data.txt.includes('model=AirPort10,115') && foundSerialNumber && this.serialNumber === foundSerialNumber) {
this.platform.log.debug(`txt record contents: ${data.txt}`)
this.setMediaState(this.convertMediaState(data.txt));
}
}
} catch(error) {
this.log.error(`Error in mDNS check, found invalid record`);
this.log.debug(error);
this.platform.log.error(`Error in mDNS check, found invalid record`);
this.platform.log.debug(error);
}
setTimeout(() => mdnsBrowser.stop(), 5000);
});
}

setMediaState(state: CharacteristicValue) {
this.speakerService
.setCharacteristic(this.hap.Characteristic.TargetMediaState, state)
.setCharacteristic(this.hap.Characteristic.CurrentMediaState, state);
this.service
.setCharacteristic(this.platform.Characteristic.TargetMediaState, state)
.setCharacteristic(this.platform.Characteristic.CurrentMediaState, state);

if (this.showSwitch) {
this.switchService
.setCharacteristic(this.hap.Characteristic.On, state ===this.hap.Characteristic.CurrentMediaState.PLAY ? true : false)
.setCharacteristic(
this.platform.Characteristic.On,
state === this.platform.Characteristic.CurrentMediaState.PLAY ? true : false
)
}
}

getServices(): Service[] {
const services = [ this.informationService, this.speakerService ];
if (this.showSwitch) { services.push(this.switchService); }
return services;
convertMediaState(mDNS_TXT_record: Array<string>) {
const bit11 = (parseInt(mDNS_TXT_record.find((r: string) => r.indexOf('flag') > -1)!.replace('flags=', ''), 16).toString(2)).padStart(12, '0').charAt(0);
if (bit11 === '0') {
return this.platform.Characteristic.CurrentMediaState.STOP;
} else if (bit11 === '1') {
/* bit11 correspponds to playing
* see https://github.com/openairplay/airplay-spec/blob/master/src/status_flags.md
*/
return this.platform.Characteristic.CurrentMediaState.PLAY;
}
return this.platform.Characteristic.CurrentMediaState.INTERRUPTED;
}
}
2 changes: 2 additions & 0 deletions src/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const PLATFORM_NAME = 'AirportExpressPlaying';
export const PLUGIN_NAME = 'homebridge-airport-express-playing';

export interface mDNSReply {
txt: Array<string>;
fullname: string;
Expand Down
24 changes: 14 additions & 10 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
{
"compilerOptions": {
"target": "ES2018",
"target": "ES2018", // ~node10
"module": "commonjs",
"lib": [
"ES2015",
"ES2016",
"ES2017",
"ES2018"
"es2015",
"es2016",
"es2017",
"es2018"
],
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": "src",
"outDir": "dist",

"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
"noImplicitAny": false
},
"include": [
"src"
"src/"
],
"exclude": [
"**/*.spec.ts"
]
}

0 comments on commit 5c9c442

Please sign in to comment.