import { Characteristic , CharacteristicValue , Logger , PlatformAccessory , Service } from 'homebridge' ; import util from 'node:util' ; import wakeOnLan from '@mi-sec/wol' ; import { Abnormal , Ok , Outcome , EmptyObject } from './helpers' ; import VieramaticPlatform from './platform' ; import { VieraApp , VieraApps , VieraSpecs , VieraTV } from './viera' ; type InputVisibility = 0 | 1 ; type OnDisk = EmptyObject | { data : { inputs : { applications : VieraApps ; hdmi : HdmiInput [ ] ; TUNER : { hidden ? : InputVisibility ; } ; } ; ipAddress : string ; specs : VieraSpecs ; } ; } ; interface HdmiInput { name : string ; id : string ; hidden ? : InputVisibility ; } interface UserConfig { friendlyName ? : string ; ipAddress : string ; mac ? : string ; encKey ? : string ; appId ? : string ; customVolumeSlider ? : boolean ; disabledAppSupport ? : boolean ; hdmiInputs : HdmiInput [ ] ; } type InputType = 'HDMI' | 'APPLICATION' | 'TUNER' ; class VieramaticPlatformAccessory { private readonly service : Service ; private readonly Service : typeof Service ; private readonly Characteristic : typeof Characteristic ; private readonly log : Logger ; private readonly storage : OnDisk ; private readonly device : VieraTV ; constructor ( private readonly platform , readonly accessory , private readonly userConfig ) { this . log = this . platform . log ; this . Service = this . platform . Service ; this . Characteristic = this . platform . Characteristic ; this . log . debug ( JSON . stringify ( this . userConfig , undefined , 2 ) ) ; const handler = { get : < T , K extends keyof T > ( obj , prop ) : T [ K ] | boolean | undefined => { if ( prop === 'isProxy' ) return true ; const property = obj [ prop ] ; if ( typeof property === 'undefined' ) return ; if ( ! util . types . isProxy ( property ) && typeof property === 'object' ) obj [ prop ] = new Proxy ( property as unknown as Record < string , unknown > , handler ) as unknown as T [ K ] ; return obj [ prop ] ; } , set : < T , K extends keyof T > ( obj , prop , value ) => { obj [ prop ] = value ; this . platform . storage . save ( ) ; return true ; } } ; this . device = this . accessory . context . device ; this . storage = new Proxy < OnDisk > ( this . platform . storage . get ( this . device . specs . serialNumber ) , handler ) ; const svc = this . accessory . getService ( this . Service . AccessoryInformation ) ; const model = `${ this . device . specs . modelName } ${ this . device . specs . modelNumber }` ; if ( svc ) svc . setCharacteristic ( this . Characteristic . Manufacturer , this . device . specs . manufacturer ) . setCharacteristic ( this . Characteristic . SerialNumber , this . device . specs . serialNumber ) . setCharacteristic ( this . Characteristic . Model , model ) ; this . accessory . on ( 'identify' , ( ) => this . log . info ( this . device . specs . friendlyName , 'Identified!' ) ) ; this . service = this . accessory . addService ( this . Service . Television ) ; this . service . setCharacteristic ( this . Characteristic . Name , this . device . specs . friendlyName ) ; this . service . setCharacteristic ( this . Characteristic . ConfiguredName , this . device . specs . friendlyName ) . setCharacteristic ( this . Characteristic . SleepDiscoveryMode , this . Characteristic . SleepDiscoveryMode . ALWAYS_DISCOVERABLE ) ; this . service . addCharacteristic ( this . Characteristic . PowerModeSelection ) . onSet ( async ( ) => { const outcome = await this . device . sendKey ( 'MENU' ) ; if ( Abnormal ( outcome ) ) this . log . error ( 'unexpected error in PowerModeSelection.set' , outcome . error ) ; } ) ; this . service . getCharacteristic ( this . Characteristic . Active ) . onSet ( this . setPowerStatus . bind ( this ) ) . onGet ( this . getPowerStatus . bind ( this ) ) ; this . service . getCharacteristic ( this . Characteristic . RemoteKey ) . onSet ( this . remoteControl . bind ( this ) ) ; this . service . getCharacteristic ( this . Characteristic . ActiveIdentifier ) . onSet ( this . setInput . bind ( this ) ) ; const speakerService = this . accessory . addService ( this . Service . TelevisionSpeaker , `${ this . device . specs . friendlyName } Volume` , 'volumeService' ) ; speakerService . addCharacteristic ( this . Characteristic . Volume ) ; speakerService . addCharacteristic ( this . Characteristic . Active ) ; speakerService . setCharacteristic ( this . Characteristic . VolumeControlType , this . Characteristic . VolumeControlType . ABSOLUTE ) ; this . service . addLinkedService ( speakerService ) ; speakerService . getCharacteristic ( this . Characteristic . Mute ) . onGet ( this . getMute . bind ( this ) ) . onSet ( this . setMute . bind ( this ) ) ; speakerService . getCharacteristic ( this . Characteristic . Volume ) . onGet ( this . getVolume . bind ( this ) ) . onSet ( this . setVolume . bind ( this ) ) ; speakerService . getCharacteristic ( this . Characteristic . VolumeSelector ) . onSet ( this . setVolumeSelector . bind ( this ) ) ; if ( this . userConfig . customVolumeSlider === true ) { const [ friendlyN , svcN ] = [ `${ this . device . specs . friendlyName } Volume` , 'VolumeAsFanService' ] ; const customSpeakerService = this . accessory . addService ( this . Service . Fan , friendlyN , svcN ) ; this . service . addLinkedService ( customSpeakerService ) ; customSpeakerService . getCharacteristic ( this . Characteristic . On ) . onGet ( ( ) => { const { value } = this . service . getCharacteristic ( this . Characteristic . Active ) ; this . log . debug ( '(customSpeakerService/On.get)' , value ) ; return value ; } ) . onSet ( async ( value ) => { this . log . debug ( '(customSpeakerService/On.set)' , value ) ; const state = this . service . getCharacteristic ( this . Characteristic . Active ) . value === this . Characteristic . Active . INACTIVE ? false : ! ( value as boolean ) ; await this . device . setMute ( state ) ; } ) ; customSpeakerService . getCharacteristic ( this . Characteristic . RotationSpeed ) . onGet ( this . getVolume . bind ( this ) ) . onSet ( this . setVolume . bind ( this ) ) ; } setInterval ( async ( ) => await this . getPowerStatus ( ) , 5000 ) ; this . userConfig . hdmiInputs ||= [ ] ; this . userConfig . hdmiInputs = this . userConfig . hdmiInputs . filter ( ( input ) => { const required = [ 'id' , 'name' ] ; for ( const req of required ) if ( ! Object . prototype . hasOwnProperty . call ( input , req ) ) { this . log . warn ( `ignoring hdmi input "${ JSON . stringify ( input ) }" as it has a missing required field ("${ req }" is required)` ) ; return false ; } return true ; } ) ; const apps = Ok ( this . device . apps ) ? this . device . apps . value : [ ] ; if ( ! ( this . storage . data as unknown ) ) this . storage . data = { inputs : { applications : { ... apps } , hdmi : this . userConfig . hdmiInputs , TUNER : { hidden : 0 } } , ipAddress : this . userConfig . ipAddress , specs : { ... this . device . specs } } ; else { this . log . debug ( 'Restoring' , this . device . specs . friendlyName ) ; const sameId = ( a , b ) : boolean => a . id === b . id ; const sameNameId = ( a , b ) : boolean => a . id === b . id && a . name === b . name ; this . storage . data . inputs . hdmi = this . storage . data . inputs . hdmi . map ( ( element ) : HdmiInput => { const found = userConfig . hdmiInputs . findIndex ( ( x ) => sameId ( x , element ) ) ; if ( found !== - 1 && userConfig . hdmiInputs [ found ] . name !== element . name ) { const msg = "HDMI input '%s' renamed from '%s' to '%s'" ; this . log . info ( msg , element . id , element . name , userConfig . hdmiInputs [ found ] . name ) ; element . name = userConfig . hdmiInputs [ found ] . name ; } return element ; } ) ; for ( const input of userConfig . hdmiInputs ) { const found = this . storage . data . inputs . hdmi . findIndex ( ( x ) => sameNameId ( x , input ) ) ; if ( found === - 1 ) { const msg = `appending HDMI input '${ input . id }':'${ input . name }' as it was appended to config.json` ; this . log . info ( msg ) ; this . storage . data . inputs . hdmi . push ( input ) ; } } this . storage . data . inputs . hdmi = this . storage . data . inputs . hdmi . filter ( ( input ) => { const found = userConfig . hdmiInputs . findIndex ( ( x ) => sameId ( x , input ) ) ; if ( found !== - 1 ) return true ; const msg = `removing HDMI input '${ input . id }':'${ input . name }' as it was dropped from the config.json` ; this . log . info ( msg , input . id , input . name ) ; return false ; } ) ; this . storage . data . ipAddress = this . userConfig . ipAddress ; this . storage . data . specs = { ... this . device . specs } ; if ( apps . length > 0 ) { const next = [ ] ; for ( const line of Object . entries ( this . storage . data . inputs . applications ) ) { const [ _ , app ] = line ; const found = [ ... apps ] . some ( ( x ) : boolean => x . name === app . name ) ; if ( ! found ) { this . log . warn ( `deleting TV App '${ app . name }' as it wasn't removed from your TV's` ) ; } else next . push ( app ) ; } for ( const line of Object . entries ( [ ... apps ] ) ) { const [ _ , app ] = line ; const found = next . some ( ( x ) : boolean => x . name === app . name ) ; if ( ! found ) { this . log . warn ( `adding TV App '${ app . name }' since it was added to your TV` ) ; next . push ( app ) ; } } this . storage . data . inputs . applications = { ... next } ; } else this . log . warn ( 'Using previously cached App listing.' ) ; } this . configureInputSource ( 'TUNER' , 'TV Tuner' , 500 ) ; this . storage . data . inputs . hdmi = this . storage . data . inputs . hdmi . filter ( ( input ) : boolean => { try { this . configureInputSource ( 'HDMI' , input . name , Number . parseInt ( input . id , 10 ) ) ; } catch { this . log . error ( "Unable to add as an accessory to your TV 'HDMI' input:\n%s\n\n%s" , JSON . stringify ( input , undefined , 2 ) , "If you do believe that your homebridge's 'config.json' is in order and" , 'has absolutelly no duplicated entries for HDMI inputs then please fill' , 'a bug at https://github.com/AntonioMeireles/homebridge-vieramatic/issues,' , 'otherwise just remove or fix the duplicated stuff.' ) ; return false ; } return true ; } ) ; for ( const line of Object . entries ( this . storage . data . inputs . applications ) ) { const [ id , app ] = line ; const sig = 1000 + Number . parseInt ( id , 10 ) ; this . configureInputSource ( 'APPLICATION' , app . name , sig ) ; } } private async setInput ( value ) { const fn = async ( ) : Promise < Outcome < void > > => { let app , real ; switch ( true ) { case value < 100 : this . log . debug ( '(setInput) switching to HDMI INPUT ' , value ) ; return await this . device . switchToHDMI ( ( value as number ) . toString ( ) ) ; case value > 999 : real = ( value as number ) - 1000 ; app = this . storage . data . inputs . applications [ real ] ; this . log . debug ( '(setInput) switching to App' , app . name ) ; return await this . device . launchApp ( app . id ) ; case value === 500 : default : this . log . debug ( '(setInput) switching to internal TV tunner' ) ; return await this . device . sendKey ( 'AD_CHANGE' ) ; } } ; const cmd = await fn ( ) ; if ( Abnormal ( cmd ) ) this . log . error ( 'setInput' , value , cmd . error ) ; } private configureInputSource ( type , configuredName , identifier ) { const fn = ( element ) : boolean => element . id === identifier . toString ( ) ; const visibility = ( ) : string => { let idx ; let hidden ; const { inputs } = this . storage . data ; switch ( type ) { case 'HDMI' : idx = inputs . hdmi . findIndex ( ( x ) => fn ( x ) ) ; hidden = inputs . hdmi [ idx ] . hidden ?? 0 ; break ; case 'APPLICATION' : idx = identifier - 1000 ; hidden = inputs . applications [ idx ] . hidden ?? 1 ; break ; case 'TUNER' : default : hidden = inputs . TUNER . hidden ?? 0 ; } return hidden . toFixed ( 0 ) ; } ; const source = this . accessory . addService ( this . Service . InputSource , configuredName . toLowerCase ( ) . replace ( /\s/gu , '' ) , identifier ) ; const visibilityState = ( state ) : void => { let idx ; const id = source . getCharacteristic ( this . Characteristic . Identifier ) . value ?? 500 ; const { inputs } = this . storage . data ; switch ( true ) { case id < 100 : idx = inputs . hdmi . findIndex ( ( x ) => fn ( x ) ) ; inputs . hdmi [ idx ] . hidden = state as InputVisibility ; break ; case id > 999 : idx = ( id as number ) - 1000 ; inputs . applications [ idx ] . hidden = state as InputVisibility ; break ; case id === 500 : default : inputs . TUNER . hidden = state as InputVisibility ; break ; } source . updateCharacteristic ( this . Characteristic . CurrentVisibilityState , state ) ; } ; const hidden = visibility ( ) ; source . setCharacteristic ( this . Characteristic . InputSourceType , this . Characteristic . InputSourceType [ type ] ) . setCharacteristic ( this . Characteristic . CurrentVisibilityState , hidden ) . setCharacteristic ( this . Characteristic . TargetVisibilityState , hidden ) . setCharacteristic ( this . Characteristic . Identifier , identifier ) . setCharacteristic ( this . Characteristic . ConfiguredName , configuredName ) . setCharacteristic ( this . Characteristic . IsConfigured , this . Characteristic . IsConfigured . CONFIGURED ) ; source . getCharacteristic ( this . Characteristic . TargetVisibilityState ) . onSet ( visibilityState ) ; const svc = this . accessory . getService ( this . Service . Television ) ; if ( svc ) svc . addLinkedService ( source ) ; } async setPowerStatus ( nextState ) { const message = nextState === this . Characteristic . Active . ACTIVE ? 'ON' : 'into STANDBY' ; const currentState = await VieraTV . isTurnedOn ( this . device . address ) ; this . log . debug ( '(setPowerStatus)' , nextState , currentState ) ; if ( ( nextState === this . Characteristic . Active . ACTIVE ) === currentState ) this . log . debug ( 'TV is already %s: Ignoring!' , message ) ; else if ( nextState === this . Characteristic . Active . ACTIVE && this . device . mac ) { this . log . debug ( 'sending WOL packets to awake TV' ) ; await wakeOnLan ( this . device . mac , { packets : 10 } ) ; await this . updateTVstatus ( nextState ) ; this . log . debug ( 'Turned TV' , message ) ; } else { const cmd = await this . device . sendKey ( 'POWER' ) ; if ( Abnormal ( cmd ) ) this . log . error ( '(setPowerStatus)/-> %s - unable to power cycle TV - unpowered ?' , message ) ; else { await this . updateTVstatus ( nextState ) ; this . log . debug ( 'Turned TV' , message ) ; } } } async getPowerStatus ( ) { const currentState = await VieraTV . isTurnedOn ( this . device . address ) ; await this . updateTVstatus ( currentState ) ; return currentState ; } async getMute ( ) { const state = await VieraTV . isTurnedOn ( this . device . address ) ; let mute ; if ( state ) { const cmd = await this . device . getMute ( ) ; mute = Ok ( cmd ) ? cmd . value : true ; } else mute = true ; this . log . debug ( '(getMute) is' , mute ) ; return mute ; } async setMute ( state ) { this . log . debug ( '(setMute) is' , state ) ; if ( Abnormal ( await this . device . setMute ( state as boolean ) ) ) this . log . error ( '(setMute)/(%s) unable to change mute state on TV...' , state ) ; } async setVolume ( value ) { this . log . debug ( '(setVolume)' , value ) ; if ( Abnormal ( await this . device . setVolume ( ( value as number ) . toString ( ) ) ) ) this . log . error ( '(setVolume)/(%s) unable to set volume on TV...' , value ) ; } async getVolume ( ) { const cmd = await this . device . getVolume ( ) ; let volume = 0 ; Ok ( cmd ) ? ( volume = Number ( cmd . value ) ) : this . log . debug ( '(getVolume) no volume from TV...' ) ; return volume ; } async setVolumeSelector ( key ) { this . log . debug ( 'setVolumeSelector' , key ) ; const action = key === this . Characteristic . VolumeSelector . INCREMENT ? 'VOLUP' : 'VOLDOWN' ; const cmd = await this . device . sendKey ( action ) ; if ( Abnormal ( cmd ) ) this . log . error ( '(setVolumeSelector) unable to change volume' , cmd . error ) ; } async updateTVstatus ( nextState ) { const tvService = this . accessory . getService ( this . Service . Television ) ; const speakerService = this . accessory . getService ( this . Service . TelevisionSpeaker ) ; const customSpeakerService = this . accessory . getService ( this . Service . Fan ) ; if ( ! tvService || ! speakerService ) return ; speakerService . updateCharacteristic ( this . Characteristic . Active , nextState ) ; tvService . updateCharacteristic ( this . Characteristic . Active , nextState ) ; if ( nextState === true ) { const [ cmd , volume ] = [ await this . device . getMute ( ) , await this . getVolume ( ) ] ; const muted = Ok ( cmd ) ? cmd . value : true ; speakerService . updateCharacteristic ( this . Characteristic . Mute , muted ) . updateCharacteristic ( this . Characteristic . Volume , volume ) ; if ( customSpeakerService ) customSpeakerService . updateCharacteristic ( this . Characteristic . On , ! muted ) . updateCharacteristic ( this . Characteristic . RotationSpeed , volume ) ; } else if ( customSpeakerService ) customSpeakerService . updateCharacteristic ( this . Characteristic . On , nextState ) ; } async remoteControl ( keyId ) { const keys = { 0 : 'REW' , 1 : 'FF' , 2 : 'SKIP_NEXT' , 3 : 'SKIP_PREV' , 4 : 'UP' , 5 : 'DOWN' , 6 : 'LEFT' , 7 : 'RIGHT' , 8 : 'ENTER' , 9 : 'RETURN' , 10 : 'CANCEL' , 11 : 'PLAY' , 15 : 'HOME' } ; const action = ( keyId as number ) in keys ? keys [ keyId as number ] : 'HOME' ; this . log . debug ( 'remote control:' , action ) ; const cmd = await this . device . sendKey ( action ) ; if ( Abnormal ( cmd ) ) this . log . error ( '(remoteControl)/(%s) %s' , action , cmd . error ) ; } } export { InputVisibility , OnDisk , UserConfig , VieramaticPlatformAccessory } ;