@koush/ring-client-api
Version:
Unofficial API for Ring doorbells, cameras, security alarm system and smart lighting
380 lines (345 loc) • 10.7 kB
text/typescript
import { noop, Subject } from 'rxjs'
import {
delay,
logDebug,
logError,
logInfo,
randomInteger,
randomString,
} from './util'
import { RtpDescription, RtpOptions, RtpStreamDescription } from './rtp-utils'
import { createCryptoLine, decodeSrtpOptions } from '@homebridge/camera-utils'
const sip = require('./sip.js'),
sdp = require('sdp')
export const expiredDingError = new Error('Ding expired, received 480')
interface UriOptions {
name?: string
uri: string
params?: { tag?: string }
}
interface SipHeaders {
[name: string]: string | any
cseq: { seq: number; method: string }
to: UriOptions
from: UriOptions
contact?: UriOptions[]
via?: UriOptions[]
}
export interface SipRequest {
uri: UriOptions | string
method: string
headers: SipHeaders
content: string
}
export interface SipResponse {
status: number
reason: string
headers: SipHeaders
content: string
}
export interface SipClient {
send: (
request: SipRequest | SipResponse,
handler?: (response: SipResponse) => void
) => void
destroy: () => void
makeResponse: (
response: SipRequest,
status: number,
method: string
) => SipResponse
}
export interface SipOptions {
to: string
from: string
dingId: string
localIp: string
}
function getRandomId() {
return Math.floor(Math.random() * 1e6).toString()
}
function getRtpDescription(
sections: string[],
mediaType: 'audio' | 'video'
): RtpStreamDescription {
try {
const section = sections.find((s) => s.startsWith('m=' + mediaType)),
{ port } = sdp.parseMLine(section),
lines: string[] = sdp.splitLines(section),
rtcpLine = lines.find((l: string) => l.startsWith('a=rtcp:')),
cryptoLine = lines.find((l: string) => l.startsWith('a=crypto'))!,
ssrcLine = lines.find((l: string) => l.startsWith('a=ssrc')),
iceUFragLine = lines.find((l: string) => l.startsWith('a=ice-ufrag')),
icePwdLine = lines.find((l: string) => l.startsWith('a=ice-pwd')),
encodedCrypto = cryptoLine.match(/inline:(\S*)/)![1]
return {
port,
rtcpPort: (rtcpLine && Number(rtcpLine.match(/rtcp:(\S*)/)?.[1])) || port, // rtcp-mux would cause this line to not be present
ssrc:
(ssrcLine && Number(ssrcLine.match(/ssrc:(\S*)/)?.[1])) || undefined,
iceUFrag:
(iceUFragLine && iceUFragLine.match(/ice-ufrag:(\S*)/)?.[1]) ||
undefined,
icePwd:
(icePwdLine && icePwdLine.match(/ice-pwd:(\S*)/)?.[1]) || undefined,
...decodeSrtpOptions(encodedCrypto),
}
} catch (e) {
logError('Failed to parse SDP from Ring')
logError(sections.join('\r\n'))
throw e
}
}
function parseRtpDescription(inviteResponse: {
content: string
}): RtpDescription {
const sections: string[] = sdp.splitSections(inviteResponse.content),
lines: string[] = sdp.splitLines(sections[0]),
cLine = lines.find((line: string) => line.startsWith('c='))!
return {
address: cLine.match(/c=IN IP4 (\S*)/)![1],
audio: getRtpDescription(sections, 'audio'),
video: getRtpDescription(sections, 'video'),
}
}
export class SipCall {
private seq = 20
private fromParams = { tag: getRandomId() }
private toParams: { tag?: string } = {}
private callId = getRandomId()
private sipClient: SipClient
public readonly onEndedByRemote = new Subject()
private destroyed = false
private cameraConnected?: (value?: unknown) => void
private cameraConnectedPromise = new Promise((resolve) => {
this.cameraConnected = resolve
})
public readonly sdp: string
public readonly audioUfrag = randomString(16)
public readonly videoUfrag = randomString(16)
constructor(
private sipOptions: SipOptions,
rtpOptions: RtpOptions,
tlsPort: number
) {
const { audio, video } = rtpOptions,
{ from } = this.sipOptions,
host = this.sipOptions.localIp
this.sipClient = sip.create(
{
host,
hostname: host,
tls_port: tlsPort,
tls: {
rejectUnauthorized: false,
},
tcp: false,
udp: false,
},
(request: SipRequest) => {
if (request.method === 'BYE') {
logDebug('received BYE from ring server')
this.sipClient.send(this.sipClient.makeResponse(request, 200, 'Ok'))
if (this.destroyed) {
this.onEndedByRemote.next(null)
}
} else if (
request.method === 'MESSAGE' &&
request.content === 'event=camera_connected'
) {
logDebug('camera connected to ring server')
this.cameraConnected?.()
}
}
)
this.sdp = [
'v=0',
`o=${from.split(':')[1].split('@')[0]} 3747 461 IN IP4 ${host}`,
's=Talk',
`c=IN IP4 ${host}`,
'b=AS:380',
't=0 0',
`m=audio ${audio.port} RTP/SAVPF 0`,
'a=rtpmap:0 PCMU/8000',
createCryptoLine(audio),
'a=rtcp-mux',
'a=rtcp-fb:* trr-int 5',
'a=rtcp-fb:* ccm tmmbr',
`a=ice-ufrag:${this.audioUfrag}`,
`a=ice-pwd:${randomString(22)}`,
`a=candidate:${randomInteger()} 1 udp ${randomInteger()} ${host} ${
audio.port
} typ host generation 0 network-id 1 network-cost 50`,
`m=video ${video.port} RTP/SAVPF 99`,
'a=rtpmap:99 H264/90000',
'a=fmtp:99 profile-level-id=42801F',
createCryptoLine(video),
'a=rtcp-mux',
'a=rtcp-fb:* trr-int 5',
'a=rtcp-fb:* ccm tmmbr',
'a=rtcp-fb:99 nack pli',
'a=rtcp-fb:99 ccm tstr',
'a=rtcp-fb:99 ccm fir',
`a=ice-ufrag:${this.videoUfrag}`,
`a=ice-pwd:${randomString(22)}`,
`a=candidate:${randomInteger()} 1 udp ${randomInteger()} ${host} ${
video.port
} typ host generation 0 network-id 1 network-cost 50`,
]
.filter((l) => l)
.join('\r\n')
}
request({
method,
headers,
content,
seq,
}: {
method: string
headers?: Partial<SipHeaders>
content?: string
seq?: number
}) {
if (this.destroyed) {
return Promise.reject(
new Error('SIP request made after call was destroyed')
)
}
return new Promise<SipResponse>((resolve, reject) => {
seq = seq || this.seq++
this.sipClient.send(
{
method,
uri: this.sipOptions.to,
headers: {
to: {
name: '"FS Doorbot"',
uri: this.sipOptions.to,
params: this.toParams,
},
from: {
uri: this.sipOptions.from,
params: this.fromParams,
},
'max-forwards': 70,
'call-id': this.callId,
'X-Ding': this.sipOptions.dingId,
'X-Authorization': '',
'User-Agent': 'Android/3.23.0 (belle-sip/1.6.3)',
cseq: { seq, method },
...headers,
},
content: content || '',
},
(response: SipResponse) => {
if (response.headers.to.params && response.headers.to.params.tag) {
this.toParams.tag = response.headers.to.params.tag
}
if (response.status >= 300) {
if (response.status === 480 && method === 'INVITE') {
const { dingId } = this.sipOptions
logInfo(
`Ding ${dingId} is expired (${response.status}). Fetching a new ding and trying video stream again`
)
reject(expiredDingError)
return
}
if (response.status !== 408 || method !== 'BYE') {
logError(
`sip ${method} request failed with status ` + response.status
)
}
reject(
new Error(
`sip ${method} request failed with status ` + response.status
)
)
} else if (response.status < 200) {
// call made progress, do nothing and wait for another response
// console.log('call progress status ' + response.status)
} else {
if (method === 'INVITE') {
// The ACK must be sent with every OK to keep the connection alive.
this.ackWithInfo(seq!).catch((e) => {
logError('Failed to send SDP ACK and INFO')
logError(e)
})
}
resolve(response)
}
}
)
})
}
private async ackWithInfo(seq: number) {
// Don't wait for ack, it won't ever come back.
this.request({
method: 'ACK',
seq, // The ACK must have the original sequence number.
}).catch(noop)
// SIP session will be terminated after 60 seconds if these aren't sent
await this.sendDtmf('2')
await this.sendKeyFrameRequest()
}
sendDtmf(key: string) {
return this.request({
method: 'INFO',
headers: {
'Content-Type': 'application/dtmf-relay',
},
content: `Signal=${key}\r\nDuration=250`,
})
}
private sendKeyFrameRequest() {
return this.request({
method: 'INFO',
headers: {
'Content-Type': 'application/media_control+xml',
},
content:
'<?xml version="1.0" encoding="utf-8" ?><media_control> <vc_primitive> <to_encoder> <picture_fast_update></picture_fast_update> </to_encoder> </vc_primitive></media_control>',
})
}
async invite() {
const { from } = this.sipOptions,
inviteResponse = await this.request({
method: 'INVITE',
headers: {
supported: 'replaces, outbound',
allow:
'INVITE, ACK, CANCEL, OPTIONS, BYE, REFER, NOTIFY, MESSAGE, SUBSCRIBE, INFO, UPDATE',
'content-type': 'application/sdp',
contact: [{ uri: from }],
},
content: this.sdp,
})
return parseRtpDescription(inviteResponse)
}
async requestKeyFrame() {
// camera connected event doesn't always happen if cam is already streaming. 2 second fallback
await Promise.race([this.cameraConnectedPromise, delay(2000)])
logDebug('requesting key frame')
await this.sendKeyFrameRequest()
}
private speakerActivated = false
async activateCameraSpeaker() {
if (this.speakerActivated) {
return
}
this.speakerActivated = true
logDebug('Activating camera speaker')
await this.sendDtmf('1').catch((e) => {
logError('Failed to activate camera speaker')
logError(e)
})
}
sendBye() {
return this.request({ method: 'BYE' }).catch(() => {
// Don't care if we get an exception here.
})
}
destroy() {
this.destroyed = true
this.sipClient.destroy()
}
}