diff --git a/index.js b/index.js index 09de106..9c60845 100644 --- a/index.js +++ b/index.js @@ -5,10 +5,9 @@ 'use strict' -const ZpPlatformModule = require('./lib/ZpPlatform') -const ZpPlatform = ZpPlatformModule.ZpPlatform +const ZpPlatform = require('./lib/ZpPlatform') +const packageJson = require('./package.json') module.exports = (homebridge) => { - ZpPlatformModule.setHomebridge(homebridge) - homebridge.registerPlatform('homebridge-zp', 'ZP', ZpPlatform) + ZpPlatform.loadPlatform(homebridge, packageJson, 'ZP', ZpPlatform) } diff --git a/lib/ZpAccessory.js b/lib/ZpAccessory.js index a651fa1..5b5ea90 100644 --- a/lib/ZpAccessory.js +++ b/lib/ZpAccessory.js @@ -5,1577 +5,326 @@ 'use strict' -const ZpAlarmModule = require('./ZpAlarm') -const ZpAlarm = ZpAlarmModule.ZpAlarm - const events = require('events') -const he = require('he') -const request = require('request') -const SonosModule = require('sonos') -const util = require('util') -const xml2js = require('xml2js') - -module.exports = { - setHomebridge: setHomebridge, - ZpAccessory: ZpAccessory -} +const homebridgeLib = require('homebridge-lib') -let Service -let Characteristic -let my +const ZpService = require('./ZpService') const remoteKeys = {} const volumeSelectors = {} -function setHomebridge (Homebridge) { - Service = Homebridge.hap.Service - Characteristic = Homebridge.hap.Characteristic - remoteKeys[Characteristic.RemoteKey.REWIND] = 'Rewind' - remoteKeys[Characteristic.RemoteKey.FAST_FORWARD] = 'Fast Forward' - remoteKeys[Characteristic.RemoteKey.NEXT_TRACK] = 'Next Track' - remoteKeys[Characteristic.RemoteKey.PREVIOUS_TRACK] = 'Previous Track' - remoteKeys[Characteristic.RemoteKey.ARROW_UP] = 'Up' - remoteKeys[Characteristic.RemoteKey.ARROW_DOWN] = 'Down' - remoteKeys[Characteristic.RemoteKey.ARROW_LEFT] = 'Left' - remoteKeys[Characteristic.RemoteKey.ARROW_RIGHT] = 'Right' - remoteKeys[Characteristic.RemoteKey.SELECT] = 'Select' - remoteKeys[Characteristic.RemoteKey.BACK] = 'Back' - remoteKeys[Characteristic.RemoteKey.EXIT] = 'Exit' - remoteKeys[Characteristic.RemoteKey.PLAY_PAUSE] = 'Play/Pause' - remoteKeys[Characteristic.RemoteKey.INFORMATION] = 'Info' - volumeSelectors[Characteristic.VolumeSelector.INCREMENT] = 'Up' - volumeSelectors[Characteristic.VolumeSelector.DECREMENT] = 'Down' -} - -// ===== SONOS ACCESSORY ======================================================= - -// Constructor for ZpAccessory. -function ZpAccessory (platform, zp) { - this.name = platform.nameScheme.replace('%', zp.zone) - this.uuid_base = zp.id - this.zp = zp - this.platform = platform - my = my || this.platform.my - this.subscriptions = {} - this.state = { - group: {}, - zone: {}, - light: {} - } - this.log = this.platform.log - this.parser = new xml2js.Parser() - - this.avTransport = new SonosModule.Services.AVTransport(this.zp.host, this.zp.port) - this.renderingControl = new SonosModule.Services.RenderingControl(this.zp.host, this.zp.port) - this.groupRenderingControl = new SonosModule.Services.GroupRenderingControl(this.zp.host, this.zp.port) - this.alarmClock = new SonosModule.Services.AlarmClock(this.zp.host, this.zp.port) - - this.on('AlarmClock', this.handleAlarmClockEvent) - this.on('AVTransport', this.handleAVTransportEvent) - this.on('DeviceProperties', this.handleDevicePropertiesEvent) - this.on('GroupManagement', this.handleGroupManagementEvent) - this.on('GroupRenderingControl', this.handleGroupRenderingControlEvent) - this.on('RenderingControl', this.handleRenderingControlEvent) - this.on('ZoneGroupTopology', this.handleZoneGroupTopologyEvent) - - this.subscribe('DeviceProperties', (err) => { - if (err) { - this.log.error('%s: subscribe to DeviceProperties events: %s', this.name, err) - } - }) - this.subscribe('ZoneGroupTopology', (err) => { - if (err) { - this.log.error('%s: subscribe to ZoneGroupTopology events: %s', this.name, err) - } - }) -} - -util.inherits(ZpAccessory, events.EventEmitter) - -// Called by homebridge to initialise a static accessory. -ZpAccessory.prototype.getServices = function () { - this.tv = this.capabilities.tvIn - this.hasBalance = this.capabilities.audioIn || this.capabilities.stereoPair - - this.infoService = new Service.AccessoryInformation() - this.infoService - .updateCharacteristic(Characteristic.Manufacturer, 'homebridge-zp') - .updateCharacteristic(Characteristic.Model, this.zp.modelName) - .updateCharacteristic(Characteristic.SerialNumber, this.uuid_base) - .updateCharacteristic(Characteristic.FirmwareRevision, this.zp.version) - this.services = [this.infoService] - - if (this.platform.tv) { - this.groupService = new Service.Television(this.name, 'group') - this.groupService.getCharacteristic(Characteristic.ConfiguredName) - .updateValue(this.name) - .on('set', (value, callback) => { - this.log.info('%s: configured name changed to %j', this.name, value) - callback() - }) - this.groupService.getCharacteristic(Characteristic.SleepDiscoveryMode) - .updateValue(Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE) - this.groupService.getCharacteristic(Characteristic.Active) - .on('set', (value, callback) => { - this.log.info('%s: active changed to %s', this.name, value) - const on = value === Characteristic.Active.ACTIVE - return this.setGroupOn(on, callback) - }) - this.groupService.getCharacteristic(Characteristic.ActiveIdentifier) - .setProps({ maxValue: this.tv ? 3 : 2 }) - .setValue(1) - .on('set', (value, callback) => { - this.log.info('%s: active identifier changed to %j', this.name, value) - callback() - }) - this.groupService.getCharacteristic(Characteristic.RemoteKey) - .on('set', (value, callback) => { - this.log.debug('%s: %s (%j)', this.name, remoteKeys[value], value) - switch (value) { - case Characteristic.RemoteKey.PLAY_PAUSE: - return this.setGroupOn(!this.state.group.on, callback) - case Characteristic.RemoteKey.ARROW_LEFT: - return this.setGroupChangeTrack(-1, callback, false) - case Characteristic.RemoteKey.ARROW_RIGHT: - return this.setGroupChangeTrack(1, callback, false) - default: - return callback() - } - }) - this.groupService.getCharacteristic(Characteristic.PowerModeSelection) - .on('set', (value, callback) => { - this.log.info('%s: power mode selection changed to %j', this.name, value) - return callback() - }) - this.services.push(this.groupService) - - this.televisionSpeakerService = new Service.TelevisionSpeaker(this.zp.zone + ' Speakers', 'zone') - this.televisionSpeakerService - .updateCharacteristic(Characteristic.VolumeControlType, Characteristic.VolumeControlType.ABSOLUTE) - this.televisionSpeakerService.getCharacteristic(Characteristic.VolumeSelector) - .on('set', (value, callback) => { - this.log.debug('%s: %s (%j)', this.name, volumeSelectors[value], value) - const volume = value === Characteristic.VolumeSelector.INCREMENT ? 1 : -1 - this.setZoneChangeVolume(volume, callback, false) - }) - this.services.push(this.televisionSpeakerService) - // this.groupService.addLinkedService(this.televisionSpeakerService) - - const displayOrder = [] - - this.inputService1 = new Service.InputSource(this.name, 1) - this.inputService1 - .updateCharacteristic(Characteristic.ConfiguredName, 'Uno') - .updateCharacteristic(Characteristic.Identifier, 1) - .updateCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.TUNER) - .updateCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.AUDIO_SYSTEM) - .updateCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED) - .updateCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN) - this.services.push(this.inputService1) - this.groupService.addLinkedService(this.inputService1) - displayOrder.push(0x01, 0x04, 0x01, 0x00, 0x00, 0x00) - - this.inputService2 = new Service.InputSource(this.name, 2) - this.inputService2 - .updateCharacteristic(Characteristic.ConfiguredName, 'Due') - .updateCharacteristic(Characteristic.Identifier, 2) - .updateCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.TUNER) - .updateCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.AUDIO_SYSTEM) - .updateCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED) - .updateCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN) - this.services.push(this.inputService2) - this.groupService.addLinkedService(this.inputService2) - displayOrder.push(0x01, 0x04, 0x02, 0x00, 0x00, 0x00) - - if (this.tv) { - this.inputService3 = new Service.InputSource(this.name, 3) - this.inputService3 - .updateCharacteristic(Characteristic.ConfiguredName, 'TV') - .updateCharacteristic(Characteristic.Identifier, 3) - .updateCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.TUNER) - .updateCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.TV) - .updateCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED) - .updateCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN) - this.services.push(this.inputService3) - this.groupService.addLinkedService(this.inputService3) - displayOrder.push(0x01, 0x04, 0x03, 0x00, 0x00, 0x00) - } - - displayOrder.push(0x00, 0x00) - this.groupService.getCharacteristic(Characteristic.DisplayOrder) - .updateValue(Buffer.from(displayOrder).toString('base64')) - } else { - this.groupService = new this.platform.SpeakerService(this.name, 'group') - this.services.push(this.groupService) - this.groupService.addOptionalCharacteristic(Characteristic.On) - this.groupService.getCharacteristic(Characteristic.On) - .on('set', this.setGroupOn.bind(this)) - } - - this.groupService.addOptionalCharacteristic(this.platform.VolumeCharacteristic) - this.groupService.getCharacteristic(this.platform.VolumeCharacteristic) - .on('set', this.setGroupVolume.bind(this)) - this.groupService.addOptionalCharacteristic(my.Characteristic.ChangeVolume) - this.groupService.getCharacteristic(my.Characteristic.ChangeVolume) - .updateValue(0) - .on('set', this.setGroupChangeVolume.bind(this)) - this.groupService.addOptionalCharacteristic(Characteristic.Mute) - this.groupService.getCharacteristic(Characteristic.Mute) - .on('set', this.setGroupMute.bind(this)) - // this.groupService.addOptionalCharacteristic(my.Characteristic.ChangeInput) - // this.groupService.getCharacteristic(my.Characteristic.ChangeInput) - // .updateValue(0) - // .on('set', this.setGroupChangeInput.bind(this)) - this.groupService.addOptionalCharacteristic(my.Characteristic.ChangeTrack) - this.groupService.getCharacteristic(my.Characteristic.ChangeTrack) - .updateValue(0) - .on('set', this.setGroupChangeTrack.bind(this)) - this.groupService.addOptionalCharacteristic(my.Characteristic.CurrentTrack) - if (this.tv) { - this.groupService.addOptionalCharacteristic(my.Characteristic.TV) - } - this.groupService.addOptionalCharacteristic(my.Characteristic.SonosGroup) - this.groupService.addOptionalCharacteristic(my.Characteristic.SonosCoordinator) - this.groupService.getCharacteristic(my.Characteristic.SonosCoordinator) - .updateValue(false) - .on('set', this.setGroupSonosCoordinator.bind(this)) - - this.zoneService = new this.platform.SpeakerService( - this.zp.zone + ' Speakers', 'zone' - ) - this.zoneService.addOptionalCharacteristic(Characteristic.On) - this.zoneService.getCharacteristic(Characteristic.On) - .on('set', this.setZoneOn.bind(this)) - this.zoneService.addOptionalCharacteristic(this.platform.VolumeCharacteristic) - this.zoneService.getCharacteristic(this.platform.VolumeCharacteristic) - .on('set', this.setZoneVolume.bind(this)) - this.zoneService.addOptionalCharacteristic(my.Characteristic.ChangeVolume) - this.zoneService.getCharacteristic(my.Characteristic.ChangeVolume) - .updateValue(0) - .on('set', this.setZoneChangeVolume.bind(this)) - this.zoneService.addOptionalCharacteristic(Characteristic.Mute) - this.zoneService.getCharacteristic(Characteristic.Mute) - .on('set', this.setZoneMute.bind(this)) - if (this.hasBalance) { - this.zoneService.addOptionalCharacteristic(my.Characteristic.Balance) - this.zoneService.getCharacteristic(my.Characteristic.Balance) - .on('set', this.setZoneBalance.bind(this)) - } - this.zoneService.addOptionalCharacteristic(my.Characteristic.Bass) - this.zoneService.getCharacteristic(my.Characteristic.Bass) - .on('set', this.setZoneBass.bind(this)) - this.zoneService.addOptionalCharacteristic(my.Characteristic.Treble) - this.zoneService.getCharacteristic(my.Characteristic.Treble) - .on('set', this.setZoneTreble.bind(this)) - this.zoneService.addOptionalCharacteristic(my.Characteristic.Loudness) - this.zoneService.getCharacteristic(my.Characteristic.Loudness) - .on('set', this.setZoneLoudness.bind(this)) - if (this.platform.speakers) { - this.services.push(this.zoneService) - } - - this.lightService = new Service.Lightbulb(this.zp.zone + ' Sonos LED', 'light') - this.lightService.getCharacteristic(Characteristic.On) - .on('get', this.getLightOn.bind(this)) - .on('set', this.setLightOn.bind(this)) - if (this.platform.leds) { - this.services.push(this.lightService) - } - - this.alarms = {} - if (this.platform.alarms) { - for (let id in this.zp.alarms) { - const alarm = this.zp.alarms[id] - this.alarms[alarm.ID] = new ZpAlarm(this, alarm) - this.services.push(this.alarms[alarm.ID].service) - this.hasAlarms = true - } - } - this.createSubscriptions() - - return this.services -} - -// Return array of members. -ZpAccessory.prototype.members = function () { - if (!this.isCoordinator) { - return [] - } - return this.platform.groupMembers(this.group) -} - -// Copy group characteristic values from group coordinator. -ZpAccessory.prototype.copyCoordinator = function () { - const coordinator = this.coordinator - if (coordinator && coordinator !== this && !this.leaving) { - coordinator.becomePlatformCoordinator() - this.log.debug('%s: copy group characteristics from %s', this.name, coordinator.name) - if (this.state.group.on !== coordinator.state.group.on) { - this.log.debug( - '%s: set member %s (play/pause) from %s to %s', this.name, - this.platform.tv ? 'active' : 'power', - this.state.group.on, coordinator.state.group.on - ) - this.state.group.on = coordinator.state.group.on - if (this.platform.tv) { - const active = this.state.group.on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE - this.groupService.updateCharacteristic(Characteristic.Active, active) - } else { - this.groupService.updateCharacteristic(Characteristic.On, this.state.group.on) - } - } - if (this.state.group.volume !== coordinator.state.group.volume) { - this.log.debug( - '%s: set member group volume from %s to %s', this.name, - this.state.group.volume, coordinator.state.group.volume - ) - this.state.group.volume = coordinator.state.group.volume - this.groupService.updateCharacteristic(this.platform.VolumeCharacteristic, this.state.group.volume) - } - if (this.state.group.mute !== coordinator.state.group.mute) { - this.log.debug( - '%s: set member group mute from %s to %s', this.name, - this.state.group.mute, coordinator.state.group.mute - ) - this.state.group.mute = coordinator.state.group.mute - this.groupService.updateCharacteristic(Characteristic.Mute, this.state.group.mute) - } - if (this.state.group.track !== coordinator.state.group.track) { - this.log.debug( - '%s: set member current track from %j to %j', this.name, - this.state.group.track, coordinator.state.group.track - ) - this.state.group.track = coordinator.state.group.track - this.groupService.updateCharacteristic(my.Characteristic.CurrentTrack, this.state.group.track) - } - if (this.state.group.name !== coordinator.state.group.name) { - this.log.debug( - '%s: set member sonos group from %s to %s', this.name, - this.state.group.name, coordinator.state.group.name - ) - this.state.group.name = coordinator.state.group.name - this.groupService.updateCharacteristic(my.Characteristic.SonosGroup, this.state.group.name) - } - if (this.state.group.currentTransportActions !== coordinator.state.group.currentTransportActions) { - this.log.debug( - '%s: set member transport actions from %j to %j', this.name, - this.state.group.currentTransportActions, coordinator.state.group.currentTransportActions - ) - this.state.group.currentTransportActions = coordinator.state.group.currentTransportActions - } - } -} - -ZpAccessory.prototype.becomePlatformCoordinator = function () { - if (!this.platform.coordinator) { - this.log.info('%s: platform coordinator', this.name) - this.platform.setPlatformCoordinator(this) - this.state.zone.on = true - this.zoneService.updateCharacteristic(Characteristic.On, this.state.zone.on) +function init (characteristicHap) { + if (Object.keys(remoteKeys).length > 0) { + return } + remoteKeys[characteristicHap.RemoteKey.REWIND] = 'Rewind' + remoteKeys[characteristicHap.RemoteKey.FAST_FORWARD] = 'Fast Forward' + remoteKeys[characteristicHap.RemoteKey.NEXT_TRACK] = 'Next Track' + remoteKeys[characteristicHap.RemoteKey.PREVIOUS_TRACK] = 'Previous Track' + remoteKeys[characteristicHap.RemoteKey.ARROW_UP] = 'Up' + remoteKeys[characteristicHap.RemoteKey.ARROW_DOWN] = 'Down' + remoteKeys[characteristicHap.RemoteKey.ARROW_LEFT] = 'Left' + remoteKeys[characteristicHap.RemoteKey.ARROW_RIGHT] = 'Right' + remoteKeys[characteristicHap.RemoteKey.SELECT] = 'Select' + remoteKeys[characteristicHap.RemoteKey.BACK] = 'Back' + remoteKeys[characteristicHap.RemoteKey.EXIT] = 'Exit' + remoteKeys[characteristicHap.RemoteKey.PLAY_PAUSE] = 'Play/Pause' + remoteKeys[characteristicHap.RemoteKey.INFORMATION] = 'Info' + volumeSelectors[characteristicHap.VolumeSelector.INCREMENT] = 'Up' + volumeSelectors[characteristicHap.VolumeSelector.DECREMENT] = 'Down' } -// ===== SONOS EVENTS ========================================================== +// ===== SONOS ACCESSORY ======================================================= -ZpAccessory.prototype.createSubscriptions = function () { - this.subscribe('GroupManagement', (err) => { - if (err) { - this.log.error('%s: subscribe to GroupManagement events: %s', this.name, err) - } - setTimeout(() => { - // Give homebridge-zp some time to setup groups. - for (const member of this.members()) { - member.coordinator = this - member.log.info('%s: member of group %s', member.name, member.coordinator.name) - member.copyCoordinator() - } - this.subscribe('DeviceProperties', (err) => { - if (err) { - this.log.error('%s: subscribe to DeviceProperties events: %s', this.name, err) - } - }) - this.subscribe('ZoneGroupTopology', (err) => { - if (err) { - this.log.error('%s: subscribe to ZoneGroupTopology events: %s', this.name, err) - } - }) - this.subscribe('MediaRenderer/AVTransport', (err) => { - if (err) { - this.log.error('%s: subscribe to AVTransport events: %s', this.name, err) - } - }) - this.subscribe('MediaRenderer/GroupRenderingControl', (err) => { - if (err) { - this.log.error('%s: subscribe to GroupRenderingControl events: %s', this.name, err) +class ZpAccessory extends homebridgeLib.AccessoryDelegate { + constructor (platform, params) { + params.category = platform.Accessory.hap.Categories.SPEAKER + super(platform, params) + this.context.name = params.name + this.context.id = params.id + this.context.address = params.address + this.setAlive() + + init(this.Characteristic.hap) + this.alarmServices = {} + this.on('identify', async () => { + try { + if (this.blinking) { + return } - }) - if (this.platform.speakers) { - this.subscribe('MediaRenderer/RenderingControl', (err) => { - if (err) { - this.log.error('%s: subscribe to RenderingControl events: %s', this.name, err) - } - }) - } - if (this.hasAlarms) { - this.subscribe('AlarmClock', (err) => { - if (err) { - this.log.error('%s: subscribe to AlarmClock events: %s', this.name, err) - } - }) - } - }, 200) - }) -} - -ZpAccessory.prototype.onExit = function () { - for (const service in this.subscriptions) { - const sid = this.subscriptions[service] - this.unsubscribe(sid, service) - } -} - -ZpAccessory.prototype.handleGroupManagementEvent = function (data) { - this.log.debug('%s: GroupManagement event', this.name) - this.isCoordinator = data.GroupCoordinatorIsLocal === '1' - this.group = data.LocalGroupUUID - if (this.isCoordinator) { - this.coordinator = this - this.state.group.name = this.coordinator.zp.zone - this.log.info('%s: coordinator for group %s', this.name, this.state.group.name) - this.groupService.updateCharacteristic(my.Characteristic.SonosGroup, this.state.group.name) - for (const member of this.members()) { - member.coordinator = this - member.copyCoordinator() - } - if (this.platform.coordinator !== this) { - this.state.zone.on = false - this.zoneService.updateCharacteristic(Characteristic.On, this.state.zone.on) - } - } else { - this.coordinator = this.platform.groupCoordinator(this.group) - if (this.coordinator) { - this.log.info('%s: member of group %s', this.name, this.coordinator.zp.zone) - this.copyCoordinator() - } - this.state.zone.on = true - this.zoneService.updateCharacteristic(Characteristic.On, this.state.zone.on) - } -} - -ZpAccessory.prototype.handleDevicePropertiesEvent = function (data) { - this.log.debug('%s: DeviceProperties event', this.name) - if (this.capabilities == null) { - // this.log.debug('%s: DeviceProperties event: %j', this.name, data) - const caps = { - airPlay: data.AirPlayEnabled != null && data.AirPlayEnabled !== '0', - audioIn: data.SupportsAudioIn != null && data.SupportsAudioIn !== '0', - tvIn: data.HTFreq != null, - homeTheatreSetup: data.HTSatChanMapSet != null && data.HTSatChanMapSet !== '', - stereoPair: data.ChannelMapSet != null && data.ChannelMapSet !== '' - } - this.log.warn( - '%s: detected %s capabilities (please report if wrong): ' + - 'AirPlay: %j, audio input: %j, TV input: %j', - this.name, this.zp.modelName, caps.airPlay, caps.audioIn, caps.tvIn - ) - this.log.warn( - '%s: detected setup (please report if wrong): ' + - 'Home Theatre: %j, Stereo Pair: %j', - this.name, caps.homeTheatreSetup, caps.stereoPair - ) - this.capabilities = caps - } -} - -ZpAccessory.prototype.handleZoneGroupTopologyEvent = function (data) { - this.log.debug('%s: ZoneGroupTopology event', this.name) - if (data.ZoneGroupState != null) { - this.parser.parseString(data.ZoneGroupState, (error, json) => { - if (error) { - return - } - const zoneGroupState = json.ZoneGroupState - // this.log.debug('%s: ZoneGroupState: %j', this.name, zoneGroupState) - if ( - Array.isArray(zoneGroupState.ZoneGroups) && - zoneGroupState.ZoneGroups[0] != null && - Array.isArray(zoneGroupState.ZoneGroups[0].ZoneGroup) - ) { - for (const zoneGroup of zoneGroupState.ZoneGroups[0].ZoneGroup) { - if (Array.isArray(zoneGroup.ZoneGroupMember)) { - for (const zoneGroupMember of zoneGroup.ZoneGroupMember) { - const member = zoneGroupMember.$ - if (member != null) { - const slave = member.Invisible == null ? '' : ' (slave)' - const ipAddress = member.Location.split('/')[2].split(':')[0] - this.log.debug( - '%s: found %s at %s in %s%s', this.name, - member.UUID, ipAddress, member.ZoneName, slave - ) - if (this.platform.zpAccessories[member.UUID] != null) { - this.platform.zpAccessories[member.UUID].found() - } - } - if (Array.isArray(zoneGroupMember.Satellite)) { - for (const satellite of zoneGroupMember.Satellite) { - const member = satellite.$ - if (member != null) { - const ipAddress = member.Location.split('/')[2].split(':')[0] - this.log.debug( - '%s: found %s at %s in %s (satellite)', this.name, - member.UUID, ipAddress, member.ZoneName - ) - } - } - } - } - } + this.blinking = true + const on = await this.zpClient.getLedState() + for (let n = 0; n < 10; n++) { + this.zpClient.setLedState(n % 2 === 0) + await events.once(this, 'heartbeat') } + await this.zpClient.setLedState(on) + this.blinking = false + } catch (error) { + this.error(error) } - if (Array.isArray(zoneGroupState.VanishedDevices)) { - for (const vanishedDevice of zoneGroupState.VanishedDevices) { - if (Array.isArray(vanishedDevice.Device)) { - const device = vanishedDevice.Device[0].$ - if (device != null) { - this.log.debug('%s: lost %s in %s', this.name, device.UUID, device.ZoneName) - if (this.platform.zpAccessories[device.UUID]) { - this.platform.zpAccessories[device.UUID].lost() - } - } - } + }) + this.zpClient = platform.zpClients[params.id] + this.zpClient.on('event', (device, service, payload) => { + try { + const f = `handle${device}${service}Event` + if (this[f] != null) { + this[f](payload) } + } catch (error) { + this.error(error) } }) - } -} - -ZpAccessory.prototype.handleAVTransportEvent = function (data) { - this.log.debug('%s: AVTransport event', this.name) - this.parser.parseString(data.LastChange, (err, json) => { - if (err) { - return - } - let on - let track - let currentTransportActions - const event = json.Event.InstanceID[0] - // this.log.debug('%s: AVTransport event: %j', this.name, event) - if (event.TransportState && this.state.group.track !== 'TV') { - on = event.TransportState[0].$.val === 'PLAYING' - } - // if (event.CurrentTrackURI) { - // const data = event.CurrentTrackURI[0].$.val - // this.log.debug('%s: AVTransport event CurrentTrackURI: %j', this.name, data) - // } - if (event.CurrentTrackMetaData) { - const data = event.CurrentTrackMetaData[0].$.val - if (data) { - this.parser.parseString(data, (err, json) => { - if (!err && json['DIDL-Lite']) { - const item = json['DIDL-Lite'].item[0] - // this.log.debug('%s: AVTransport CurrentTrackMetaData: %j', this.name, item) - if (item.res != null && item.res[0] != null && item.res[0]._ != null) { - const type = item.res[0]._ - // this.log.debug('%s: AVTransport event CurrentTrackMetaData: %j', this.name, type) - switch (type.split(':')[0]) { - case 'x-rincon-stream': // Line in input. - track = he.decode(item['dc:title'][0]) // source - break - case 'x-sonos-htastream': // SPDIF TV input. - track = 'TV' - const streamInfo = item['r:streamInfo'][0] - // "0": no input; "2": stereo; "18": Dolby Digital 5.1; - on = streamInfo !== '0' - break - case 'x-sonosapi-vli': // Airplay2. - track = 'Airplay2' - break - case 'aac': // Radio stream (e.g. DI.fm) - case 'x-sonosapi-stream': // Radio stream. - case 'x-rincon-mp3radio': // AirTunes (by homebridge-zp). - track = he.decode(item['r:streamContent'][0]) // info - if (track === '') { - if (event['r:EnqueuedTransportURIMetaData']) { - const data = event['r:EnqueuedTransportURIMetaData'][0].$.val - if (data) { - this.parser.parseString(data, (err, json) => { - if (err) { - return - } - if (json['DIDL-Lite']) { - track = json['DIDL-Lite'].item[0]['dc:title'][0] // station - } - }) - } - } - } - break - case 'x-file-cifs': // Library song. - case 'x-sonos-http': // See issue #44. - case 'http': // Song on iDevice. - case 'https': // Apple Music, see issue #68 - case 'x-sonos-spotify': // Spotify song. - track = item['dc:title'][0] // song - // track = item['dc:creator'][0] // artist - // track = item['upnp:album'][0] // album - // track = item.res[0].$.duration // duration - break - case 'x-sonosapi-hls': // ?? - case 'x-sonosapi-hls-static': // e.g. Amazon Music - // Skip! update will arrive in subsequent CurrentTrackMetaData events - // and will be handled by default case - break - default: - this.log.warn('%s: unknown track metadata %j', this.name, item) - if (item['dc:title']) { - track = item['dc:title'][0] // song - } else { - track = '(unknown)' - } - break - } - } - } + this.sonosService = new ZpService.Sonos(this) + if (this.platform.config.speakers) { + this.speakerService = new ZpService.Speaker(this) + } else { + // TODO remove sercice if restored + } + if (this.platform.config.leds) { + this.ledService = new ZpService.Led(this) + } else { + // TODO remove sercice if restored + } + this.zpClient.subscribe('/GroupManagement/Event') + .catch((error) => { + this.error(error) + }) + if (this.platform.config.alarms) { + this.zpClient.subscribe('/AlarmClock/Event') + .catch((error) => { + this.error(error) }) - } - } - if (event.CurrentTransportActions && this.state.group.track !== 'TV') { - currentTransportActions = event.CurrentTransportActions[0].$.val.split(', ') - if (currentTransportActions.length === 1) { - track = '' - } - } - if (on != null && on !== this.state.group.on) { - this.log.info( - '%s: set %s (play/pause) from %s to %s', this.name, - this.platform.tv ? 'active' : 'power', this.state.group.on, on - ) - this.state.group.on = on - if (this.platform.tv) { - const active = on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE - this.groupService.updateCharacteristic(Characteristic.Active, active) - } else { - this.groupService.updateCharacteristic(Characteristic.On, this.state.group.on) - } - } - if (track != null && track !== this.state.group.track && - track !== 'ZPSTR_CONNECTING' && track !== 'ZPSTR_BUFFERING') { - this.log.info( - '%s: set current track from %j to %j', this.name, - this.state.group.track, track - ) - this.state.group.track = track - this.groupService.updateCharacteristic(my.Characteristic.CurrentTrack, this.state.group.track) - } - if (this.tv && on != null) { - const tv = on && track === 'TV' - if (tv !== this.state.group.tv) { - if (tv || this.state.group.tv == null) { - this.log.info( - '%s: set tv from %s to %s', this.name, - this.state.group.tv, tv - ) - this.state.group.tv = tv - this.groupService - .updateCharacteristic(my.Characteristic.TV, this.state.group.tv) - } else { - this.tvTimer = setTimeout(() => { - this.tvTimer = null - this.log.info( - '%s: set tv from %s to %s', this.name, - this.state.group.tv, tv - ) - this.state.group.tv = tv - this.groupService - .updateCharacteristic(my.Characteristic.TV, this.state.group.tv) - }, 10000) - } - } else if (this.tvTimer != null) { - clearTimeout(this.tvTimer) - this.tvTimer = null - } - } - if ( - currentTransportActions != null && - currentTransportActions !== this.state.group.currentTransportActions - ) { - // this.log.debug( - // '%s: transport actions changed from %j to %j', this.name, - // this.state.group.currentTransportActions, currentTransportActions - // ) - this.state.group.currentTransportActions = currentTransportActions - } - for (const member of this.members()) { - member.copyCoordinator() - } - }) -} - -ZpAccessory.prototype.handleGroupRenderingControlEvent = function (json) { - this.log.debug('%s: GroupRenderingControl event', this.name) - // this.log.debug('%s: GroupRenderingControl event: %j', this.name, json) - this.coordinator = this - this.leaving = false - if (json.GroupVolume) { - const volume = Number(json.GroupVolume) - if (volume !== this.state.group.volume) { - this.log.info('%s: set group volume from %s to %s', this.name, this.state.group.volume, volume) - this.state.group.volume = volume - this.groupService.updateCharacteristic(this.platform.VolumeCharacteristic, this.state.group.volume) - } - } - if (json.GroupMute) { - const mute = json.GroupMute === '1' - if (mute !== this.state.group.mute) { - this.log.info('%s: set group mute from %s to %s', this.name, this.state.group.mute, mute) - this.state.group.mute = mute - this.groupService.updateCharacteristic(Characteristic.Mute, this.state.group.mute) } } - for (const member of this.members()) { - member.copyCoordinator(this) - } -} -ZpAccessory.prototype.handleRenderingControlEvent = function (data) { - this.log.debug('%s: RenderingControl event', this.name) - this.parser.parseString(data.LastChange, (err, json) => { - if (err) { - return - } - const event = json.Event.InstanceID[0] - // this.log.debug('%s: RenderingControl event: %j', this.name, event) - if (event.Volume) { - let volume = 0 - let balance = 0 - for (const record of event.Volume) { - switch (record.$.channel) { - case 'Master': - volume = Number(record.$.val) - break - case 'LF': - balance -= Number(record.$.val) - break - case 'RF': - balance += Number(record.$.val) - break - default: - this.log.warn('%s: warning: %s: ingoring unknown Volume channel', this.name, record.$.channel) - return - } - } - if (volume !== this.state.zone.volume) { - this.log.info('%s: set volume from %s to %s', this.name, this.state.zone.volume, volume) - this.state.zone.volume = volume - this.zoneService.updateCharacteristic(this.platform.VolumeCharacteristic, this.state.zone.volume) - } - if (this.hasBalance && balance !== this.state.zone.balance) { - this.log.info('%s: set balance from %s to %s', this.name, this.state.zone.balance, balance) - this.state.zone.balance = balance - this.zoneService.updateCharacteristic(my.Characteristic.Balance, this.state.zone.balance) - } - } - if (event.Mute) { - const mute = event.Mute[0].$.val === '1' - if (mute !== this.state.zone.mute) { - this.log.info('%s: set mute from %s to %s', this.name, this.state.zone.mute, mute) - this.state.zone.mute = mute - this.zoneService.updateCharacteristic(Characteristic.Mute, this.state.zone.mute) - } - } - if (event.Bass) { - const bass = Number(event.Bass[0].$.val) - if (bass !== this.state.zone.bass) { - this.log.info('%s: set bass from %s to %s', this.name, this.state.zone.bass, bass) - this.state.zone.bass = bass - this.zoneService.updateCharacteristic(my.Characteristic.Bass, this.state.zone.bass) - } - } - if (event.Treble) { - const treble = Number(event.Treble[0].$.val) - if (treble !== this.state.zone.treble) { - this.log.info('%s: set treble from %s to %s', this.name, this.state.zone.treble, treble) - this.state.zone.treble = treble - this.zoneService.updateCharacteristic(my.Characteristic.Treble, this.state.zone.treble) - } - } - if (event.Loudness) { - const loudness = event.Loudness[0].$.val === '1' - if (loudness !== this.state.zone.loudness) { - this.log.info('%s: set loudness from %s to %s', this.name, this.state.zone.loudness, loudness) - this.state.zone.loudness = loudness - this.zoneService.updateCharacteristic(my.Characteristic.Loudness, this.state.zone.loudness) - } - } - if (event.NightMode) { - if (this.state.zone.nightSound == null) { - this.zoneService.addOptionalCharacteristic(my.Characteristic.NightSound) - this.zoneService.getCharacteristic(my.Characteristic.NightSound) - .on('set', this.setZoneNightSound.bind(this)) - } - const nightSound = event.NightMode[0].$.val === '1' - if (nightSound !== this.state.zone.nightSound) { - this.log.info('%s: set night sound from %s to %s', this.name, this.state.zone.nightSound, nightSound) - this.state.zone.nightSound = nightSound - this.zoneService.updateCharacteristic(my.Characteristic.NightSound, this.state.zone.nightSound) - } - } - if (event.DialogLevel) { - if (this.state.zone.speechEnhancement == null) { - this.zoneService.addOptionalCharacteristic(my.Characteristic.SpeechEnhancement) - this.zoneService.getCharacteristic(my.Characteristic.SpeechEnhancement) - .on('set', this.setZoneSpeechEnhancement.bind(this)) + handleZonePlayerGroupManagementEvent (event) { + // this.debug('GroupRenderingControl event %j', event) + this.isCoordinator = event.groupCoordinatorIsLocal === 1 + this.groupId = event.localGroupUuid + if (this.isCoordinator) { + this.coordinator = this + this.sonosService.values.sonosGroup = this.zpClient.zoneName + for (const member of this.members()) { + member.coordinator = this + member.copyCoordinator() } - const speechEnhancement = event.DialogLevel[0].$.val === '1' - if (speechEnhancement !== this.state.zone.speechEnhancement) { - this.log.info('%s: set speech enhancement from %s to %s', this.name, this.state.zone.speechEnhancement, speechEnhancement) - this.state.zone.speechEnhancement = speechEnhancement - this.zoneService.updateCharacteristic(my.Characteristic.SpeechEnhancement, this.state.zone.speechEnhancement) + if (this.platform.coordinator !== this && this.speakerService != null) { + this.speakerService.values.on = false } - } - }) -} - -ZpAccessory.prototype.handleAlarmClockEvent = function (data) { - this.log.debug('%s: AlarmClock event', this.name) - if (data.AlarmListVersion === this.platform.alarmListVersion) { - // Already handled. - return - } - this.platform.alarmListVersion = data.AlarmListVersion - this.log.debug( - '%s: alarm list version %s', this.name, this.platform.alarmListVersion - ) - this.alarmClock.ListAlarms((err, alarmClock) => { - if (err) { - return - } - for (const alarm of alarmClock.CurrentAlarmList) { - const zp = this.platform.zpAccessories[alarm.RoomUUID] - if (zp && zp.alarms[alarm.ID]) { - zp.alarms[alarm.ID].handleAlarm(alarm) + } else { + this.coordinator = this.platform.groupCoordinator(this.groupId) + if (this.coordinator) { + this.copyCoordinator() } - } - }) -} - -// ===== HOMEKIT EVENTS ======================================================== - -ZpAccessory.prototype.blink = function (n) { - this.zp.setLEDState('On', (err) => { - if (err) { - this.log.error('%s: set led state: %s', this.name, err) - } - setTimeout(() => { - this.zp.setLEDState('Off', (err) => { - if (err) { - this.log.error('%s: set led state: %s', this.name, err) - } - }) - setTimeout(() => { - if (--n > 0) { - return this.blink(n) - } - this.zp.setLEDState(this.state.light.on ? 'On' : 'Off', (err) => { - if (err) { - this.log.error('%s: set led state: %s', this.name, err) - } - this.lightService.updateCharacteristic(Characteristic.On, this.state.light.on) - this.blinking = false - }) - }, 1000) - }, 1000) - }) -} - -// Called by homebridge when accessory is identified from HomeKit. -ZpAccessory.prototype.identify = function (callback) { - this.log.info('%s: identify', this.name) - if (this.blinking) { - return callback() - } - this.blinking = true - this.zp.getLEDState((err, on) => { - if (err) { - this.log.error('%s: get led state: %s', this.name, err) - this.blinking = false - return callback(err) - } - this.state.light.on = on === 'On' - this.blink(5) - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneOn = function (on, callback) { - on = !!on - if (this.state.zone.on === on) { - return callback() - } - this.log.info('%s: power (group membership) changed from %s to %s', this.name, this.state.zone.on, on) - this.state.zone.on = on - if (on) { - const coordinator = this.platform.coordinator - if (coordinator) { - return this.join(coordinator, callback) - } - this.becomePlatformCoordinator() - return callback() - } else { - if (this.platform.coordinator === this) { - this.platform.coordinator = null - } - if (this.isCoordinator) { - const newCoordinator = this.members()[0] - if (newCoordinator) { - newCoordinator.becomePlatformCoordinator() - this.leaving = true - return this.abandon(newCoordinator, callback) + if (this.speakerService != null) { + this.speakerService.values.on = true } - return callback() } - this.leaving = true - return this.leave(callback) + this.emit('groupInitialised') } -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneVolume = function (volume, callback) { - if (this.state.zone.volume === volume) { - return callback() - } - this.log.info('%s: volume changed from %s to %s', this.name, this.state.zone.volume, volume) - this.zp.setVolume(volume + '', (err, data) => { - if (err) { - this.log.error('%s: set volume: %s', this.name, err) - return callback(err) - } - // this.state.zone.volume = volume - return callback() - }) -} -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneChangeVolume = function (volume, callback, reset = true) { - if (volume === 0) { - return callback() - } - if (reset) { - setTimeout(() => { - this.log.debug('%s: reset volume change to 0', this.name) - this.zoneService.updateCharacteristic(my.Characteristic.ChangeVolume, 0) - }, this.platform.resetTimeout) - } - this.log.info('%s: volume change %s', this.name, volume) - const newVolume = Math.min(Math.max(this.state.zone.volume + volume, 0), 100) - this.setZoneVolume(newVolume, callback) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneMute = function (mute, callback) { - mute = !!mute - if (this.state.zone.mute === mute) { - return callback() - } - this.log.info('%s: mute changed from %s to %s', this.name, this.state.zone.mute, mute) - this.zp.setMuted(mute, (err, data) => { - if (err) { - this.log.error('%s: set mute: %s', this.name, err) - return callback(err) - } - this.state.zone.mute = mute - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneBalance = function (balance, callback) { - if (this.state.zone.balance === balance) { - return callback() - } - this.log.info('%s: balance changed from %s to %s', this.name, this.state.zone.balance, balance) - const oldLeft = this.state.zone.balance > 0 ? 100 - this.state.zone.balance : 100 - const oldRight = this.state.zone.balance < 0 ? 100 + this.state.zone.balance : 100 - const left = balance > 0 ? 100 - balance : 100 - const right = balance < 0 ? 100 + balance : 100 - if (oldLeft !== left) { - const args = { - InstanceID: 0, - Channel: 'LF', - DesiredVolume: left + '' - } - this.log.debug('%s: set volume LF from %s to %s', this.name, oldLeft, left) - this.renderingControl._request('SetVolume', args, (err, status) => { - if (err) { - this.log.error('%s: set volume LF: %s', this.name, err) - return callback(err) - } - if (oldRight !== right) { - const args = { - InstanceID: 0, - Channel: 'RF', - DesiredVolume: right + '' - } - this.log.debug('%s: set volume RF from %s to %s', this.name, oldRight, right) - this.renderingControl._request('SetVolume', args, (err, status) => { - if (err) { - this.log.error('%s: set volume RF: %s', this.name, err) - return callback(err) - } - this.state.zone.balance = balance - return callback() - }) + async handleZonePlayerAlarmClockEvent (event) { + // this.debug('AlarmClock event %j', event) + // if (event.alarmListVersion === this.platform.alarmListVersion) { + // // Already handled. + // return + // } + // this.platform.alarmListVersion = event.alarmListVersion + const alarms = (await this.zpClient.listAlarms()).currentAlarmList + .filter((alarm) => { return alarm.roomUuid === this.context.id }) + this.debug('alarms: %j', alarms) + for (const alarm of alarms) { + const service = this.alarmServices[alarm.id] + if (service == null) { + this.alarmServices[alarm.id] = new ZpService.Alarm(this, alarm) } else { - this.state.zone.balance = balance - return callback() + service.alarm = alarm } - }) - } else if (oldRight !== right) { - const args = { - InstanceID: 0, - Channel: 'RF', - DesiredVolume: right + '' } - this.log.debug('%s: set volume RF from %s to %s', this.name, oldRight, right) - this.renderingControl._request('SetVolume', args, (err, status) => { - if (err) { - this.log.error('%s: set volume RF: %s', this.name, err) - return callback(err) - } - this.state.zone.balance = balance - return callback() - }) } -} -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneBass = function (bass, callback) { - if (this.state.zone.bass === bass) { - return callback() - } - this.log.info('%s: bass changed from %s to %s', this.name, this.state.zone.bass, bass) - const args = { - InstanceID: 0, - DesiredBass: bass + '' - } - this.renderingControl._request('SetBass', args, (err, status) => { - if (err) { - this.log.error('%s: set bass: %s', this.name, err) - return callback(err) + // Copy group characteristic values from group coordinator. + copyCoordinator () { + const coordinator = this.coordinator + if (coordinator && coordinator !== this && !this.leaving) { + coordinator.becomePlatformCoordinator() + const src = coordinator.sonosService.values + const dst = this.sonosService.values + dst.sonosGroup = src.sonosGroup + dst.on = src.on + dst.volume = src.volume + dst.mute = src.mute + dst.currentTrack = src.currentTrack + dst.currentTransportActions = src.currentTransportActions } - this.state.zone.bass = bass - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneTreble = function (treble, callback) { - if (this.state.zone.treble === treble) { - return callback() - } - this.log.info('%s: treble changed from %s to %s', this.name, this.state.zone.treble, treble) - const args = { - InstanceID: 0, - DesiredTreble: treble + '' } - this.renderingControl._request('SetTreble', args, (err, status) => { - if (err) { - this.log.error('%s: set treble: %s', this.name, err) - return callback(err) - } - this.state.zone.treble = treble - return callback() - }) -} -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneLoudness = function (loudness, callback) { - loudness = !!loudness - if (this.state.zone.loudness === loudness) { - return callback() - } - this.log.info('%s: loudness changed from %s to %s', this.name, this.state.zone.loudness, loudness) - const args = { - InstanceID: 0, - Channel: 'Master', - DesiredLoudness: loudness ? '1' : '0' - } - this.renderingControl._request('SetLoudness', args, (err, status) => { - if (err) { - this.log.error('%s: set loudness: %s', this.name, err) - return callback(err) + // Return array of members. + members () { + if (!this.isCoordinator) { + return [] } - this.state.zone.loudness = loudness - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneNightSound = function (nightSound, callback) { - nightSound = !!nightSound - if (this.state.zone.nightSound === nightSound) { - return callback() - } - this.log.info('%s: night sound changed from %s to %s', this.name, this.state.zone.nightSound, nightSound) - const args = { - InstanceID: 0, - EQType: 'NightMode', - DesiredValue: nightSound ? '1' : '0' + return this.platform.groupMembers(this.groupId) } - this.renderingControl._request('SetEQ', args, (err, status) => { - if (err) { - this.log.error('%s: set night mode: %s', this.name, err) - return callback(err) - } - this.state.zone.nightSound = nightSound - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setZoneSpeechEnhancement = function (speechEnhancement, callback) { - speechEnhancement = !!speechEnhancement - if (this.state.zone.speechEnhancement === speechEnhancement) { - return callback() - } - this.log.info('%s: speech enhancement changed from %s to %s', this.name, this.state.zone.speechEnhancement, speechEnhancement) - const args = { - InstanceID: 0, - EQType: 'DialogLevel', - DesiredValue: speechEnhancement ? '1' : '0' - } - this.renderingControl._request('SetEQ', args, (err, status) => { - if (err) { - this.log.error('%s: set speech enhancement: %s', this.name, err) - return callback(err) - } - this.state.zone.speechEnhancement = speechEnhancement - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setGroupOn = function (on, callback) { - on = !!on - if (this.state.group.on === on) { - return callback() - } - this.log.info( - '%s: %s (play/pause) changed from %s to %s', this.name, - this.platform.tv ? 'active' : 'power', this.state.group.on, on - ) - if (!this.isCoordinator) { - return this.coordinator.setGroupOn(on, callback) - } - if (on && this.state.group.currentTransportActions.includes('Play') && this.state.group.track !== 'TV') { - this.log.debug('%s: play', this.name) - this.zp.play((err, success) => { - if (err || !success) { - this.log.error('%s: play: %s', this.name, err) - return callback(err) - } - // this.state.group.on = on - // TODO: copy members - return callback() - }) - } else if (!on && this.state.group.currentTransportActions.includes('Pause')) { - this.log.debug('%s: pause', this.name) - this.zp.pause((err, success) => { - if (err || !success) { - this.log.error('%s: pause: %s', this.name, err) - return callback(err) - } - // this.state.group.on = on - // TODO: copy members - return callback() - }) - } else if (!on && this.state.group.currentTransportActions.includes('Stop')) { - this.log.debug('%s: stop', this.name) - this.zp.stop((err, success) => { - if (err || !success) { - this.log.error('%s: stop: %s', this.name, err) - return callback(err) - } - // this.state.group.on = on - // TODO: copy members - return callback() - }) - } else { - this.log.debug('%s: play/pause not available', this.name) - setTimeout(() => { - this.log.debug('%s: reset play/pause to %j', this.name, this.state.group.on) - if (this.platform.tv) { - const active = this.state.group.on ? Characteristic.Active.ACTIVE : Characteristic.Active.INACTIVE - this.groupService.updateCharacteristic(Characteristic.Active, active) - } else { - this.groupService.updateCharacteristic(Characteristic.On, this.state.group.on) - } - }, this.platform.resetTimeout) - return callback() - } -} -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setGroupVolume = function (volume, callback) { - if (this.state.group.volume === volume) { - return callback() - } - this.log.info('%s: group volume changed from %s to %s', this.name, this.state.group.volume, volume) - if (!this.isCoordinator) { - return this.coordinator.setGroupVolume(volume, callback) - } - const args = { - InstanceID: 0, - DesiredVolume: volume + '' - } - this.groupRenderingControl._request('SetGroupVolume', args, (err, status) => { - if (err) { - this.log.error('%s: set group volume: %s', this.name, err) - return callback(err) - } - // this.state.group.volume = volume - // TODO: copy members - return callback() - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setGroupChangeVolume = function (volume, callback, reset = true) { - if (volume === 0) { - return callback() - } - if (reset) { - setTimeout(() => { - this.log.debug('%s: reset group volume change to 0', this.name) - this.groupService.updateCharacteristic(my.Characteristic.ChangeVolume, 0) - }, this.platform.resetTimeout) - } - this.log.info('%s: group volume change %s', this.name, volume) - const newVolume = Math.min(Math.max(this.state.group.volume + volume, 0), 100) - this.setGroupVolume(newVolume, callback) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setGroupMute = function (mute, callback) { - mute = !!mute - if (this.state.group.mute === mute) { - return callback() - } - this.log.info('%s: group mute changed from %s to %s', this.name, this.state.group.mute, mute) - if (!this.isCoordinator) { - return this.coordinator.setGroupMute(mute, callback) - } - this.log.debug('%s: set group mute to ', this.name, mute) - const args = { - InstanceID: 0, - DesiredMute: mute - } - this.groupRenderingControl._request('SetGroupMute', args, (err, status) => { - if (err) { - this.log.error('%s: set group mute: %s', this.name, err) - return callback(err) - } - // this.state.group.mute = mute - // TODO: copy members - return callback() - }) -} + becomePlatformCoordinator () { + this.platform.setPlatformCoordinator(this) + if (this.speakerService != null) { + this.speakerService.values.on = true + } + } +} + +module.exports = ZpAccessory + +// // Called by homebridge to initialise a static accessory. +// ZpAccessory.prototype.getServices = function () { +// if (this.platform.tv) { +// this.groupService = new Service.Television(this.name, 'group') +// this.groupService.getCharacteristic(Characteristic.ConfiguredName) +// .updateValue(this.name) +// .on('set', (value, callback) => { +// this.log.info('%s: configured name changed to %j', this.name, value) +// callback() +// }) +// this.groupService.getCharacteristic(Characteristic.SleepDiscoveryMode) +// .updateValue(Characteristic.SleepDiscoveryMode.ALWAYS_DISCOVERABLE) +// this.groupService.getCharacteristic(Characteristic.Active) +// .on('set', (value, callback) => { +// this.log.info('%s: active changed to %s', this.name, value) +// const on = value === Characteristic.Active.ACTIVE +// return this.setGroupOn(on, callback) +// }) +// this.groupService.getCharacteristic(Characteristic.ActiveIdentifier) +// .setProps({ maxValue: this.tv ? 3 : 2 }) +// .setValue(1) +// .on('set', (value, callback) => { +// this.log.info('%s: active identifier changed to %j', this.name, value) +// callback() +// }) +// this.groupService.getCharacteristic(Characteristic.RemoteKey) +// .on('set', (value, callback) => { +// this.log.debug('%s: %s (%j)', this.name, remoteKeys[value], value) +// switch (value) { +// case Characteristic.RemoteKey.PLAY_PAUSE: +// return this.setGroupOn(!this.state.group.on, callback) +// case Characteristic.RemoteKey.ARROW_LEFT: +// return this.setGroupChangeTrack(-1, callback, false) +// case Characteristic.RemoteKey.ARROW_RIGHT: +// return this.setGroupChangeTrack(1, callback, false) +// default: +// return callback() +// } +// }) +// this.groupService.getCharacteristic(Characteristic.PowerModeSelection) +// .on('set', (value, callback) => { +// this.log.info('%s: power mode selection changed to %j', this.name, value) +// return callback() +// }) +// this.services.push(this.groupService) +// +// this.televisionSpeakerService = new Service.TelevisionSpeaker(this.zp.zone + ' Speakers', 'zone') +// this.televisionSpeakerService +// .updateCharacteristic(Characteristic.VolumeControlType, Characteristic.VolumeControlType.ABSOLUTE) +// this.televisionSpeakerService.getCharacteristic(Characteristic.VolumeSelector) +// .on('set', (value, callback) => { +// this.log.debug('%s: %s (%j)', this.name, volumeSelectors[value], value) +// const volume = value === Characteristic.VolumeSelector.INCREMENT ? 1 : -1 +// this.setZoneChangeVolume(volume, callback, false) +// }) +// this.services.push(this.televisionSpeakerService) +// // this.groupService.addLinkedService(this.televisionSpeakerService) +// +// const displayOrder = [] +// +// this.inputService1 = new Service.InputSource(this.name, 1) +// this.inputService1 +// .updateCharacteristic(Characteristic.ConfiguredName, 'Uno') +// .updateCharacteristic(Characteristic.Identifier, 1) +// .updateCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.TUNER) +// .updateCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.AUDIO_SYSTEM) +// .updateCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED) +// .updateCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN) +// this.services.push(this.inputService1) +// this.groupService.addLinkedService(this.inputService1) +// displayOrder.push(0x01, 0x04, 0x01, 0x00, 0x00, 0x00) +// +// this.inputService2 = new Service.InputSource(this.name, 2) +// this.inputService2 +// .updateCharacteristic(Characteristic.ConfiguredName, 'Due') +// .updateCharacteristic(Characteristic.Identifier, 2) +// .updateCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.TUNER) +// .updateCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.AUDIO_SYSTEM) +// .updateCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED) +// .updateCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN) +// this.services.push(this.inputService2) +// this.groupService.addLinkedService(this.inputService2) +// displayOrder.push(0x01, 0x04, 0x02, 0x00, 0x00, 0x00) +// +// if (this.tv) { +// this.inputService3 = new Service.InputSource(this.name, 3) +// this.inputService3 +// .updateCharacteristic(Characteristic.ConfiguredName, 'TV') +// .updateCharacteristic(Characteristic.Identifier, 3) +// .updateCharacteristic(Characteristic.InputSourceType, Characteristic.InputSourceType.TUNER) +// .updateCharacteristic(Characteristic.InputDeviceType, Characteristic.InputDeviceType.TV) +// .updateCharacteristic(Characteristic.IsConfigured, Characteristic.IsConfigured.CONFIGURED) +// .updateCharacteristic(Characteristic.CurrentVisibilityState, Characteristic.CurrentVisibilityState.SHOWN) +// this.services.push(this.inputService3) +// this.groupService.addLinkedService(this.inputService3) +// displayOrder.push(0x01, 0x04, 0x03, 0x00, 0x00, 0x00) +// } +// +// displayOrder.push(0x00, 0x00) +// this.groupService.getCharacteristic(Characteristic.DisplayOrder) +// .updateValue(Buffer.from(displayOrder).toString('base64')) +// +// this.alarms = {} +// if (this.platform.alarms) { +// for (let id in this.zp.alarms) { +// const alarm = this.zp.alarms[id] +// this.alarms[alarm.ID] = new ZpAlarm(this, alarm) +// this.services.push(this.alarms[alarm.ID].service) +// this.hasAlarms = true +// } +// } +// +// // ===== SONOS EVENTS ========================================================== +// +// ZpAccessory.prototype.createSubscriptions = function () { +// if (this.hasAlarms) { +// this.subscribe('AlarmClock', (err) => { +// if (err) { +// this.log.error('%s: subscribe to AlarmClock events: %s', this.name, err) +// } +// }) +// } +// } +// -// Called by homebridge when characteristic is changed from homekit. -// ZpAccessory.prototype.setGroupChangeInput = function (input, callback) { -// if (input === 0) { -// return callback() +// // ===== SONOS INTERACTION ===================================================== +// +// ZpAccessory.prototype.found = function () { +// if (this.isLost == null) { +// return // } -// setTimeout(() => { -// this.log.debug('%s: reset group input change to 0', this.name) -// this.groupService.updateCharacteristic(my.Characteristic.ChangeInput, 0) -// }, this.platform.resetTimeout) -// this.log.info('%s: group input change %s', this.name, input) -// if (!this.isCoordinator) { -// return this.coordinator.setGroupChangeInput(input, callback) +// this.log('%s: found', this.name) +// delete this.isLost +// this.createSubscriptions() +// } +// +// ZpAccessory.prototype.lost = function () { +// if (this.isLost) { +// return +// } +// this.isLost = true +// this.log('%s: lost', this.name) +// for (const service in this.subscriptions) { +// this.log.debug( +// '%s: invalidate %s subscription %s', this.name, service, +// this.subscriptions[service] +// ) +// delete this.subscriptions[service] // } -// this.log.debug('%s: input change not yet implemented', this.name) -// return callback() // } - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setGroupChangeTrack = function (track, callback, reset = true) { - if (track === 0) { - return callback() - } - if (reset) { - setTimeout(() => { - this.log.debug('%s: reset group track change to 0', this.name) - this.groupService.updateCharacteristic(my.Characteristic.ChangeTrack, 0) - }, this.platform.resetTimeout) - } - this.log.info('%s: group track change %s', this.name, track) - if (!this.isCoordinator) { - return this.coordinator.setGroupChangeTrack(track, callback) - } - if (track > 0 && this.state.group.currentTransportActions.includes('Next')) { - this.log.debug('%s: next track', this.name) - this.zp.next((err, success) => { - if (err) { - this.log.error('%s: next track: %s', this.name, err) - return callback(err) - } - return callback() - }) - } else if (track < 0 && this.state.group.currentTransportActions.includes('Previous')) { - this.log.debug('%s: previous track', this.name) - this.zp.previous((err, success) => { - if (err) { - this.log.error('%s: previous track: %s', this.name, err) - return callback(err) - } - return callback() - }) - } else { - this.log.debug('%s: next/previous track not available', this.name) - return callback() - } -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setGroupSonosCoordinator = function (on, callback) { - on = !!on - if (on && this.platform.coordinator === this) { - return callback() - } - this.zoneService.updateCharacteristic(Characteristic.On, false) - this.setZoneOn(false, () => { - this.platform.coordinator = null - if (on) { - this.becomePlatformCoordinator() - } - return callback() - }) -} - -// Called by homebridge when characteristic is read from homekit. -ZpAccessory.prototype.getLightOn = function (callback) { - this.zp.getLEDState((err, on) => { - if (err) { - this.log.error('%s: get led state: %s', this.name, err) - return callback(err) - } - const newOn = on === 'On' - if (newOn !== this.state.light.on) { - this.log.debug('%s: set led on from %s to %s', this.name, this.state.light.on, newOn) - this.state.light.on = newOn - } - return callback(null, this.state.light.on) - }) -} - -// Called by homebridge when characteristic is changed from homekit. -ZpAccessory.prototype.setLightOn = function (on, callback) { - if (this.state.zone.lightOn === on) { - return callback() - } - this.log.info('%s: led on changed from %s to %s', this.name, this.state.light.on, on) - this.zp.setLEDState(on ? 'On' : 'Off', (err) => { - if (err) { - this.log.error('%s: set led state: %s', this.name, err) - return callback(err) - } - this.state.light.on = on - return callback() - }) -} - -// ===== SONOS INTERACTION ===================================================== - -// Join a group. -ZpAccessory.prototype.join = function (coordinator, callback) { - this.log.debug('%s: join %s', this.name, coordinator.name) - const args = { - InstanceID: 0, - CurrentURI: 'x-rincon:' + coordinator.zp.id, - CurrentURIMetaData: null - } - this.avTransport.SetAVTransportURI(args, (err, status) => { - if (err) { - this.log.error('%s: join %s: %s', this.name, coordinator.name, err) - return callback(err) - } - return callback() - }) -} - -// Leave a group. -ZpAccessory.prototype.leave = function (callback) { - const oldGroup = this.coordinator.name - this.log.debug('%s: leave %s', this.name, oldGroup) - const args = { - InstanceID: 0 - } - this.avTransport.BecomeCoordinatorOfStandaloneGroup(args, (err, status) => { - if (err) { - this.log.error('%s: leave %s: %s', this.name, oldGroup, err) - return callback(err) - } - return callback() - }) -} - -// Transfer ownership and leave a group. -ZpAccessory.prototype.abandon = function (newCoordinator, callback) { - const oldGroup = this.coordinator.name - this.log.debug('%s: leave %s to %s', this.name, oldGroup, newCoordinator.name) - const args = { - InstanceID: 0, - NewCoordinator: newCoordinator.zp.id, - RejoinGroup: false - } - this.avTransport.DelegateGroupCoordinationTo(args, (err, status) => { - if (err) { - this.log.error('%s: leave %s to %s: %s', this.name, oldGroup, newCoordinator.name, err) - return callback(err) - } - return callback() - }) -} - -// Subscribe to Sonos ZonePlayer events -ZpAccessory.prototype.subscribe = function (service, callback) { - if (this.platform.shuttingDown || this.subscriptions[service] != null) { - return callback() - } - const subscribeUrl = 'http://' + this.zp.host + ':' + this.zp.port + '/' + - service + '/Event' - const callbackUrl = this.platform.callbackUrl + '/' + this.zp.id + '/' + service - const opt = { - url: subscribeUrl, - method: 'SUBSCRIBE', - headers: { - CALLBACK: '<' + callbackUrl + '>', - NT: 'upnp:event', - TIMEOUT: 'Second-' + this.platform.subscriptionTimeout - } - } - this.request(opt, (err, response) => { - if (err) { - return callback(err) - } - this.log.debug( - '%s: new %s subscription %s (timeout %s)', this.name, - service, response.headers.sid, response.headers.timeout - ) - this.subscriptions[service] = response.headers.sid - if (this.platform.shuttingDown) { - this.unsubscribe(response.headers.sid, service) - return callback() - } - setTimeout(() => { - this.resubscribe(response.headers.sid, service) - }, (this.platform.subscriptionTimeout - 60) * 1000) - return callback() - }) -} - -// Cancel subscription to Sonos ZonePlayer events -ZpAccessory.prototype.unsubscribe = function (sid, service) { - const subscribeUrl = 'http://' + this.zp.host + ':' + this.zp.port + '/' + - service + '/Event' - const opt = { - url: subscribeUrl, - method: 'UNSUBSCRIBE', - headers: { - SID: sid - } - } - this.request(opt, (err, response) => { - if (err) { - this.log.error('%s: cancel %s subscription %s: %s', this.name, service, sid, err) - return - } - this.log.debug( - '%s: cancelled %s subscription %s', this.name, service, sid - ) - delete this.subscriptions[service] - }) -} - -// Renew subscription to Sonos ZonePlayer events -ZpAccessory.prototype.resubscribe = function (sid, service) { - if (this.platform.shuttingDown || sid !== this.subscriptions[service]) { - return - } - this.log.debug('%s: renewing %s subscription %s', this.name, service, sid) - const subscribeUrl = 'http://' + this.zp.host + ':' + this.zp.port + '/' + - service + '/Event' - const opt = { - url: subscribeUrl, - method: 'SUBSCRIBE', - headers: { - SID: sid, - TIMEOUT: 'Second-' + this.platform.subscriptionTimeout - } - } - this.request(opt, (err, response) => { - if (err) { - this.log.error('%s: renew %s subscription %s: %s', this.name, service, sid, err) - this.subscribe(service, (err) => { - this.log.error('%s: subscribe to %s events: %s', this.name, service, err) - }) - return - } - this.log.debug( - '%s: renewed %s subscription %s (timeout %s)', this.name, - service, response.headers.sid, response.headers.timeout - ) - if (this.platform.shuttingDown) { - this.unsubscribe(response.headers.sid, service) - return - } - setTimeout(() => { - this.resubscribe(response.headers.sid, service) - }, (this.platform.subscriptionTimeout - 60) * 1000) - }) -} - -ZpAccessory.prototype.found = function () { - if (this.isLost == null) { - return - } - this.log('%s: found', this.name) - delete this.isLost - this.createSubscriptions() -} - -ZpAccessory.prototype.lost = function () { - if (this.isLost) { - return - } - this.isLost = true - this.log('%s: lost', this.name) - for (const service in this.subscriptions) { - this.log.debug( - '%s: invalidate %s subscription %s', this.name, service, - this.subscriptions[service] - ) - delete this.subscriptions[service] - } -} - -// Send request to Sonos ZonePlayer. -ZpAccessory.prototype.request = function (opt, callback) { - this.log.debug('%s: %s %s', this.name, opt.method, opt.url) - request(opt, (err, response) => { - if (err) { - this.log.error('%s: cannot %s %s (%s)', this.name, opt.method, opt.url, err) - return callback(err) - } - if (response.statusCode !== 200) { - this.log.error( - '%s: cannot %s %s (%d - %s)', this.name, opt.method, opt.url, - response.statusCode, response.statusMessage - ) - return callback(response.statusCode) - } - return callback(null, response) - }) -} diff --git a/lib/ZpAlarm.js b/lib/ZpAlarm.js deleted file mode 100644 index 9e6c8e6..0000000 --- a/lib/ZpAlarm.js +++ /dev/null @@ -1,76 +0,0 @@ -// homebridge-zp/lib/ZpAlarm.js -// Copyright © 2016-2019 Erik Baauw. All rights reserved. -// -// Homebridge plugin for Sonos ZonePlayer. - -'use strict' - -const xml2js = require('xml2js') - -module.exports = { - ZpAlarm: ZpAlarm -} - -let my - -// ===== SONOS ALARM =========================================================== - -function ZpAlarm (zpAccessory, alarm) { - this.accessory = zpAccessory - this.log = this.accessory.log - this.id = alarm.ID - my = my || this.accessory.platform.my - this.parser = new xml2js.Parser() - // this.log.debug('%s: alarm (%j)', this.accessory.name, alarm) - if (alarm.ProgramURI === 'x-rincon-buzzer:0') { - this.name = 'Sonos Chime' - } else { - const data = alarm.ProgramMetaData - if (data) { - this.parser.parseString(data, function (err, json) { - // this.log.debug('%s: alarm metadata %j', this.name, json) - if (!err && json['DIDL-Lite']) { - this.name = json['DIDL-Lite'].item[0]['dc:title'] - } else { - this.name = '' - } - }.bind(this)) - } - } - this.name = this.name + ' (' + alarm.StartTime + ')' - this.log.debug('%s: alarm %d: %s', this.accessory.name, alarm.ID, this.name) - this.service = new my.Service.Alarm(zpAccessory.zp.zone + ' alarm ' + this.name, alarm.ID) - this.service.getCharacteristic(my.Characteristic.Enabled) - .on('set', this.setEnabled.bind(this)) -} - -ZpAlarm.prototype.handleAlarm = function (alarm) { - this.alarm = alarm - const newValue = alarm.Enabled === '1' - if (newValue !== this.value) { - this.log.info( - '%s: set alarm %s enabled from %s to %s', this.accessory.name, - this.name, this.value, newValue - ) - this.value = newValue - this.service.setCharacteristic(my.Characteristic.Enabled, this.value) - } -} - -ZpAlarm.prototype.setEnabled = function (enabled, callback) { - if (enabled === this.value) { - return callback() - } - this.log.debug( - '%s: alarm %s enabled changed from %s to %s', this.accessory.name, - this.name, this.value, enabled - ) - this.accessory.alarmClock.SetAlarm(this.id, enabled, function (err, data) { - if (err) { - this.log.error('%s: set alarm %s enabled: %s', this.accessory.name, this.name, err) - return callback(err) - } - this.value = enabled - return callback() - }.bind(this)) -} diff --git a/lib/ZpPlatform.js b/lib/ZpPlatform.js index 8d356f1..35bad73 100644 --- a/lib/ZpPlatform.js +++ b/lib/ZpPlatform.js @@ -2,369 +2,437 @@ // Copyright © 2016-2019 Erik Baauw. All rights reserved. // // Homebridge plugin for Sonos ZonePlayer. -// -// TODO: -// - Upgrade to sonos@1.x asynchronous interface. 'use strict' -const http = require('http') +const events = require('events') const homebridgeLib = require('homebridge-lib') -const os = require('os') -const semver = require('semver') -const SonosModule = require('sonos') -const util = require('util') -const xml2js = require('xml2js') - -const ZpAccessoryModule = require('./ZpAccessory') -const ZpAccessory = ZpAccessoryModule.ZpAccessory -const packageJson = require('../package.json') - -module.exports = { - ZpPlatform: ZpPlatform, - setHomebridge: setHomebridge -} +const ZpAccessory = require('./ZpAccessory') +const ZpClient = require('./ZpClient') +const ZpListener = require('./ZpListener') -function minVersion (range) { - let s = range.split(' ')[0] - while (s) { - if (semver.valid(s)) { - break +// Constructor for ZpPlatform. Called by homebridge on load time. +class ZpPlatform extends homebridgeLib.Platform { + constructor (log, configJson, homebridge) { + super(log, configJson, homebridge) + this.on('accessoryRestored', this.accessoryRestored) + if (configJson == null) { + return } - s = s.substring(1) - } - return s || undefined -} - -// Convert string with IP address to int. -function ipToInt (ipaddress) { - const a = ipaddress.split('.') - return a[0] << 24 | a[1] << 16 | a[2] << 8 | a[3] -} + this.parseConfigJson(configJson) + this.unInitialisedZpClients = 0 + this.zpAccessories = {} // ZpAccessory by id of master. + this.zpClients = {} // ZpClient by id. + this.zonePlayers = {} // Reachable ZonePlayers by id. + this.zones = {} // Reachable zone by zoneName. -// Check whether ip1 and ip2 are in the same network. -function inSameNetwork (ip1, ip2, netmask) { - return (ipToInt(ip1) & ipToInt(netmask)) === (ipToInt(ip2) & ipToInt(netmask)) -} - -// Find my address for network of ip. -function findMyAddressFor (ip) { - const interfaces = os.networkInterfaces() - for (const id in interfaces) { - const aliases = interfaces[id] - for (const aid in aliases) { - const alias = aliases[aid] - if ( - alias.family === 'IPv4' && alias.internal === false && - inSameNetwork(ip, alias.address, alias.netmask) - ) { - return alias.address + // this.once('heartbeat', this.init) + this.on('heartbeat', this.heartbeat) + this.on('shutdown', async () => { + for (const id in this.zpClients) { + try { + await this.zpClients[id].close() + } catch (error) { + this.log(error) + } } - } - } - return '0.0.0.0' -} + }) -// ======================================================================================= -// -// Link platform module to Homebridge. + this.upnpConfig({ class: 'urn:schemas-upnp-org:device:ZonePlayer:1' }) + this.on('upnpDeviceAlive', this.handleUpnpMessage) + this.on('upnpDeviceFound', this.handleUpnpMessage) -let Service -let Characteristic -let homebridgeVersion -let _homebridge -let my + // Setup listener for zoneplayer events. + this.zpListener = new ZpListener() + this.zpListener.on('listening', (url) => { + this.log('listening on %s', url) + }) + this.zpListener.on('close', (url) => { + this.log('closed %s', url) + }) + this.zpListener.on('error', (error) => { this.error(error) }) -function setHomebridge (homebridge) { - // Link accessory modules to Homebridge. - ZpAccessoryModule.setHomebridge(homebridge) + const jsonOptions = { noWhiteSpace: false, sortKeys: true } + this.jsonFormatter = new homebridgeLib.JsonFormatter(jsonOptions) - my = new homebridgeLib.MyHomeKitTypes(homebridge) + this.debug('config: %j', this.config) + this.debug('SpeakerService: %j', this.config.SpeakerService.UUID) + this.debug('VolumeCharacteristic: %j', this.config.VolumeCharacteristic.UUID) + } - Service = homebridge.hap.Service - Characteristic = homebridge.hap.Characteristic - homebridgeVersion = homebridge.serverVersion - _homebridge = homebridge -} + // Reachable ZonePlayers by zonePlayerName + get zonePlayersByName () { + const zonePlayersByName = {} + for (const id in this.zonePlayers) { + const zonePlayer = this.zonePlayers[id] + zonePlayersByName[zonePlayer.name] = zonePlayer + } + return zonePlayersByName + } -// ======================================================================================= + // get zones () { + // const zonesByName = {} + // for (const id in this.zonePlayers) { + // const zonePlayer = this.zonePlayers[id] + // if (zonePlayer.role === 'master') { + // zonesByName[zonePlayer.zoneName] = zonePlayer + // } + // } + // return zonesByName + // } -// Constructor for ZpPlatform. Called by homebridge on load time. -function ZpPlatform (log, config) { - this.log = log - this.name = config.name || 'ZP' - this.host = config.host || '0.0.0.0' - this.port = config.port || 0 - this.excludeAirPlay = config.excludeAirPlay - this.nameScheme = config.nameScheme || '% Sonos' - this.packageJson = packageJson - this.my = my - switch (config.service) { - case undefined: - /* Falls through */ - case 'switch': - this.SpeakerService = Service.Switch - this.VolumeCharacteristic = config.brightness ? Characteristic.Brightness : Characteristic.Volume - break - case 'light': - this.SpeakerService = Service.Lightbulb - this.VolumeCharacteristic = Characteristic.Brightness - break - case 'speaker': - this.SpeakerService = Service.Speaker - this.VolumeCharacteristic = config.brightness ? Characteristic.Brightness : Characteristic.Volume - break - case 'fan': - this.SpeakerService = Service.Fan - this.VolumeCharacteristic = Characteristic.RotationSpeed - break - default: - this.log.error('config.json: warning: ignoring unknown service \'%s\'', config.service) - this.SpeakerService = Service.Switch - this.VolumeCharacteristic = Characteristic.Volume - break + // get zonesByName () { + // const zonesByName = {} + // for (const id in this.zones) { + // const zone = this.zones[id] + // zonesByName[zone.name] = zone + // } + // return zonesByName + // } + + // Parse config.json into this.config. + parseConfigJson (configJson) { + this.config = { + heartrate: 5, + host: '0.0.0.0', + nameScheme: '% Sonos', + port: 0, + resetTimeout: 500, // milliseconds + searchTimeout: 15, // seconds + subscriptionTimeout: 30, // minutes + timeout: 5, // seconds + SpeakerService: this.Service.hap.Switch, + VolumeCharacteristic: this.Characteristic.hap.Volume + } + const optionParser = new homebridgeLib.OptionParser(this.config, true) + optionParser.on('usageError', (message) => { + this.warn('config.json: %s', message) + }) + optionParser.stringKey('platform') + optionParser.stringKey('name') + optionParser.boolKey('alarms') + optionParser.boolKey('brightness') + optionParser.boolKey('excludeAirPlay') + optionParser.intKey('heartrate', 1, 60) + optionParser.stringKey('host') + optionParser.boolKey('leds') + optionParser.stringKey('nameScheme') + optionParser.intKey('port', 1, 65535) + optionParser.intKey('resetTimeout', 1, 60) + optionParser.intKey('searchTimeout', 1, 60) + optionParser.enumKey('service') + optionParser.enumKeyValue('service', 'fan', (value) => { + this.config.SpeakerService = this.Service.hap.Fan + this.config.VolumeCharacteristic = this.Characteristic.hap.RotationSpeed + }) + optionParser.enumKeyValue('service', 'light', (value) => { + this.config.SpeakerService = this.Service.hap.Lightbulb + this.config.VolumeCharacteristic = this.Characteristic.hap.Brightness + }) + optionParser.enumKeyValue('service', 'speaker', (value) => { + this.config.SpeakerService = this.Service.hap.Speaker + this.config.VolumeCharacteristic = this.Characteristic.hap.Volume + }) + optionParser.enumKeyValue('service', 'switch', (value) => { + this.config.SpeakerService = this.Service.hap.Switch + this.config.VolumeCharacteristic = this.Characteristic.hap.Volume + }) + optionParser.boolKey('speakers') + optionParser.intKey('subscriptionTimeout', 1, 1440) // minutes + optionParser.intKey('timeout', 1, 60) // seconds + optionParser.boolKey('tv') + try { + optionParser.parse(configJson) + if (this.config.brightness) { + if (this.config.service === 'speaker' && this.config.service === 'switch') { + this.config.VolumeCharacteristic = this.Characteristic.hap.Brightness + } else { + this.warn( + 'config.json: ignoring "brightness" for "service": "%s"', + this.config.service + ) + } + } + this.config.searchTimeout *= 1000 // seconds -> milliseconds + this.config.subscriptionTimeout *= 60 // minutes -> seconds + } catch (error) { + this.fatal(error) + } } - this.tv = config.tv || false - this.speakers = config.speakers || false - this.leds = config.leds || false - this.alarms = config.alarms || false - this.resetTimeout = config.resetTimeout || 500 // milliseconds - this.searchTimeout = config.searchTimeout || 15 // seconds - this.searchTimeout *= 1000 // milliseconds - this.subscriptionTimeout = config.subscriptionTimeout || 30 // minutes - this.subscriptionTimeout *= 60 // seconds - this.zpAccessories = {} + // init (beat) { + // this.debug('config: %j', this.config) + // this.debug('SpeakerService: %j', this.config.SpeakerService.UUID) + // this.debug('VolumeCharacteristic: %j', this.config.VolumeCharacteristic.UUID) + // } - var msg = util.format( - '%s v%s, node %s, homebridge v%s', packageJson.name, - packageJson.version, process.version, homebridgeVersion - ) - this.infoMessage = msg - this.log.info(this.infoMessage) - if (semver.clean(process.version) !== minVersion(packageJson.engines.node)) { - this.log.warn( - 'warning: not using recommended node version v%s LTS', - minVersion(packageJson.engines.node) - ) - } - if (homebridgeVersion !== minVersion(packageJson.engines.homebridge)) { - this.log.warn( - 'warning: not using recommended homebridge version v%s', - minVersion(packageJson.engines.homebridge) - ) + heartbeat (beat) { + if (beat % 60 === 30) { + for (const zonePlayerName of Object.keys(this.zonePlayersByName).sort()) { + const zpClient = this.zonePlayersByName[zonePlayerName] + if (zpClient.lastSeen == null) { + this.lostPlayer(zpClient.id, zpClient.zoneName) + continue + } + const log = (zpClient.lastSeen > 570 ? this.log : this.debug).bind(this) + log( + '%s: lastSeen: %js ago at %s, bootSeq: %j', zpClient.name, + zpClient.lastSeen, zpClient.address, zpClient.bootSeq + ) + if (zpClient.lastSeen > 600) { + this.lostPlayer(zpClient.id, zpClient.zoneName) + } + } + } } - this.log.debug('config.json: %j', config) - this.parser = new xml2js.Parser() + accessoryRestored (className, version, id, name, context) { + this.createZpClient(context.address, id).catch((error) => { + this.error(error) + }) + } - process.on('exit', () => { this.log.info('exit') }) - _homebridge.on('shutdown', this.onExit.bind(this)) - this.findPlayers() -} + async handleUpnpMessage (address, message) { + const id = message.usn.split(':')[1] + if (message.st != null) { + this.debug('upnp: found %s at %s', id, address) + } else { + // this.debug('upnp: %s is alive at %s', id, address) + } + try { + await this.createZpClient(address, id) + this.zpClients[id].handleUpnpMessage(address, message) + } catch (error) { + this.error(error) + } + } -// Called by homebridge to retrieve static list of ZpAccessories. -ZpPlatform.prototype.accessories = function (callback) { - const accessoryList = [] - // Allow for search to find all Sonos ZonePlayers. - setTimeout(() => { - for (const id in this.zpAccessories) { - const zpAccessory = this.zpAccessories[id] - if (zpAccessory.capabilities.airPlay && this.excludeAirPlay) { - this.log( - '%s: %s already exposed through AirPlay 2', - zpAccessory.name, zpAccessory.zp.modelName + // Create new zpClient. + async createZpClient (address, id) { + let zpClient = this.zpClients[id] + if (this.zpClients[id] == null) { + zpClient = new ZpClient({ + host: address, + id: id, + timeout: this.config.timeout + }) + zpClient.initialised = false + this.zpClients[id] = zpClient + zpClient.on('error', (error) => { + this.error('%s: %s', zpClient.address, error.message) + }) + zpClient.on('rebooted', (bootSeq) => { + this.warn( + '%s: rebooted %j -> %j', zpClient.address, bootSeq, zpClient.bootSeq ) - zpAccessory.getServices() - } else { - accessoryList.push(zpAccessory) - } + }) + zpClient.on('addressChanged', (oldAddress) => { + this.warn('%s: now at %s', oldAddress, zpClient.address) + }) + zpClient.on('event', (device, service, payload) => { + const f = `handle${device}${service}Event` + if (this[f] != null) { + this[f](zpClient, payload) + } + }) } - return callback(accessoryList) - }, this.searchTimeout) - const npmRegistry = new homebridgeLib.RestClient({ - host: 'registry.npmjs.org', - name: 'npm registry' - }) - npmRegistry.get('/' + packageJson.name).then((response) => { - if ( - response && response['dist-tags'] && - response['dist-tags'].latest !== packageJson.version - ) { - this.log.warn( - 'warning: latest version: %s v%s', packageJson.name, - response['dist-tags'].latest - ) + if (!zpClient.initialised) { + if (zpClient.initialising) { + await events.once(zpClient, 'init') + if (zpClient.name == null) { + throw new Error('cannot initialise zone player') + } + return zpClient + } + try { + zpClient.initialising = true + this.unInitialisedZpClients++ + this.debug('%s: %s: probing (%d jobs)...', address, id, this.unInitialisedZpClients) + await zpClient.init() + this.debug( + '%s: %s: %s: %s (%s) v%s', address, id, zpClient.name, + zpClient.modelName, zpClient.modelNumber, zpClient.version + ) + if (this.zonePlayers[zpClient.id] == null) { + this.topologyChanged = true + this.zonePlayers[zpClient.id] = zpClient + } + if (this.zones[zpClient.zoneName] == null) { + this.topologyChanged = true + this.zones[zpClient.zoneName] = { + master: zpClient.role === 'master' ? zpClient.name : null, + name: zpClient.zoneName, + zonePlayers: {} + } + } + if ( + zpClient.role === 'master' && + this.zones[zpClient.zoneName].master !== zpClient.id + ) { + this.topologyChanged = true + this.zones[zpClient.zoneName].master = zpClient.name + } + if (this.zones[zpClient.zoneName].zonePlayers[zpClient.name] == null) { + this.topologyChanged = true + this.zones[zpClient.zoneName].zonePlayers[zpClient.name] = zpClient + } + await this.parseZones(zpClient.zones, zpClient.name) + await zpClient.open(this.zpListener) + await zpClient.subscribe('/ZoneGroupTopology/Event') + zpClient.initialised = true + delete zpClient.initialising + this.unInitialisedZpClients-- + this.debug( + '%s: %s: probing done (%d jobs remaining)', address, id, + this.unInitialisedZpClients + ) + zpClient.emit('init') + } catch (error) { + this.error(error) + delete zpClient.initialising + this.unInitialisedZpClients-- + this.debug( + '%s: %s: probing failed (%d jobs remaining)', address, id, + this.unInitialisedZpClients + ) + zpClient.emit('init') + throw error + } } - }).catch((err) => { - this.log.error('%s', err) - }) -} - -// Create listener to receive notifications from Sonos ZonePlayers. -ZpPlatform.prototype.listen = function (ipaddress) { - if (this.host === '0.0.0.0') { - this.host = findMyAddressFor(ipaddress) + return zpClient } - if (this.host === '0.0.0.0') { - this.log.error('cannot find network interface to zoneplayers') - } - this.server = http.createServer((request, response) => { - let buffer = '' - request.on('data', (data) => { - buffer += data - }) - request.on('end', () => { - request.body = buffer - // this.log.debug('listener: %s %s', request.method, request.url) - if (request.method === 'GET' && request.url === '/notify') { - // Provide an easy way to check that listener is reachable. - response.writeHead(200, { 'Content-Type': 'text/plain' }) - response.write(this.infoMessage) - } else if (request.method === 'NOTIFY') { - const array = request.url.split('/') - const accessory = this.zpAccessories[array[2]] - const service = array[4] != null ? array[4] : array[3] - if (array[1] === 'notify' && accessory !== null && service !== null) { - this.parser.parseString(request.body.toString(), (err, json) => { - if (err) { - return - } - const properties = json['e:propertyset']['e:property'] - let obj = {} - for (const prop of properties) { - for (const key in prop) { - obj[key] = prop[key][0] - } - } - accessory.emit(service, obj) - }) + + async parseZones (zones) { + const jobs = [] + for (const zoneName in zones) { + const zone = zones[zoneName] + for (const zonePlayerName in zone.zonePlayers) { + const zonePlayer = zone.zonePlayers[zonePlayerName] + if (this.zpClients[zonePlayer.id] == null) { + jobs.push( + this.createZpClient(zonePlayer.address, zonePlayer.id) + .catch((error) => { + this.error('%s: %j', zonePlayer.address, error) + }) + ) } } - response.end() - }) - }) - this.server.listen(this.port, this.host, () => { - this.callbackUrl = 'http://' + this.server.address().address + ':' + - this.server.address().port + '/notify' - this.log.debug('listening on %s', this.callbackUrl) - }) -} + } + for (const job of jobs) { + await job + } + if (this.unInitialisedZpClients === 0 && this.topologyChanged) { + this.topologyChanged = false + this.logTopology() + } + } -ZpPlatform.prototype.findPlayers = function () { - SonosModule.search({ timeout: this.searchTimeout }, (zp, model) => { - if (this.server == null) { - this.listen(zp.host) + lostPlayer (id, zoneName) { + const zpClient = this.zpClients[id] + if (zpClient == null || this.zonePlayers[id] == null) { + return } - const deviceProperties = new SonosModule.Services.DeviceProperties(zp.host, zp.port) - const zoneGroupTopology = new SonosModule.Services.ZoneGroupTopology(zp.host, zp.port) - const alarmClock = new SonosModule.Services.AlarmClock(zp.host, zp.port) - zp.model = model - zp.deviceDescription((error, data) => { - if (error) { - this.log.error('%s: error %s', zp.host, error) - } else { - // this.log.debug('%s: device description: %j', zp.host, data) - zp.id = data.UDN.substr(5) - zp.modelName = data.modelName + ' (' + model + ')' - deviceProperties.GetZoneAttributes({}, (err, attrs) => { - if (err) { - this.log.error('%s: error %s', zp.host, err) - } else { - zp.zone = attrs.CurrentZoneName - // this.log.debug('%s: zone attrs %j', zp.zone, attrs) - deviceProperties.GetZoneInfo({}, (err, info) => { - if (err) { - this.log.error('%s: error %s', zp.zone, err) - } else { - // this.log.debug('%s: info %j', zp.zone, info) - // zp.id = 'RINCON_' + info.MACAddress.replace(/:/g, '') + - // ('00000' + zp.port).substr(-5, 5) - zp.version = info.DisplaySoftwareVersion - zoneGroupTopology.GetZoneGroupAttributes({}, (err, attrs) => { - if (err) { - this.log.error('%s: error %s', zp.host, err) - } else { - // this.log.debug('%s: zone group attrs %j', zp.zone, attrs) - if (attrs.CurrentZoneGroupID === '') { - this.log.debug( - '%s: %s: %s v%s in %s (slave)', - zp.host, zp.id, zp.modelName, zp.version, zp.zone - ) - } else { - this.log.debug( - '%s: %s: %s v%s in %s', - zp.host, zp.id, zp.modelName, zp.version, zp.zone - ) - zp.alarms = {} - if (this.alarms) { - alarmClock.ListAlarms((err, alarmClock) => { - if (err) { - this.log.error('%s: error %s', zp.zone, err) - } else { - for (const alarm of alarmClock.CurrentAlarmList) { - if (alarm && alarm.RoomUUID === zp.id) { - zp.alarms[alarm.ID] = alarm - } - } - } - }) - } - const accessory = new ZpAccessory(this, zp) - this.zpAccessories[zp.id] = accessory - } - } - }) - } - }) - } - }) + const zonePlayerName = zpClient.name + this.debug('%s: %s vanished from %s', zonePlayerName, zpClient.id, zoneName) + if (this.zones[zoneName] != null) { + delete this.zones[zoneName].zonePlayers[zonePlayerName] + if (Object.keys(this.zones[zoneName].zonePlayers).length === 0) { + this.debug('%s: vanished', zoneName) + delete this.zones[zoneName] } + } + delete this.zonePlayers[id] + zpClient.close().catch((error) => { + this.error('%s: %s', zpClient.address, error) }) - }) -} + this.logTopology() + } -// Return coordinator for group. -ZpPlatform.prototype.groupCoordinator = function (group) { - for (const id in this.zpAccessories) { - const accessory = this.zpAccessories[id] - if (accessory.isCoordinator && accessory.group === group) { - return accessory + handleZonePlayerZoneGroupTopologyEvent (zpClient, zoneGroupState) { + // this.debug('%s: ZoneGroupTopologyEvent', zpClient.address) + if (zoneGroupState.vanishedDevices != null) { + for (const zonePlayer of zoneGroupState.vanishedDevices) { + this.lostPlayer(zonePlayer.uuid, zonePlayer.zoneName) + } + } + if (zoneGroupState.zoneGroups != null) { + this.parseZones(zoneGroupState.zoneGroups).catch((error) => { + this.error(error) + }) } } - return null -} -// Return array of members for group. -ZpPlatform.prototype.groupMembers = function (group) { - const members = [] - for (const id in this.zpAccessories) { - const accessory = this.zpAccessories[id] - if (accessory.coordinator !== accessory && accessory.group === group) { - members.push(accessory) + logTopology () { + this.log('found %d zones', Object.keys(this.zones).length) + for (const zoneName of Object.keys(this.zones).sort()) { + const zone = this.zones[zoneName] + const master = zone.zonePlayers[zone.master] + this.log( + ' %s:%s zone', zone.name, + master.homeTheatre ? ' home theatre' + : master.stereoPair ? ' stereo pair' : '' + ) + if (this.zpAccessories[master.id] == null) { + const a = master.modelName.split(' ') + const params = { + name: this.config.nameScheme.replace('%', master.zoneName), + id: master.id, + address: master.address, + manufacturer: a[0], + model: a[1] + ' (' + master.modelNumber + ')', + firmware: master.version, + category: this.Accessory.hap.Categories.SPEAKER + } + this.zpAccessories[master.id] = new ZpAccessory(this, params) + } + } + this.log('found %d zone players', Object.keys(this.zonePlayers).length) + for (const zonePlayerName of Object.keys(this.zonePlayersByName).sort()) { + const zonePlayer = this.zonePlayersByName[zonePlayerName] + let caps = zonePlayer.role + caps += zonePlayer.airPlay ? ', airPlay' : '' + caps += zonePlayer.audioIn ? ', audioIn' : '' + caps += zonePlayer.tvIn ? ', tvIn' : '' + this.log( + ' %s: %s (%s) (%s)', zonePlayer.name, + zonePlayer.modelName, zonePlayer.modelNumber, caps + ) } } - return members -} -ZpPlatform.prototype.setPlatformCoordinator = function (group) { - this.coordinator = group - for (const id in this.zpAccessories) { - const accessory = this.zpAccessories[id] - accessory.groupService.updateCharacteristic( - my.Characteristic.SonosCoordinator, accessory === group - ) + // Return coordinator for group. + groupCoordinator (groupId) { + for (const id in this.zpAccessories) { + const accessory = this.zpAccessories[id] + if (accessory.isCoordinator && accessory.groupId === groupId) { + return accessory + } + } + return null } -} -ZpPlatform.prototype.onExit = function () { - if (this.shuttingDown) { - return + // Return array of members for group. + groupMembers (groupId) { + const members = [] + for (const id in this.zpAccessories) { + const accessory = this.zpAccessories[id] + // if (accessory.coordinator !== accessory && accessory.groupId === groupId) { + if (!accessory.isCoordinator && accessory.groupId === groupId) { + members.push(accessory) + } + } + return members } - this.shuttingDown = true - this.log.info('cleaning up...') - for (const id in this.zpAccessories) { - const accessory = this.zpAccessories[id] - accessory.onExit() + + // Set coordinator zpAccessory as default coordinator + setPlatformCoordinator (coordinator) { + this.coordinator = coordinator + for (const id in this.zpAccessories) { + const accessory = this.zpAccessories[id] + const service = accessory.sonosService + service.values.sonosCoordinator = accessory === coordinator + } } } + +module.exports = ZpPlatform diff --git a/lib/ZpService.js b/lib/ZpService.js new file mode 100644 index 0000000..d156a1d --- /dev/null +++ b/lib/ZpService.js @@ -0,0 +1,561 @@ +// homebridge-zp/lib/ZpAccessory.js +// Copyright © 2016-2019 Erik Baauw. All rights reserved. +// +// Homebridge plugin for Sonos ZonePlayer. + +'use strict' + +const homebridgeLib = require('homebridge-lib') + +class ZpService extends homebridgeLib.ServiceDelegate { + constructor (zpAccessory, params) { + super(zpAccessory, params) + this.zpAccessory = zpAccessory + this.zpClient = this.zpAccessory.zpClient + this.zpClient.on('event', (device, service, payload) => { + try { + const f = `handle${device}${service}Event` + if (this[f] != null) { + this[f](payload) + } + } catch (error) { + this.error(error) + } + }) + } + + static get Sonos () { return Sonos } + static get Speaker () { return Speaker } + static get Alarm () { return Alarm } + static get Led () { return Led } +} + +class Sonos extends ZpService { + constructor (zpAccessory, params = {}) { + params.name = zpAccessory.platform.config.nameScheme.replace('%', zpAccessory.zpClient.zoneName) + params.Service = zpAccessory.platform.config.SpeakerService + params.subtype = 'group' + super(zpAccessory, params) + this.addCharacteristic({ + key: 'on', + Characteristic: this.Characteristic.hap.On, + setter: async (value) => { + try { + if (value === this.values.on) { + return + } + if ( + value && + this.values.currentTransportActions.includes('Play') && + this.values.currentTrack !== 'TV' + ) { + await this.zpAccessory.coordinator.zpClient.play() + } else if ( + !value && + this.values.currentTransportActions.includes('Pause') + ) { + await this.zpAccessory.coordinator.zpClient.pause() + } else if ( + !value && + this.values.currentTransportActions.includes('Stop') + ) { + await this.zpAccessory.coordinator.zpClient.stop() + } else { + setTimeout(() => { + this.values.on = !value + }, this.platform.config.resetTimeout) + } + } catch (error) { + this.error(error) + } + } + }) + this.addCharacteristic({ + key: 'volume', + Characteristic: this.platform.config.VolumeCharacteristic, + unit: '%', + setter: async (value) => { + try { + await this.zpAccessory.coordinator.zpClient.setGroupVolume(value) + } catch (error) { + this.error(error) + } + } + }) + this.addCharacteristic({ + key: 'changeVolume', + Characteristic: this.Characteristic.my.ChangeVolume, + value: 0, + setter: async (value) => { + try { + const newVolume = Math.min(Math.max(this.values.volume + value, 0), 100) + await this.zpAccessory.coordinator.zpClient.setGroupVolume(newVolume) + } catch (error) { + this.error(error) + } + setTimeout(() => { + this.values.changeVolume = 0 + }, this.platform.config.resetTimeout) + } + }) + this.addCharacteristic({ + key: 'mute', + Characteristic: this.Characteristic.hap.Mute, + setter: async (value) => { + try { + await this.zpAccessory.coordinator.zpClient.setGroupMute(value) + } catch (error) { + this.error(error) + } + } + }) + this.addCharacteristic({ + key: 'currentTrack', + Characteristic: this.Characteristic.my.CurrentTrack + }) + this.addCharacteristic({ + key: 'changeTrack', + Characteristic: this.Characteristic.my.ChangeTrack, + value: 0, + setter: async (value) => { + try { + if ( + value > 0 && + this.values.currentTransportActions.includes('Next') + ) { + await this.zpAccessory.coordinator.zpClient.next() + } else if ( + value < 0 && + this.values.currentTransportActions.includes('Previous') + ) { + await this.zpAccessory.coordinator.zpClient.previous() + } + } catch (error) { + this.error(error) + } + setTimeout(() => { + this.values.changeTrack = 0 + }, this.platform.config.resetTimeout) + } + }) + if (this.zpClient.tvIn) { + this.addCharacteristic({ + key: 'tv', + Characteristic: this.Characteristic.my.Tv + }) + } + this.addCharacteristic({ + key: 'sonosGroup', + Characteristic: this.Characteristic.my.SonosGroup + }) + this.addCharacteristic({ + key: 'sonosCoordinator', + Characteristic: this.Characteristic.my.SonosCoordinator, + value: false, + setter: async (value) => { + try { + if (value) { + this.zpAccessory.becomePlatformCoordinator() + } else { + if (this.zpAccessory.speakerService != null) { + this.zpAccessory.speakerService.values.on = false + } + this.platform.coordinator = null + } + } catch (error) { + this.error(error) + } + } + }) + zpAccessory.once('groupInitialised', () => { + this.zpClient.subscribe('/MediaRenderer/AVTransport/Event') + .catch((error) => { + this.error(error) + }) + this.zpClient.subscribe('/MediaRenderer/GroupRenderingControl/Event') + .catch((error) => { + this.error(error) + }) + if (this.zpClient.tvIn) { + this.zpClient.subscribe('/HTControl/Event') + .catch((error) => { + this.error(error) + }) + } + }) + } + + handleMediaRendererAVTransportEvent (payload) { + if ( + payload.lastChange == null || + !Array.isArray(payload.lastChange) || + payload.lastChange[0] == null + ) { + return + } + const event = payload.lastChange[0] + // this.debug('AVTransport event %j', event) + let on + let track + let currentTransportActions + const state = event.transportState + if (state != null && this.values.currentTrack !== 'TV') { + if (state === 'PLAYING') { + on = true + } else if (state === 'PAUSED' || state === 'STOPPED') { + on = false + } + } + const meta = event.currentTrackMetaData + // this.debug('currentTrackMetaData: %j', meta) + if (meta != null && meta.res != null) { + switch (meta.res._.split(':')[0]) { + case 'x-rincon-stream': // Line in input. + track = meta.title + break + case 'x-sonos-htastream': // SPDIF TV input. + track = 'TV' + on = meta.streamInfo !== 0 // 0: no input; 2: stereo; 18: Dolby 5.1 + break + case 'x-sonosapi-vli': // Airplay2. + track = 'Airplay2' + break + case 'aac': // Radio stream (e.g. DI.fm) + case 'x-sonosapi-stream': // Radio stream. + case 'x-rincon-mp3radio': // AirTunes (by homebridge-zp). + track = meta.streamContent // info + if (track === '') { + if (event.enqueuedTransportUriMetaData != null) { + track = event.enqueuedTransportUriMetaData.title // station + } + } + break + case 'x-file-cifs': // Library song. + case 'x-sonos-http': // See issue #44. + case 'http': // Song on iDevice. + case 'https': // Apple Music, see issue #68 + case 'x-sonos-spotify': // Spotify song. + if (meta.title != null) { + track = meta.title // song + } + break + case 'x-sonosapi-hls': // ?? + case 'x-sonosapi-hls-static': // e.g. Amazon Music + // Skip! update will arrive in subsequent CurrentTrackMetaData events + // and will be handled by default case + break + case 'x-rincon-buzzer': + track = 'Sonos Chime' + break + default: + if (meta.title != null) { + track = meta.title // song + } else { + track = '' + } + break + } + } + if (event.currentTransportActions != null && this.values.currentTrack !== 'TV') { + currentTransportActions = event.currentTransportActions.split(', ') + if (currentTransportActions.length === 1) { + track = '' + } + } + if (on != null) { + this.values.on = on + for (const member of this.zpAccessory.members()) { + member.sonosService.values.on = this.values.on + } + } + if ( + track != null && + track !== 'ZPSTR_CONNECTING' && track !== 'ZPSTR_BUFFERING' + ) { + this.values.currentTrack = track + for (const member of this.zpAccessory.members()) { + member.sonosService.values.currentTrack = this.values.currentTrack + } + } + if (this.zpClient.tvIn && on != null) { + const tv = on && track === 'TV' + if (tv !== this.values.tv) { + if (tv || this.values.tv == null) { + this.values.tv = tv + } else { + this.tvTimer = setTimeout(() => { + this.tvTimer = null + this.values.tv = tv + }, 10000) + } + } else if (this.tvTimer != null) { + clearTimeout(this.tvTimer) + this.tvTimer = null + } + } + if (currentTransportActions != null) { + this.values.currentTransportActions = currentTransportActions + for (const member of this.zpAccessory.members()) { + member.sonosService.values.currentTransportActions = + this.values.currentTransportActions + } + } + } + + handleMediaRendererGroupRenderingControlEvent (event) { + // this.debug('GroupRenderingControl event %j', event) + if (event.groupVolumeChangeable === 1) { + this.zpAccessory.coordinator = this.zpAccessory + this.zpAccessory.leaving = false + } + if (event.groupVolume != null) { + this.values.volume = event.groupVolume + for (const member of this.zpAccessory.members()) { + member.sonosService.values.volume = this.values.volume + } + } + if (event.groupMute != null) { + this.values.mute = !!event.groupMute + for (const member of this.zpAccessory.members()) { + member.sonosService.values.mute = this.values.mute + } + } + } + + handleZonePlayerHTControlEvent (event) { + this.debug('HTControl event: %j', event) + } +} + +class Speaker extends ZpService { + constructor (zpAccessory, params = {}) { + params.name = zpAccessory.zpClient.zoneName + ' Speakers' + params.Service = zpAccessory.platform.config.SpeakerService + params.subtype = 'zone' + super(zpAccessory, params) + this.addCharacteristic({ + key: 'on', + Characteristic: this.Characteristic.hap.On, + setter: async (value) => { + try { + if (value === this.values.on) { + return + } + this.values.on = value + if (value) { + const coordinator = this.platform.coordinator + if (coordinator) { + return this.zpClient.setAvTransportGroup(coordinator.zpClient.id) + } + return this.zpAccessory.becomePlatformCoordinator() + } + if (this.platform.coordinator === this.zpAccessory) { + this.platform.coordinator = null + } + if (this.isCoordinator) { + const newCoordinator = this.zpAccessory.members()[0] + if (newCoordinator != null) { + newCoordinator.becomePlatformCoordinator() + this.zpAccessory.leaving = true + return this.zpClient.delegateGroupCoordinationTo( + newCoordinator.zpClient.id + ) + } + } + this.zpAccessory.leaving = true + return this.zpClient.becomeCoordinatorOfStandaloneGroup() + } catch (error) { + this.error(error) + } + } + }) + this.addCharacteristic({ + key: 'volume', + Characteristic: this.platform.config.VolumeCharacteristic, + unit: '%', + setter: this.zpClient.setVolume.bind(this.zpClient) + }) + this.addCharacteristic({ + key: 'changeVolume', + Characteristic: this.Characteristic.my.ChangeVolume, + value: 0, + setter: async (value) => { + try { + const newVolume = Math.min(Math.max(this.values.volume + value, 0), 100) + await this.zpClient.setVolume(newVolume) + } catch (error) { + this.error(error) + } + setTimeout(() => { + this.values.changeVolume = 0 + }, this.platform.config.resetTimeout) + } + }) + this.addCharacteristic({ + key: 'mute', + Characteristic: this.Characteristic.hap.Mute, + setter: this.zpClient.setMute.bind(this.zpClient) + }) + this.addCharacteristic({ + key: 'loudness', + Characteristic: this.Characteristic.my.Loudness, + setter: this.zpClient.setLoudness.bind(this.zpClient) + }) + this.addCharacteristic({ + key: 'bass', + Characteristic: this.Characteristic.my.Bass, + setter: this.zpClient.setBass.bind(this.zpClient) + }) + this.addCharacteristic({ + key: 'treble', + Characteristic: this.Characteristic.my.Treble, + setter: this.zpClient.setTreble.bind(this.zpClient) + }) + if (this.zpClient.balance) { + this.addCharacteristic({ + key: 'balance', + Characteristic: this.Characteristic.my.Balance, + unit: '%', + setter: this.zpClient.setBalance.bind(this.zpClient) + }) + } + if (this.zpClient.tvIn) { + this.addCharacteristic({ + key: 'nightSound', + Characteristic: this.Characteristic.my.NightSound, + setter: this.zpClient.setNightSound.bind(this.zpClient) + }) + this.addCharacteristic({ + key: 'speechEnhancement', + Characteristic: this.Characteristic.my.SpeechEnhancement, + setter: this.zpClient.setSpeechEnhancement.bind(this.zpClient) + }) + } + this.zpClient.subscribe('/MediaRenderer/RenderingControl/Event') + .catch((error) => { + this.error(error) + }) + } + + handleMediaRendererRenderingControlEvent (payload) { + if ( + payload.lastChange == null || + !Array.isArray(payload.lastChange) || + payload.lastChange[0] == null + ) { + return + } + const event = payload.lastChange[0] + // this.debug('RenderingControl event: %j', event) + if (event.volume != null && event.volume.master != null) { + this.values.volume = event.volume.master + if ( + this.zpClient.balance && + event.volume.lf != null && event.volume.rf != null + ) { + this.values.balance = event.volume.rf - event.volume.lf + } + } + if (event.mute != null && event.mute.master != null) { + this.values.mute = !!event.mute.master + } + if (event.loudness != null && event.loudness.master != null) { + this.values.loudness = !!event.loudness.master + } + if (event.bass != null) { + this.values.bass = event.bass + } + if (event.treble != null) { + this.values.treble = event.treble + } + if (event.nightMode != null) { + this.values.nightSound = !!event.nightMode + } + if (event.dialogLevel != null) { + this.values.speechEnhancement = !!event.dialogLevel + } + } +} + +class Alarm extends ZpService { + constructor (zpAccessory, alarm) { + const params = { + id: alarm.id, + name: zpAccessory.zpClient.zoneName + ' Alarm ' + alarm.id, + Service: zpAccessory.Service.hap.Switch, + subtype: 'alarm' + alarm.id + } + super(zpAccessory, params) + this.addCharacteristic({ + key: 'on', + Characteristic: this.Characteristic.hap.On, + setter: async (value) => { + const alarm = Object.assign({}, this._alarm) + alarm.enabled = value ? 1 : 0 + return this.zpClient.updateAlarm(alarm) + } + }) + this.addCharacteristic({ + 'key': 'currentTrack', + Characteristic: this.Characteristic.my.CurrentTrack + }) + this.addCharacteristic({ + 'key': 'time', + Characteristic: this.Characteristic.my.Time + }) + this.alarm = alarm + } + + get alarm () { return this._alarm } + set alarm (alarm) { + this._alarm = alarm + this.values.enabled = alarm.enabled === 1 + this.values.currentTrack = alarm.programUri === 'x-rincon-buzzer:0' + ? 'Sonos Chime' + : alarm.programMetaData != null && alarm.programMetaData.title != null + ? alarm.programMetaData.title + : 'unknown' + this.values.time = alarm.startTime + } +} + +class Led extends ZpService { + constructor (zpAccessory, params = {}) { + params.name = zpAccessory.zpClient.zoneName + ' LED' + params.Service = zpAccessory.Service.hap.Lightbulb + params.subtype = 'led' + super(zpAccessory, params) + this.addCharacteristic({ + key: 'on', + Characteristic: this.Characteristic.hap.On, + // getter: this.zpClient.getLedState.bind(this.zpClient), + setter: this.zpClient.setLedState.bind(this.zpClient) + }) + this.addCharacteristic({ + key: 'locked', + Characteristic: this.Characteristic.hap.LockPhysicalControls, + // getter: async (value) => { + // return (await this.zpClient.getButtonLockState()) + // ? this.Characteristic.hap.LockPhysicalControls.CONTROL_LOCK_ENABLED + // : this.Characteristic.hap.LockPhysicalControls.CONTROL_LOCK_DISABLED + // }, + setter: this.zpClient.setButtonLockState.bind(this.zpClient) + }) + this.zpAccessory.on('heartbeat', async (beat) => { + try { + if (beat % this.platform.config.heartrate === 0) { + if (!this.zpAccessory.blinking) { + this.values.on = await this.zpClient.getLedState() + } + this.values.locked = (await this.zpClient.getButtonLockState()) + ? this.Characteristic.hap.LockPhysicalControls.CONTROL_LOCK_ENABLED + : this.Characteristic.hap.LockPhysicalControls.CONTROL_LOCK_DISABLED + } + } catch (error) { + this.error(error) + } + }) + } +} + +module.exports = ZpService