import { Logger } from 'homebridge' import crypto from 'node:crypto' import net from 'node:net' import { got, Got, RequestError, OptionsOfTextResponseBody } from 'got' import { decode } from 'html-entities' import { InputVisibility } from './accessory' import { Abnormal, EmptyObject, isEmpty, isValidIPv4, Ok, Outcome, prettyPrint } from './helpers' import { xml2obj, xml } from './helpers.server' import { UPnPSubscription } from './networkUtils' import { PluginConfig } from './ui/state' // helpers and default settings const AudioChannel: string = xml({ Channel: 'Master', InstanceID: 0 }) type VieraSpecs = | EmptyObject | { friendlyName: string modelName: string modelNumber: string manufacturer: string serialNumber: string requiresEncryption: boolean } interface VieraApp { name: string id: string hidden?: InputVisibility } type VieraApps = VieraApp[] type RequestType = 'command' | 'render' type VieraSession = | EmptyObject | { iv: Buffer key: Buffer hmacKey: Buffer challenge: Buffer seqNum: number id: number } type VieraAuth = | EmptyObject | { appId: string key: string } class VieraTV implements VieraTV { private static readonly NRC = 'nrc/control_0' private static readonly DMR = 'dmr/control_0' private static readonly INFO = 'nrc/ddd.xml' private static readonly ACTIONS = 'nrc/sdd_0.xml' private static readonly RemoteURN = 'panasonic-com:service:p00NetworkControl:1' static readonly URN = `urn:${this.RemoteURN}` private static readonly RenderingURN = 'schemas-upnp-org:service:RenderingControl:1' private static readonly plainText = ['X_GetEncryptSessionId', 'X_DisplayPinCode', 'X_RequestAuth'] static readonly port = 55_000 readonly address: string readonly mac: string | undefined readonly log: Logger | Console apps: Outcome<VieraApps> = {} auth: VieraAuth = {} #client: Got #session: VieraSession = {} specs: VieraSpecs = {} private constructor(ip: string, log: Logger | Console) { this.address = ip this.log = log this.#client = got.extend({ headers: { Accept: 'application/xml', 'Cache-Control': 'no-cache', 'Content-Type': 'application/xml; charset="utf-8"', Host: `${this.address}:${VieraTV.port}`, Pragma: 'no-cache' }, prefixUrl: `http://${this.address}:${VieraTV.port}`, retry: { limit: 0 }, timeout: { request: 1500 } }) } static async connect( ip: string, log: Logger | Console, settings: { auth?: VieraAuth; bootstrap?: boolean; cached?: VieraSpecs } = {} ): Promise<Outcome<VieraTV>> { const tv = new VieraTV(ip, log) tv.specs = await tv.#getSpecs() settings.bootstrap ??= false if (!settings.bootstrap) { if (isEmpty(tv.specs) && settings.cached !== undefined && !isEmpty(settings.cached)) { tv.log.warn(`Unable to fetch specs from TV at '${ip}'.`) tv.log.warn('Using the previously cached ones:\n\n', prettyPrint(settings.cached)) const err = `IGNORING '${ip}' as we do not support offline initialization, from cache, for models that require encryption.` if (settings.cached.requiresEncryption) return { error: Error(err) } tv.specs = settings.cached } if (isEmpty(tv.specs)) { tv.log.error( 'please fill a bug at https://github.com/AntonioMeireles/homebridge-vieramatic/issues with data bellow' ) const error = Error(`${ip}: offline initialization failure!\n${prettyPrint(settings)}`) return { error } } if (tv.specs.requiresEncryption) { const err = `'${ip} ('${tv.specs.modelName}')' ignored, as it is from a Panasonic TV that requires encryption and no working credentials were supplied.` if (settings.auth) tv.auth = settings.auth if (isEmpty(tv.auth)) return { error: Error(err) } const result = await tv.#requestSessionId() if (Abnormal(result)) return { error: Error(err) } } } else if (isEmpty(tv.specs)) return { error: Error('An unexpected error occurred - Unable to fetch specs from the TV.') } tv.apps = await tv.#getApps() return { value: tv } } static probe = async (ip: string, log: Logger | Console = console): Promise<Outcome<VieraTV>> => !isValidIPv4(ip) ? { error: Error('Please introduce a valid ip address!') } : !(await VieraTV.livenessProbe(ip)) ? { error: Error(`The provided IP (${ip}) is unreachable.`) } : await this.connect(ip, log, { bootstrap: true }) static livenessProbe = async (tv: string, timeout = 1500): Promise<boolean> => await new Promise<boolean>((resolve) => { const socket = new net.Socket() const status = (availability = true) => { availability ? socket.end() : socket.destroy() resolve(availability) } const [isUp, isDown] = [(): void => status(true), (): void => status(false)] socket .setTimeout(timeout) .once('error', isDown) .once('timeout', isDown) .connect(VieraTV.port, tv, isUp) }) static isTurnedOn = async (address: string): Promise<boolean> => await new Promise<boolean>((resolve) => { const watcher = new UPnPSubscription(address, VieraTV.port, '/nrc/event_0') setTimeout(() => { watcher.unsubscribe() resolve(false) }, 1500) watcher .on('message', (message): void => { const properties = message.body['e:propertyset']['e:property'] // when in standby isn't an array // XXX: in some cases X_ScreenState simply doesn't pop up (in array or no array form) // when that happens we assume it's off. if (properties.X_ScreenState) { resolve(properties.X_ScreenState === 'on') } else if (Array.isArray(properties)) { const match = properties.find((prop) => ['on', 'off', 'none'].includes(prop.X_ScreenState) ) match ? resolve(match.X_ScreenState !== 'off') : resolve(false) } else resolve(false) }) .on('error', () => resolve(false)) }) #needsCrypto = async (): Promise<boolean> => await this.#client .get(VieraTV.ACTIONS) .then((resp) => !!/X_GetEncryptSessionId/u.test(resp.body)) .catch(() => false) #requestSessionId = async (): Promise<Outcome<void>> => { let outcome: Outcome<string> const callback = (data: string): Outcome<void> => { const error = Error('abnormal result from TV - session ID is not (!) an integer') const match = /<X_SessionId>(?<sessionId>\d+)<\/X_SessionId>/u.exec(data) const number = match?.groups?.sessionId if (!number) return { error } this.#session.seqNum = 1 this.#session.id = Number.parseInt(number, 10) return {} } if (isEmpty(this.#session)) this.#deriveSessionKey() return Ok((outcome = this.#encryptPayload(xml({ X_ApplicationId: this.auth.appId })))) ? await this.#postRemote( 'X_GetEncryptSessionId', xml({ X_ApplicationId: this.auth.appId, X_EncInfo: outcome.value }), callback ) : outcome } #deriveSessionKey = (): void => { let [i, j]: number[] = [] const iv = Buffer.from(this.auth.key, 'base64') const keyVals = Buffer.alloc(16) for (i = j = 0; j < 16; i = j += 4) { keyVals[i] = iv[i + 2] keyVals[i + 1] = iv[i + 3] keyVals[i + 2] = iv[i] keyVals[i + 3] = iv[i + 1] } this.#session.iv = iv this.#session.key = Buffer.from(keyVals) this.#session.hmacKey = Buffer.concat([iv, iv]) } #decryptPayload(payload: string, key: Buffer, iv: Buffer): string { const aes = crypto.createDecipheriv('aes-128-cbc', key, iv) const decrypted = aes.update(Buffer.from(payload, 'base64')) return decrypted.toString('utf8', 16, decrypted.indexOf('\u0000', 16)) } #encryptPayload( original: string, key: Buffer = this.#session.key, iv: Buffer = this.#session.iv, hmacKey: Buffer = this.#session.hmacKey ): Outcome<string> { try { const data = Buffer.from(original) const headerPrefix = Buffer.from(crypto.randomBytes(12)) const headerSufix = Buffer.alloc(4) headerSufix.writeIntBE(data.length, 0, 4) const payload = Buffer.concat([headerPrefix, headerSufix, data]) const aes = crypto.createCipheriv('aes-128-cbc', key, iv) const ciphered = Buffer.concat([aes.update(payload), aes.final()]) const sig = crypto.createHmac('sha256', hmacKey).update(ciphered).digest() return { value: Buffer.concat([ciphered, sig]).toString('base64') } } catch (error) { return { error: error as Error } } } /* * Returns the TV specs */ #getSpecs = async (): Promise<VieraSpecs> => { return await this.#client .get(VieraTV.INFO) .then(async (raw): Promise<VieraSpecs> => { const jsonObject = xml2obj(raw.body) // @ts-expect-error ts(2339) const { device } = jsonObject.root const specs: VieraSpecs = { friendlyName: device.friendlyName.length > 0 ? device.friendlyName : device.modelName, manufacturer: device.manufacturer, modelName: device.modelName, // #87 modelNumber: device.modelNumber ?? '(very old) model unknown', requiresEncryption: await this.#needsCrypto(), serialNumber: device.UDN.slice(5) } this.log.info( "found a '%s' TV (%s) at '%s' %s.\n", specs.modelName, specs.modelNumber, this.address, specs.requiresEncryption ? '(requires crypto for communication)' : '' ) return specs }) .catch((error) => { this.log.debug('getSpecs:', error) return {} }) } #renderEncryptedRequest = async ( action: string, urn: string, parameters: string ): Promise<Outcome<string[]>> => { // this.log.debug(`(renderEncryptedRequest) [${action}] urn:[${urn}], parameters: [${parameters}]`) this.#session.seqNum += 1 const encCommand = xml({ X_OriginalCommand: { [`u:${action}`]: { '#text': parameters, '@_xmlns:u': `urn:${urn}` } }, X_SequenceNumber: String(this.#session.seqNum + 1).padStart(8, '0'), X_SessionId: this.#session.id }) const outcome = this.#encryptPayload(encCommand) return Ok(outcome) ? { value: [ 'X_EncryptedCommand', xml({ X_ApplicationId: this.auth.appId, X_EncInfo: outcome.value }) ] } : outcome } #renderRequest = (action: string, urn: string, parameters: string): OptionsOfTextResponseBody => { const method: OptionsOfTextResponseBody['method'] = 'POST' const responseType: OptionsOfTextResponseBody['responseType'] = 'text' const headers = { SOAPACTION: `"urn:${urn}#${action}"` } const body = '<?xml version="1.0" encoding="utf-8"?>'.concat( xml({ 's:Envelope': { '@_s:encodingStyle': 'http://schemas.xmlsoap.org/soap/encoding/', '@_xmlns:s': 'http://schemas.xmlsoap.org/soap/envelope/', 's:Body': { [`u:${action}`]: { '#text': parameters, '@_xmlns:u': `urn:${urn}` } } } }) ) return { body, headers, method, responseType } } #post = async <T>( requestType: RequestType, realAction: string, realParameters = 'None', closure: (arg: string) => Outcome<T> = (x) => x as unknown as Outcome<T> ): Promise<Outcome<T>> => { let [action, parameters]: string[] = [] let payload: Outcome<T>, reset: Outcome<void> const [sessionGone, isCommand] = ['No such session', requestType === 'command'] const [urL, urn] = isCommand ? [VieraTV.NRC, VieraTV.RemoteURN] : [VieraTV.DMR, VieraTV.RenderingURN] const doIt = async (): Promise<Outcome<T>> => { if (this.specs.requiresEncryption && isCommand && !VieraTV.plainText.includes(realAction)) { const outcome = await this.#renderEncryptedRequest(realAction, urn, realParameters) if (Ok(outcome)) [action, parameters] = outcome.value else return outcome } else [action, parameters] = [realAction, realParameters] return (await this.#client(urL, this.#renderRequest(action, urn, parameters)) .then((r) => { const replacer = (_match: string, _offset: string, content: string): string => this.#decryptPayload(content, this.#session.key, this.#session.iv) const value = r.body.replace(/(<X_EncResult>)(.*)(<\/X_EncResult>)/g, replacer) return { value } }) .catch((error: RequestError) => error.response?.statusCode === 500 && error.response.statusMessage?.includes(sessionGone) ? { error: Error(sessionGone) } : { error } )) as Outcome<T> } if (Abnormal((payload = await doIt()))) if (payload.error.message === sessionGone) { this.log.warn('Session mismatch found; The session counter was reset in order to move on.') if (Abnormal((reset = await this.#requestSessionId()))) return reset if (Abnormal((payload = await doIt()))) return payload } else return payload return closure(payload.value as unknown as string) } requestPinCode = async (): Promise<Outcome<string>> => { const overreachErr = `The ${this.specs.modelNumber} model at ${this.address} doesn't need encryption!` const unexpectedErr = `An unexpected error occurred while attempting to request a pin code from the TV.` const notReadyErr = `Unable to request pin code as the TV seems to be in standby; Please turn it ON!` const parameters = xml({ X_DeviceName: 'MyRemote' }) const callback = (data: string): Outcome<string> => { const match = /<X_ChallengeKey>(?<challenge>\S*)<\/X_ChallengeKey>/u.exec(data) if (!match?.groups?.challenge) return { error: Error(unexpectedErr) } this.#session.challenge = Buffer.from(match.groups.challenge, 'base64') return { value: match.groups.challenge } } return !this.specs.requiresEncryption ? { error: Error(overreachErr) } : !(await VieraTV.isTurnedOn(this.address)) ? { error: Error(notReadyErr) } : await this.#postRemote('X_DisplayPinCode', parameters, callback) } #postRemote = async <T>( realAction: string, realParameters = 'None', closure: (arg: string) => Outcome<T> = (x) => x as unknown as Outcome<T> ): Promise<Outcome<T>> => await this.#post('command', realAction, realParameters, closure) authorizePinCode = async (pin: string, challenge?: string): Promise<Outcome<VieraAuth>> => { // injection needed by the ui-server if (challenge) this.#session.challenge = Buffer.from(challenge, 'base64') let [i, j, l, k]: number[] = [] let ack: Outcome<VieraAuth>, outcome: Outcome<string> const [iv, key, hmacKey] = [this.#session.challenge, Buffer.alloc(16), Buffer.alloc(32)] const error = Error('Wrong pin code...') for (i = k = 0; k < 16; i = k += 4) { key[i] = ~iv[i + 3] & 0xff key[i + 1] = ~iv[i + 2] & 0xff key[i + 2] = ~iv[i + 1] & 0xff key[i + 3] = ~iv[i] & 0xff } // Derive HMAC key from IV & HMAC key mask (taken from libtvconnect.so) const hmacKeyMaskVals = [ 0x15, 0xc9, 0x5a, 0xc2, 0xb0, 0x8a, 0xa7, 0xeb, 0x4e, 0x22, 0x8f, 0x81, 0x1e, 0x34, 0xd0, 0x4f, 0xa5, 0x4b, 0xa7, 0xdc, 0xac, 0x98, 0x79, 0xfa, 0x8a, 0xcd, 0xa3, 0xfc, 0x24, 0x4f, 0x38, 0x54 ] for (j = l = 0; l < 32; j = l += 4) { hmacKey[j] = hmacKeyMaskVals[j] ^ iv[(j + 2) & 0xf] hmacKey[j + 1] = hmacKeyMaskVals[j + 1] ^ iv[(j + 3) & 0xf] hmacKey[j + 2] = hmacKeyMaskVals[j + 2] ^ iv[j & 0xf] hmacKey[j + 3] = hmacKeyMaskVals[j + 3] ^ iv[(j + 1) & 0xf] } const callback = (r: string): Outcome<VieraAuth> => { const AuthResult = /(<X_AuthResult>)(.*)(<\/X_AuthResult>)/g const KeyPair = /<X_ApplicationId>(?<appId>\S+)<\/X_ApplicationId>\s+<X_Keyword>(?<key>\S+)<\/X_Keyword>/ const replacer = (_match: string, _offset: string, content: string): string => this.#decryptPayload(content, key, iv) return { value: KeyPair.exec(r.replace(AuthResult, replacer))?.groups as VieraAuth } } return Abnormal((outcome = this.#encryptPayload(xml({ X_PinCode: pin }), key, iv, hmacKey))) ? outcome : Ok( (ack = await this.#postRemote( 'X_RequestAuth', xml({ X_AuthInfo: outcome.value }), callback )) ) ? ack : { error } } renderSampleConfig = (): void => { const sample = new PluginConfig([ { appId: this.auth.appId, encKey: this.auth.key, hdmiInputs: [], ipAddress: this.address } ]) console.info( '\n', 'Please add, as a starting point, the snippet bellow inside the', "'platforms' array of your homebridge's 'config.json'\n--x--" ) console.group() console.log(prettyPrint(sample)) console.groupEnd() console.log('--x--') } /** * Sends a (command) Key to the TV */ sendKey = async <T>(cmd: string): Promise<Outcome<T>> => await this.#postRemote('X_SendKey', xml({ X_KeyEvent: `NRC_${cmd.toUpperCase()}-ONOFF` })) /** * Send a change HDMI input to the TV */ switchToHDMI = async <T>(hdmiInput: string): Promise<Outcome<T>> => await this.#postRemote('X_SendKey', xml({ X_KeyEvent: `NRC_HDMI${hdmiInput}-ONOFF` })) /** * Send command to open app on the TV */ launchApp = async <T>(appId: string): Promise<Outcome<T>> => await this.#postRemote( 'X_LaunchApp', xml({ X_AppType: 'vc_app', X_LaunchKeyword: appId.length === 16 ? `product_id=${appId}` : `resource_id=${appId}` }) ) /** * Get volume from TV */ getVolume = async (): Promise<Outcome<string>> => { const callback = (data: string): Outcome<string> => { const match = /<CurrentVolume>(?<volume>\d*)<\/CurrentVolume>/u.exec(data) return match?.groups?.volume ? { value: match.groups.volume } : { value: '0' } } return await this.#post('render', 'GetVolume', AudioChannel, callback) } /** * Set Volume */ setVolume = async (volume: string): Promise<Outcome<void>> => await this.#post('render', 'SetVolume', AudioChannel.concat(xml({ DesiredVolume: volume }))) /** * Gets the current mute setting * @returns true for mute */ getMute = async (): Promise<Outcome<boolean>> => { const callback = (data: string): Outcome<boolean> => { const match = /<CurrentMute>(?<mute>[01])<\/CurrentMute>/u.exec(data) return match?.groups?.mute ? { value: match.groups.mute === '1' } : { value: true } } return await this.#post('render', 'GetMute', AudioChannel, callback) } /** * Set mute to on/off */ setMute = async (d: boolean): Promise<Outcome<void>> => await this.#post('render', 'SetMute', AudioChannel.concat(xml({ DesiredMute: d ? '1' : '0' }))) /** * Returns the list of apps on the TV */ #getApps = async (): Promise<Outcome<VieraApps>> => { const callback = (data: string): Outcome<VieraApps> => { const value: VieraApps = [] const raw = /<X_AppList>(?<appList>.*)<\/X_AppList>/u.exec(data)?.groups?.appList // '' is NOT to be catched bellow, but later, so we can't use !raw if ((raw as unknown) === undefined) return { error: Error('X_AppList returned originally:\n'.concat(data)) } for (const index of decode(raw).matchAll(/'product_id=(?<id>[\dA-Z]+)'(?<name>[^']+)/gu)) index.groups && value.push(index.groups as unknown as VieraApp) return value.length === 0 ? { error: Error('The TV is in standby!') } : { value } } return await this.#postRemote('X_GetAppList', undefined, callback) } } export { VieraApp, VieraApps, VieraAuth, VieraSpecs, VieraTV }