Skip to content

Commit c87a83a

Browse files
committed
feat(homebridge): panic buttons for burglar and fire
#83
1 parent 60773a2 commit c87a83a

File tree

10 files changed

+242
-24
lines changed

10 files changed

+242
-24
lines changed

api/location.ts

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,13 @@ import {
1111
skip,
1212
take
1313
} from 'rxjs/operators'
14-
import { delay, logError, logInfo } from './util'
14+
import { delay, generateRandomId, logError, logInfo } from './util'
1515
import {
16+
AccountMonitoringStatus,
1617
AlarmMode,
1718
AssetSession,
1819
deviceTypesWithVolume,
20+
DispatchSignalType,
1921
LocationEvent,
2022
MessageDataType,
2123
MessageType,
@@ -25,7 +27,7 @@ import {
2527
TicketAsset,
2628
UserLocation
2729
} from './ring-types'
28-
import { clientApi, RingRestClient } from './rest-client'
30+
import { appApi, clientApi, RingRestClient } from './rest-client'
2931
import { RingCamera } from './ring-camera'
3032

3133
const deviceListMessageType = 'DeviceInfoDocGetList'
@@ -243,8 +245,7 @@ export class Location {
243245
host: string
244246
ticket: string
245247
}>({
246-
url:
247-
'https://app.ring.com/api/v1/clap/tickets?locationID=' + this.locationId
248+
url: appApi('clap/tickets?locationID=' + this.locationId)
248249
})
249250
this.assets = assets
250251
this.receivedAssetDeviceLists.length = 0
@@ -457,4 +458,45 @@ export class Location {
457458
)
458459
})
459460
}
461+
462+
getAccountMonitoringStatus() {
463+
return this.restClient.request<AccountMonitoringStatus>({
464+
url: appApi('rs/monitoring/accounts/' + this.locationId)
465+
})
466+
}
467+
468+
private triggerAlarm(signalType: DispatchSignalType) {
469+
const now = Date.now(),
470+
alarmSessionUuid = generateRandomId(),
471+
baseStationAsset =
472+
this.assets && this.assets.find(x => x.kind === 'base_station_v1')
473+
474+
if (!baseStationAsset) {
475+
throw new Error(
476+
'Cannot dispatch panic events without an alarm base station'
477+
)
478+
}
479+
480+
return this.restClient.request<AccountMonitoringStatus>({
481+
method: 'POST',
482+
url: appApi(
483+
`rs/monitoring/accounts/${this.locationId}/assets/${baseStationAsset.uuid}/userAlarm`
484+
),
485+
json: true,
486+
data: {
487+
alarmSessionUuid,
488+
currentTsMs: now,
489+
eventOccurredTime: now,
490+
signalType
491+
}
492+
})
493+
}
494+
495+
triggerBurglarAlarm() {
496+
return this.triggerAlarm(DispatchSignalType.Burglar)
497+
}
498+
499+
triggerFireAlarm() {
500+
return this.triggerAlarm(DispatchSignalType.Fire)
501+
}
460502
}

api/rest-client.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,18 @@ const ringErrorCodes: { [code: number]: string } = {
1111
7063: 'MAINTENANCE'
1212
},
1313
clientApiBaseUrl = 'https://api.ring.com/clients_api/',
14+
appApiBaseUrl = 'https://app.ring.com/api/v1/',
1415
apiVersion = 11,
1516
hardwareId = generateRandomId()
1617

1718
export function clientApi(path: string) {
1819
return clientApiBaseUrl + path
1920
}
2021

22+
export function appApi(path: string) {
23+
return appApiBaseUrl + path
24+
}
25+
2126
export interface ExtendedResponse {
2227
responseTimestamp: number
2328
}

api/ring-types.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,18 @@ export type AlarmState =
140140
| 'burglar-accelerated-alarm' // Alarming - Police Response Requested
141141
| 'fire-accelerated-alarm' // Alarming - Fire Department Response Requested
142142

143+
export const allAlarmStates: AlarmState[] = [
144+
'burglar-alarm',
145+
'entry-delay',
146+
'fire-alarm',
147+
'co-alarm',
148+
'panic',
149+
'user-verified-burglar-alarm',
150+
'user-verified-co-or-fire-alarm',
151+
'burglar-accelerated-alarm',
152+
'fire-accelerated-alarm'
153+
]
154+
143155
export interface RingDeviceData {
144156
zid: string
145157
name: string
@@ -466,3 +478,27 @@ export interface SessionResponse {
466478
tfa_phone_number: null | string
467479
}
468480
}
481+
482+
export interface AccountMonitoringStatus {
483+
accountUuid: string
484+
externalServiceConfigType: 'rrms' | string
485+
accountState: 'PROFESSIONAL' | string
486+
eligibleForDispatch: boolean
487+
addressComplete: boolean
488+
contactsComplete: boolean
489+
codewordComplete: boolean
490+
alarmSignalSent: boolean
491+
professionallyMonitored: boolean
492+
userAcceptDispatch: boolean
493+
installationDate: number
494+
externalId: string
495+
vrRequired: false
496+
vrUserOptIn: false
497+
cmsMonitoringType: 'full' | string
498+
dispatchSetupComplete: boolean
499+
}
500+
501+
export enum DispatchSignalType {
502+
Burglar = 'user-verified-burglar-xa',
503+
Fire = 'user-verified-fire-xa'
504+
}

homebridge/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ Option | Default | Explanation
8282
`hideCameraMotionSensor` | `false` | If `true`, hides the motion sensor for Ring cameras in HomeKit.
8383
`hideCameraSirenSwitch` | `false` | If `true`, hides the siren switch for Ring cameras in HomeKit.
8484
`hideAlarmSirenSwitch` | `false` | If you have a Ring Alarm, you will see both the alarm and a "Siren" switch in HomeKit. The siren switch can sometimes get triggered by Siri commands by accident, which is loud and annoying. Set this option to `true` to hide the siren switch.
85+
`showPanicButtons` | `false` | Creates a new `Panic Buttons` device in HomeKit with `Burglar Alarm` and `Fire Alarm` switches. **Use these at your own risk. I do not guarantee functionality in case of emergency, nor do I take responsibility for any false alarms**. These function just like the SOS sliders in the Ring app.
8586
`cameraStatusPollingSeconds` | `20` | How frequently to poll for updates to your cameras. Information like light/siren status do not update in real time and need to be requested periodically.
8687
`cameraDingsPollingSeconds` | `2` | How frequently to poll for new events from your cameras. These include motion and doorbell presses.
8788
`locationIds` | All Locations | Use this option if you only want a subset of your locations to appear in HomeKit. If this option is not included, all of your locations will be added to HomeKit (which is what most users will want to do).
@@ -154,6 +155,11 @@ If you are having issues with your cameras in the Home app, please see the [Came
154155
* Hue/Sat/Color Temp are _possible_, but currently not supported.
155156
Please open an issue if you have a device that you would be able to
156157
test these on.
158+
* Panic Buttons
159+
* These can be added by setting `showPanicButtons: true` in your config
160+
* Creates `Burglar Alarm` and `Fire Alarm` switches in a new `Panic Buttons` device in HomeKit
161+
* Use these at your own risk. **I do not guarantee functionality in case of emergency, nor do I take responsibility for any false alarms**
162+
* If either switch is turned on, you will receive a call from Ring monitoring to verify the emergency, and then authorities will be dispatched
157163

158164
### Alarm Modes
159165

homebridge/base-accessory.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,24 @@ export abstract class BaseAccessory<T extends RingDevice | RingCamera> {
1616
this.pruneUnusedServices()
1717
}
1818

19-
getService(serviceType: HAP.Service, name = this.device.name) {
19+
getService(
20+
serviceType: HAP.Service,
21+
name = this.device.name,
22+
subType?: string
23+
) {
24+
if (typeof (serviceType as any) === 'object') {
25+
return serviceType
26+
}
27+
2028
if (process.env.RING_DEBUG) {
2129
name = 'TEST ' + name
2230
}
2331

24-
const service =
25-
this.accessory.getService(serviceType) ||
26-
this.accessory.addService(serviceType, name)
32+
const existingService = subType
33+
? this.accessory.getServiceByUUIDAndSubType(serviceType, subType)
34+
: this.accessory.getService(serviceType),
35+
service =
36+
existingService || this.accessory.addService(serviceType, name, subType)
2737

2838
if (!this.servicesInUse.includes(service)) {
2939
this.servicesInUse.push(service)

homebridge/config.schema.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@
5050
"type": "boolean",
5151
"description": "Hides switch that allows you to turn on the siren of the Ring Alarm. Enable this is your Siri commands keep setting off the siren and you don't care about having the switch"
5252
},
53+
"showPanicButtons": {
54+
"title": "Show Panic Buttons",
55+
"type": "boolean",
56+
"description": "Creates a new `Panic Buttons` device in HomeKit with `Burglar Alarm` and `Fire Alarm` switches. **Use these at your own risk. I do not guarantee functionality in case of emergency, nor do I take responsibility for any false alarms**. These function just like the SOS sliders in the Ring app."
57+
},
5358
"beamDurationSeconds": {
5459
"title": "Ring Smart Lighting Timer",
5560
"type": "integer",
@@ -120,6 +125,7 @@
120125
"hideCameraMotionSensor",
121126
"hideCameraSirenSwitch",
122127
"hideAlarmSirenSwitch",
128+
"showPanicButtons",
123129
"beamDurationSeconds",
124130
"cameraStatusPollingSeconds",
125131
"cameraDingsPollingSeconds",

homebridge/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,5 @@ export interface RingPlatformConfig extends RingApiOptions {
88
hideCameraMotionSensor?: boolean
99
hideCameraSirenSwitch?: boolean
1010
hideAlarmSirenSwitch?: boolean
11+
showPanicButtons?: boolean
1112
}

homebridge/panic-buttons.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { RingDevice, RingDeviceData, AlarmState } from '../api'
2+
import { HAP, hap } from './hap'
3+
import { RingPlatformConfig } from './config'
4+
import { BaseAccessory } from './base-accessory'
5+
6+
const burglarStates: AlarmState[] = [
7+
'burglar-alarm',
8+
'user-verified-burglar-alarm',
9+
'burglar-accelerated-alarm'
10+
],
11+
fireStates: AlarmState[] = [
12+
'fire-alarm',
13+
'user-verified-co-or-fire-alarm',
14+
'fire-accelerated-alarm'
15+
]
16+
17+
function matchesAnyAlarmState(
18+
{ alarmInfo }: RingDeviceData,
19+
targetStates: AlarmState[]
20+
) {
21+
return Boolean(alarmInfo && targetStates.includes(alarmInfo.state))
22+
}
23+
24+
export class PanicButtons extends BaseAccessory<RingDevice> {
25+
constructor(
26+
public readonly device: RingDevice,
27+
public readonly accessory: HAP.Accessory,
28+
public readonly logger: HAP.Log,
29+
public readonly config: RingPlatformConfig
30+
) {
31+
super()
32+
33+
const { Characteristic, Service } = hap,
34+
locationName = device.location.locationDetails.name
35+
36+
this.registerCharacteristic(
37+
Characteristic.On,
38+
this.getService(Service.Switch, 'Burglar Alarm', 'Burglar'),
39+
data => matchesAnyAlarmState(data, burglarStates),
40+
on => {
41+
if (on) {
42+
this.logger.info(`Burglar Alarm activated for ${locationName}`)
43+
return this.device.location.triggerBurglarAlarm()
44+
}
45+
46+
this.logger.info(`Burglar Alarm turned off for ${locationName}`)
47+
return this.device.location.setAlarmMode('none')
48+
}
49+
)
50+
51+
this.registerCharacteristic(
52+
Characteristic.On,
53+
this.getService(Service.Switch, 'Fire Alarm', 'Fire'),
54+
data => matchesAnyAlarmState(data, fireStates),
55+
on => {
56+
if (on) {
57+
this.logger.info(`Fire Alarm activated for ${locationName}`)
58+
return this.device.location.triggerFireAlarm()
59+
}
60+
61+
this.logger.info(`Fire Alarm turned off for ${locationName}`)
62+
return this.device.location.setAlarmMode('none')
63+
}
64+
)
65+
}
66+
67+
initBase() {
68+
const { Characteristic, Service } = hap
69+
70+
this.registerCharacteristic(
71+
Characteristic.Manufacturer,
72+
Service.AccessoryInformation,
73+
data => data.manufacturerName || 'Ring'
74+
)
75+
this.registerCharacteristic(
76+
Characteristic.Model,
77+
Service.AccessoryInformation,
78+
() => 'Panic Buttons for ' + this.device.location.locationDetails.name
79+
)
80+
this.registerCharacteristic(
81+
Characteristic.SerialNumber,
82+
Service.AccessoryInformation,
83+
() => 'None'
84+
)
85+
86+
super.initBase()
87+
}
88+
}

homebridge/ring-platform.ts

Lines changed: 37 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { MultiLevelSwitch } from './multi-level-switch'
2121
import { Fan } from './fan'
2222
import { Switch } from './switch'
2323
import { Camera } from './camera'
24+
import { PanicButtons } from './panic-buttons'
2425
import { RingAuth } from '../api/rest-client'
2526
import { platformName, pluginName } from './plugin-info'
2627
import { useLogger } from '../api/util'
@@ -29,7 +30,7 @@ const debug = __filename.includes('release-homebridge')
2930

3031
process.env.RING_DEBUG = debug ? 'true' : ''
3132

32-
function getAccessoryClass(device: RingDevice | RingCamera) {
33+
function getAccessoryClass(device: RingDevice | RingCamera): any {
3334
const { deviceType } = device
3435

3536
switch (deviceType) {
@@ -129,18 +130,42 @@ export class RingPlatform {
129130
locations.map(async location => {
130131
const devices = await location.getDevices(),
131132
cameras = location.cameras,
132-
allDevices = [...devices, ...cameras]
133+
allDevices = [...devices, ...cameras],
134+
securityPanel = devices.find(
135+
x => x.deviceType === RingDeviceType.SecurityPanel
136+
),
137+
debugPrefix = debug ? 'TEST ' : '',
138+
hapDevices = allDevices.map(device => {
139+
const isCamera = device instanceof RingCamera,
140+
cameraIdDifferentiator = isCamera ? 'camera' : '' // this forces bridged cameras from old version of the plugin to be seen as "stale"
141+
142+
return {
143+
device,
144+
isCamera,
145+
id: device.id.toString() + cameraIdDifferentiator,
146+
name: device.name,
147+
AccessoryClass: isCamera ? Camera : getAccessoryClass(device)
148+
}
149+
})
150+
151+
hapDevices.length = 0
152+
153+
if (this.config.showPanicButtons && securityPanel) {
154+
hapDevices.push({
155+
device: securityPanel,
156+
isCamera: false,
157+
id: securityPanel.id.toString() + 'panic',
158+
name: 'Panic Buttons',
159+
AccessoryClass: PanicButtons
160+
})
161+
}
133162

134163
this.log.info(
135-
`Configuring ${cameras.length} cameras and ${devices.length} devices for locationId ${location.locationId} (${location.locationDetails.name})`
164+
`Configuring ${cameras.length} cameras and ${hapDevices.length} devices for location "${location.locationDetails.name}" - locationId: ${location.locationId}`
136165
)
137-
allDevices.forEach(device => {
138-
const isCamera = device instanceof RingCamera,
139-
AccessoryClass = isCamera ? Camera : getAccessoryClass(device),
140-
debugPrefix = debug ? 'TEST ' : '',
141-
cameraIdDifferentiator = isCamera ? 'camera' : '', // this forces bridged cameras from old version of the plugin to be seen as "stale"
142-
id = debugPrefix + device.id.toString() + cameraIdDifferentiator,
143-
uuid = hap.UUIDGen.generate(id)
166+
hapDevices.forEach(({ device, isCamera, id, name, AccessoryClass }) => {
167+
const uuid = hap.UUIDGen.generate(debugPrefix + id),
168+
displayName = debugPrefix + name
144169

145170
if (
146171
!AccessoryClass ||
@@ -152,16 +177,15 @@ export class RingPlatform {
152177

153178
const createHomebridgeAccessory = () => {
154179
const accessory = new hap.PlatformAccessory(
155-
debugPrefix + device.name,
180+
displayName,
156181
uuid,
157182
isCamera
158183
? hap.AccessoryCategories.CAMERA
159184
: hap.AccessoryCategories.SECURITY_SYSTEM
160185
)
161186

162187
this.log.info(
163-
`Adding new accessory ${device.deviceType} ${debugPrefix +
164-
device.name}`
188+
`Adding new accessory ${device.deviceType} ${displayName}`
165189
)
166190

167191
if (isCamera) {

0 commit comments

Comments
 (0)