LOL no sooner did I say “all good”, that I started getting a string of errors in the log. Note, the TV at issue wasn’t/isn’t even powered on (nobody was in the house all day today), so I’m not sure what triggered this or what it means:
Hi guys !
I've just installed this driver and it works fine.
I noticed that, after setting the "Amazon Echo Skill" to use the TV device, I can only turn it on/off with Alexa.
Am I doing something wrong? Or do I need to develop a "wrapper" device to expose the commands (e.g. open Netflix) to Alexa?
Thanks in advance.
Just a heads-up that cost me half an hour of searching: If you rename your connection (My PC is connected to HDMI-4), it also changes in the Activities...
So 'activity' HDMI4 was missing and didn't do anything when I tried it... I have to use 'Pc' and then it switches correctly! ![]()
And @dandanache : Thanks for this awesome piece of code! ![]()
Just did some more testing... Switching 'On' doesn't work, but sending a WOL packet from my firewall's dhcp-server worked... After that I could send a 'screen on'...
I can see (in the debug log) that the driver tries to send a WOL packet, but the TV doesn't seem to respond to it, but responded instantly to the firewall's WOL?
(The Wired MAC address in Hubitat matches the MAC address in the firewall, the WiFi is OFF)
Anything I can do to troubleshoot why the WOL packet from the driver doesn't work, and the firewall turns it on instantly?
I am not much of a network guy, but I believe the WOL package is filtered by one of the devices/layers (switch/router/wlan/firewall/etc) between the Hubitat hub (that sends the WOL message) and the TV. I remember that there was another case like this in this thread.
That's not the problem, as the pfSense firewall - whose WOL does work - is attached to the same switch the C8 sits on.
I have since installed the separate WOL app (by RamDev) on my C8, and its WOL wakes the TV as well, so I now have a (kludgey) workaround:
- Send WOL packet from RamDev's APP
- Send Power On from the LG device
- Send Activity "Pc" from the LG device
So there's something different about how you send the WOL packet and how RamDev does it...
thanks you so much for this.
it works great but I have only a small issue.
current activity remains stuck even when tv is off.
This makes me unable to use a certain app as a trigger as if i turn on the tv fast and open the same app the activity remains unchanged.
Can you please make it blank the activity when is off or change it to null or something?
I'd greatly appreciate it, thanks
Released version 1.8.2 with the following small change:
Fixed
- Change current activity to
offwhen device is turned off - @ady.adrianu
Have fun!
thank you but i have another issue
i tried to connect it to a 2nd tv, model is lg c5 but the tv doesn't seem to pop permission asking , it worked straight away on my c2 , i did the exact same thing yet it cant seem to connect to my c5 , any idea?
I can ping the ip just fine from cmd or hubitat networking and is reachable
Is it possible LG changed their api or something? c2 is running web os23 while c5 is on web os 25
2025-11-09 15:51:00.821info
Lg websocket is offline [physical]
dev:17162025-11-09 15:51:00.819debug
Lg ▶ webSocketStatus(status: closing)
dev:17162025-11-09 15:51:00.782debug
Lg ◀ Sending websocket messages: [type:hello, id:hubitat_1762696260782, sourceId:hubitat.hub]
dev:17162025-11-09 15:51:00.779debug
Lg ▶ webSocketStatus(status: open)
dev:17162025-11-09 15:51:00.697info
Lg websocket is connecting [digital]
dev:17162025-11-09 15:51:00.584debug
Lg ▲ Connecting to 192.168.50.116 ...
dev:17162025-11-09 15:51:00.405debug
Lg pinging 192.168.50.116 ...
dev:17162025-11-09 15:50:55.510info
Lg websocket is offline [physical]
dev:17162025-11-09 15:50:55.507debug
Lg ▶ webSocketStatus(status: closing)
dev:17162025-11-09 15:50:55.476debug
Lg ◀ Sending websocket messages: [type:hello, id:hubitat_1762696255475, sourceId:hubitat.hub]
dev:17162025-11-09 15:50:55.473debug
Lg ▶ webSocketStatus(status: open)
dev:17162025-11-09 15:50:55.382info
Lg websocket is connecting [digital]
dev:17162025-11-09 15:50:55.271debug
Lg ▲ Connecting to 192.168.50.116 ...
dev:17162025-11-09 15:50:55.091debug
Lg pinging 192.168.50.116 ...
dev:17162025-11-09 15:50:50.420info
Lg websocket is offline [physical]
dev:17162025-11-09 15:50:50.418debug
Lg ▶ webSocketStatus
(status: closing)
so far i tried factory reset, changing region , changing ip and many other things, no damn prompt on the tv, spent half a day and couldn't get the c5 to connect as c2. I restested c2 and it prompts the connection right away when clicking save on ip, but c5, nothing... m-a disperat
yeah seems to be a known problem on home assistant too
the fix seems to be here Fix system info by thecode · Pull Request #501 · home-assistant-libs/aiowebostv · GitHub
hopefully you can adapt with the new way of pairing ,I can test it for you, thank you
So after several hours of messing with the code and talking to chat gpt I managed to fix it myself applying home assistant solution , TV pairing prompt now works and after that everything works as normal. Be aware that my code sets the ping interval in seconds not minutes as I wanted faster response, everything else is unchanged.
Please update the app asap as the new webos version is rolling to older models too, 5 and 4 series already got it.
Here's the fully working code, dai o bere, dupa ce dau si eu pt driver ![]()
/**
* LGTV with webOS
*
* @see https://codeberg.org/dan-danache/hubitat/src/branch/main/lgtv-drivers
*/
import groovy.json.JsonBuilder
import groovy.transform.CompileStatic
import groovy.transform.Field
import hubitat.device.HubAction
import hubitat.device.Protocol
import hubitat.helper.NetworkUtils
import com.hubitat.app.ChildDeviceWrapper
@Field static final String DRIVER_NAME = 'LGTV with webOS'
@Field static final String DRIVER_VERSION = '1.8.2'
@Field static final List<String> PICTURE_MODES = ['cinema', 'eco', 'expert1', 'expert2', 'game', 'normal', 'photo', 'sports', 'technicolor', 'vivid', 'hdrEffect', 'filmMaker', 'hdrCinema']
@Field static final List<String> SOUND_MODES = ['aiSoundPlus', 'aiSound', 'standard', 'news', 'music', 'movie', 'sports', 'game']
@Field static final List<String> SOUND_OUTPUT = ['tv_speaker', 'external_arc', 'external_optical', 'bt_soundbar', 'mobile_phone', 'lineout', 'headphone', 'tv_speaker_bluetooth']
@Field static final Integer MAX_FAST_PING = 20
metadata {
definition(name:DRIVER_NAME, namespace:'dandanache', author:'Dan Danache', importUrl:'https://codeberg.org/dan-danache/hubitat/raw/branch/main/lgtv-drivers/lgtv-with-webos.groovy') {
capability 'Actuator'
capability 'Initialize'
capability 'Refresh'
capability 'Switch'
capability 'AudioVolume'
capability 'TV'
capability 'Notification'
capability 'MediaController'
capability 'MediaTransport'
capability 'ImageCapture'
capability 'SpeechSynthesis'
attribute 'websocket', 'enum', ['connecting', 'online', 'offline']
attribute 'channelName', 'string'
attribute 'screen', 'enum', ['on', 'off', 'standby', 'screensaver']
attribute 'pictureBrightness', 'number'
attribute 'pictureContrast', 'number'
attribute 'pictureColor', 'number'
attribute 'pictureBacklight', 'number'
attribute 'pictureMode', 'enum', PICTURE_MODES
attribute 'soundOutput', 'enum', SOUND_OUTPUT
attribute 'soundMode', 'enum', SOUND_MODES
attribute 'pingInterval', 'number'
}
command 'deviceNotification', [
[name:'Text*', type:'STRING', description:'Notification text*'],
[name:'Type', type:'ENUM', description:'Notification type', constraints:['Toast - Goes away after few seconds', 'Alert - Stays on screen until dismissed']]
]
command 'setChannel', [[name:'Channel number*', type:'STRING']]
command 'screenOn'
command 'screenOff'
command 'setPicture', [
[name:'Brightness', type:'NUMBER', description:'Picture brightness (1 to 100)'],
[name:'Contrast', type:'NUMBER', description:'Picture contrast (1 to 100)'],
[name:'Color', type:'NUMBER', description:'Picture color (1 to 100)'],
[name:'Backlight', type:'NUMBER', description:'Picture backlight (1 to 100)'],
[name:'Mode', type:'ENUM', description:'Select picture mode', constraints:PICTURE_MODES.sort()]
]
command 'setPingInterval', [[name:'Interval*', type:'NUMBER', description:'How often to check if the TV was turned on using the remote (in seconds, 0=disabled)']]
command 'setSoundOutput', [[name:'Output*', type:'ENUM', description:'Select sound output', constraints:SOUND_OUTPUT.sort()]]
command 'startVideo', [[name:'URL*', type:'STRING', description:'URL of video file to play']]
command 'startWebPage', [[name:'URL*', type:'STRING', description:'URL to open in Web Browser']]
command 'screenSaverOn'
command 'screenSaverOff'
command 'pushRemoteButtons', [[name:'TV Remote buttons sequence <a href="https://codeberg.org/dan-danache/hubitat/src/branch/main/lgtv-drivers/README.md#tv-remote-control" target="_blank" title="Learn more about this">📖</a> *', type:'STRING']]
command "resetPairingKey"
preferences {
input(
name:'helpInfo', type:'hidden',
title:"""
<div style="min-height:55px; background:transparent url('https://codeberg.org/dan-danache/hubitat/raw/branch/main/lgtv-drivers/img/lgtv.webp') no-repeat left center;background-size:auto 55px;padding-left:65px">
LGTV with webOS <small>v${DRIVER_VERSION}</small><br>
<small><div>
• <a href="https://codeberg.org/dan-danache/hubitat/src/branch/main/lgtv-drivers/README.md" target="_blank">driver details</a><br>
• <a href="https://community.hubitat.com/t/release-lgtv-with-webos/148892" target="_blank">community page</a><br>
</div></small>
</div>
"""
)
input(
name:'logLevel', type:'enum', title:'Log verbosity', required:true,
description: 'Choose the messages that appear in the "Logs" section',
options: [
'1' : 'Debug - log everything',
'2' : 'Info - log important events',
'3' : 'Warning - log events that require attention',
'4' : 'Error - log errors'
],
defaultValue: '1'
)
input(
name:'ipAddr', type:'string', title:'IP address', required:true,
description: 'Enter the device IP address'
)
input(
name:'useSSL', type:'bool', title:'Enable SSL', required:true,
description:'Enable SSL when connecting to the TV websocket',
defaultValue:true
)
input(
name:'simulateRemote', type:'bool', title:'Simulate TV Remote', required:true,
description:'Allows you to simulate TV Remote button pushes (creates a child device)',
defaultValue:false
)
}
}
// Called when the device is first added
void installed() {
log_warn 'Installing device ...'
// Init state
state.activities = [:]
utils_sendEvent name:'switch', value:'off', descriptionText:'Power initialized to off', type:'digital'
utils_sendEvent name:'websocket', value:'offline', descriptionText:'Websocket initialized to offline', type:'digital'
setPingInterval 5
}
// Called when the "Save Preferences" button is clicked
void updated(boolean auto = false) {
log_info "Saving preferences${auto ? ' (auto)' : ''} ..."
if (logLevel == null) {
logLevel = '1'
device.updateSetting 'logLevel', [value:logLevel, type:'enum']
}
if (logLevel == '1') runIn 1800, 'logsOff'
log_info "🛠️ Log verbosity = ${logLevel}"
// Update device network Id
if (ipAddr != null) {
device.deviceNetworkId = ipAddr.tokenize('.').collect { String.format('%02X', it.toInteger()) }.join()
connect()
log_info "🛠️ IP address = ${ipAddr}"
}
if (useSSL == null) {
useSSL = true
device.updateSetting 'useSSL', [value:useSSL, type:'bool']
}
log_info "🛠️ Enable SSL = ${useSSL}"
if (simulateRemote == null) {
simulateRemote = false
device.updateSetting 'simulateRemote', [value:simulateRemote, type:'bool']
}
log_info "🛠️ Simulate TV Remote = ${simulateRemote}"
ChildDeviceWrapper childDevice = getChildDevice "${device.deviceNetworkId}-Remote"
if (simulateRemote) {
childDevice = childDevice ?: addChildDevice('dandanache', 'LGTV Remote', "${device.deviceNetworkId}-Remote", [name:'LGTV Remote', label:"${device.displayName} - Remote", isComponent:true])
log_info "🛠️ TV Remote child device = ${childDevice}"
} else {
if (childDevice) {
log_debug "🛠️ Removing child device ${childDevice} ..."
deleteChildDevice "${device.deviceNetworkId}-Remote"
}
}
}
void setPingInterval(BigDecimal pingInterval) {
if (pingInterval < 0) {
log_error("Invalid ping interval specified: ${pingInterval} minutes")
return
}
// Update attribute
int newValue = pingInterval.toInteger()
utils_sendEvent name:'pingInterval', value:newValue, unit:'seconds', descriptionText:"Ping interval is ${newValue} min ${newValue == 0 ? ' (disabled)' : ''}", type:'digital'
// Schedule periodic device ping
unschedule('pingDevice')
if (newValue == 0) return
schedule "0/${newValue} * * ? * * *", 'pingDevice'
// Ping right now
pingDevice()
}
void pingDevice() {
if (!ipAddr || "${device.currentValue('websocket', true)}" == 'online') return
log_debug "Pinging ${ipAddr} ..."
if (NetworkUtils.ping(ipAddr, 1)?.packetsReceived > 0) connect()
}
void logsOff() {
log_info '⏲️ Automatically reverting log level to "Info"'
device.updateSetting 'logLevel', [value:'2', type:'enum']
}
// ===================================================================================================================
// Implement Capabilities
// ===================================================================================================================
// capability.Initialize
// This method will run when the hub starts
void initialize() {
state.lastTx = 0
state.lastRx = 0
connect(true)
}
// capability.Refresh
void refresh() {
log_debug '🎬 Refreshing device state ...'
// We also have subscription for this values
utils_sendMessage([type:'request', uri:'ssap://audio/getVolume'])
utils_sendMessage([type:'request', uri:'ssap://tv/getCurrentChannel'])
// These values rarely change, so we only request them on demand
utils_sendMessage([type:'request', uri:'ssap://system/getSystemInfo'])
utils_sendMessage([type:'request', uri:'ssap://com.webos.service.update/getCurrentSWInformation'])
utils_sendMessage([type:'request', uri:'ssap://settings/getSystemSettings', payload:[category:'network', keys: ['deviceName']]])
utils_sendMessage([type:'request', uri:'ssap://config/getConfigs', payload:[configNames:['tv.nyx.*']]])
// https://github.com/JPersson77/LGTVCompanion/blob/master/Docs/Commandline.md
// https://github.com/chros73/bscpylgtv/blob/master/docs/available_settings_CX.md
//utils_sendMessage([type:'request', uri:'ssap://settings/getSystemSettings', payload:[category:'sound', keys: ['soundMode']]])
//utils_sendMessage([type:'request', uri:'ssap://config/getConfigs', payload:[configNames:['audio.*']]])
}
// capability.Switch
void on() {
log_debug '🎬 Powering on ...'
util_wakeOnLan getDataValue('wifiMacAddress')
util_wakeOnLan getDataValue('wiredMacAddress')
if (ipAddr) util_wakeOnLan getMACFromIP(ipAddr)
// Start fast pinging the IP for a maximum of MAX_FAST_PING times
runIn 1, 'fastPing', [data:[currentRetry:0]]
}
private void fastPing(Map data) {
data.currentRetry += 1
if (!ipAddr || "${device.currentValue('switch', true)}" == 'on' || data.currentRetry > MAX_FAST_PING) {
log_debug 'Fast ping terminated'
return
}
log_debug "Fast pinging ${ipAddr}: ${data.currentRetry} / ${MAX_FAST_PING} ..."
if (NetworkUtils.ping(ipAddr, 1)?.packetsReceived > 0 && "${device.currentValue('switch', true)}" != 'on') connect()
// Keep fast-pinging until switch changes to 'on' or MAX_FAST_PING limit is reached
runIn 1, 'fastPing', [data:data]
}
void off() {
log_debug '🎬 Powering off ...'
utils_sendMessage([type:'request', uri:'ssap://system/turnOff'])
utils_sendEvent name:'switch', value:'off', descriptionText:'Power is off', type:'digital'
}
// capability.AudioVolume
void mute() {
log_debug '🎬 Muting sound ...'
utils_sendMessage([type:'request', uri:'ssap://audio/setMute', payload:[mute:true]])
}
void unmute() {
log_debug '🎬 Unmuting sound ...'
utils_sendMessage([type:'request', uri:'ssap://audio/setMute', payload:[mute:false]])
}
void volumeUp() {
log_debug '🎬 Raising volume ...'
utils_sendMessage([type:'request', uri:'ssap://audio/volumeUp'])
}
void volumeDown() {
log_debug '🎬 Lowering volume ...'
utils_sendMessage([type:'request', uri:'ssap://audio/volumeDown'])
}
void setVolume(String level) { setVolume Integer.parseInt(level) }
void setVolume(BigDecimal level) {
log_debug "🎬 Setting volume to ${level}% ..."
if (level < 0 || level > 100) return
utils_sendMessage([type:'request', uri:'ssap://audio/setVolume', payload:[volume:level]])
}
// capability.TV
void channelUp() {
log_debug '🎬 Changing channel up ...'
utils_sendMessage([type:'request', uri:'ssap://tv/channelUp'])
}
void channelDown() {
log_debug '🎬 Changing channel down ...'
utils_sendMessage([type:'request', uri:'ssap://tv/channelDown'])
}
// capability.Notification
void deviceNotification(String text, String type = 'Toast') {
log_debug "🎬 Sending ${type} notification ..."
if (type.startsWith('Alert') || text.startsWith('!')) {
utils_sendMessage([type:'request', uri:'"ssap://system.notifications/createAlert', payload:[message:text.replaceAll('^!', ''), buttons:[[label:'Dismiss']]]])
} else {
utils_sendMessage([type:'request', uri:'"ssap://system.notifications/createToast', payload:[message:text]])
}
}
// capability.MediaController
void getAllActivities() {
log_debug '🎬 Getting all activities ...'
// Remove old activities
Map<String, String> activities = [:]
activities['com.webos.app.home'] = 'Home'
activities['com.webos.app.livetv'] = 'Live TV'
activities['com.webos.app.miracast'] = 'Miracast'
state.activities = activities
utils_sendMessage([type:'request', uri:'ssap://tv/getExternalInputList'])
utils_sendMessage([type:'request', uri:'ssap://com.webos.applicationManager/listLaunchPoints'])
}
void getCurrentActivity() {
log_debug '🎬 Getting current activity ...'
utils_sendMessage([type:'request', uri:'ssap://com.webos.applicationManager/getForegroundAppInfo'])
}
void startActivity(String activityname) {
String appId = state.activities.find { it.value == activityname }?.key ?: activityname
log_debug "🎬 Starting activity: [${activityname}] (${appId}) ..."
utils_sendMessage([type:'request', uri:'ssap://system.launcher/launch', payload:[id:appId]])
}
// capability.MediaTransport
void play() {
log_debug '🎬 Play ...'
utils_sendMessage([type:'request', uri:'ssap://media.controls/play'])
utils_sendEvent name:'transportStatus', value:'playing', descriptionText:'Media transport status is playing', type:'digital'
}
void pause() {
log_debug '🎬 Pause ...'
utils_sendMessage([type:'request', uri:'ssap://media.controls/pause'])
utils_sendEvent name:'transportStatus', value:'paused', descriptionText:'Media transport status is paused', type:'digital'
}
void stop() {
log_debug '🎬 Stop ...'
utils_sendMessage([type:'request', uri:'ssap://media.controls/stop'])
utils_sendEvent name:'transportStatus', value:'stopped', descriptionText:'Media transport status is stopped', type:'digital'
}
// capability.ImageCapture
void take() {
log_debug '🎬 Taking a screenshot ...'
utils_sendMessage([type:'request', uri:'ssap://tv/executeOneShot', payload:[path:'/tmp/capture.jpg', method:'DISPLAY', format:'JPG']])
}
// capability.SpeechSynthesis
void speak(String text, BigDecimal volume = null, String voice = null) {
log_debug "🎬 Speaking text [${text}] ..."
Map result = textToSpeech(text, voice)
log_debug "Sending TTS file ${result} ..."
// utils_sendMessage([type:'request', uri:'ssap://com.webos.applicationManager/open', payload:[target:result.uri, mime:'audio/mp3']])
utils_sendMessage([type:'request', uri:'ssap://com.webos.applicationManager/launch', payload:[
id: state.activities.find { it.value == 'Media Player' || it.value == 'Music' }?.key ?: 'com.webos.app.mediadiscovery',
params: [payload: [[
fullPath:result.uri, mediaType:'MUSIC', deviceType:'DMR', lastPlayPosition:0,
fileName: "Message from ${location.hub.name}",
thumbnail: "http://${location.hub.localIP}/ui2/images/apple-touch-icon.png",
dlnaInfo: [
protocolInfo: 'http-get:*:audio/mpeg:DLNA.ORG_PN=MP3;DLNA.ORG_OP=01;DLNA.ORG_FLAGS=01500000000000000000000000000000',
contentLength: '-1',
duration: result.duration,
opVal: 1,
flagVal: 0,
cleartextSize: '-1'
]
]]]
]])
}
// ===================================================================================================================
// Implement custom commands
// ===================================================================================================================
void setChannel(String channel) {
log_debug "🎬 Changing channel to [${channel}] ..."
utils_sendMessage([type:'request', uri:'ssap://tv/openChannel', payload:[channelNumber:channel]])
}
void screenOn() {
log_debug '🎬 Turning screen on ...'
utils_sendMessage([type:'request', uri:'ssap://com.webos.service.tvpower/power/turnOnScreen', payload:[standbyMode:'active']])
}
void screenOff() {
log_debug '🎬 Turning screen off ...'
utils_sendMessage([type:'request', uri:'ssap://com.webos.service.tvpower/power/turnOffScreen', payload:[standbyMode:'active']])
}
void setPicture(BigDecimal brightness, BigDecimal contrast = null, BigDecimal color = null, BigDecimal backlight = null, String mode = null) {
log_debug "🎬 Setting picture to: brightness=${brightness}, contrast=${contrast}, color=${color}, backlight=${backlight}, mode=${mode}] ..."
Map<String, String> settings = [:]
if (brightness && brightness >= 1 && brightness <= 100) settings.put 'brightness', brightness.intValue().toString()
if (contrast && contrast >= 1 && contrast <= 100) settings.put 'contrast', contrast.intValue().toString()
if (color && color >= 1 && color <= 100) settings.put 'color', color.intValue().toString()
if (backlight && backlight >= 1 && backlight <= 100) settings.put 'backlight', backlight.intValue().toString()
if (mode) settings.put 'pictureMode', mode
if (settings.size() > 0) utils_sendMessage([type:'request', uri:'ssap://settings/setSystemSettings', payload:[category:'picture', settings:settings]])
}
void setSoundOutput(String output) {
log_debug "🎬 Setting sound output to: [${output}] ..."
utils_sendMessage([type:'request', uri:'ssap://settings/setSystemSettings', payload:[category:'sound', settings: [soundOutput:output]]])
//utils_sendMessage([type:'request', uri:'com.webos.service.apiadapter/audio/changeSoundOutput', payload:[output:output]]])
}
void startVideo(String url) {
log_debug "🎬 Playing video file: [${url}]..."
// https://gist.github.com/aabytt/bddbb1bcf031a050d89a89aeee3a6737#playling-a-link-with-standard-lg-webos-player
utils_sendMessage([type:'request', uri:'ssap://com.webos.applicationManager/launch', payload:[
id: state.activities.find { it.value == 'Media Player' || it.value == 'Photo & Video' }?.key ?: 'com.webos.app.mediadiscovery',
params: [payload: [[fullPath:url, mediaType:'VIDEO', deviceType:'DMR', lastPlayPosition:0]]]
]])
}
void startWebPage(String url) {
log_debug "🎬 Opening web page: [${url}]..."
utils_sendMessage([type:'request', uri:'ssap://com.webos.applicationManager/launch', payload:[
id: 'com.webos.app.browser',
params: [target:url]
]])
}
void screenSaverOn() {
log_debug '🎬 Starting screen saver ...'
utils_sendMessage([type:'request', uri:'ssap://system.launcher/launch', payload:[id:'com.webos.app.screensaver']])
}
void screenSaverOff() {
log_debug '🎬 Stopping screen saver ...'
utils_sendMessage([type:'request', uri:'ssap://system.launcher/close', payload:[id:'com.webos.app.screensaver']])
}
void pushRemoteButtons(String buttonSequence) {
if (!simulateRemote) {
log_info 'The "Simulate TV Remote" option is currently disabled in the "Preferences" tab'
state.remove 'remoteButtonSequence'
return
}
if ("${device.currentValue('switch', true)}" != 'on') {
log_info 'Cannot push TV Remote buttons as device is not switched on'
state.remove 'remoteButtonSequence'
return
}
log_debug "🎬 Pushing TV Remote buttons: ${buttonSequence} ..."
state.remoteButtonSequence = buttonSequence.toUpperCase()
utils_sendMessage([type:'request', uri:'ssap://com.webos.service.networkinput/getPointerInputSocket'])
}
// ===================================================================================================================
// Websocket helpers
// ===================================================================================================================
void connect(boolean force = false) {
if (!ipAddr) {
log_warn 'Device IP address not set in Preferences tab. Make sure you are using DHCP reservation for the TV\'s IP address!'
return
}
if (!force && "${device.currentValue('websocket', true)}" == 'connecting') {
int timeoutIn = state.containsKey('lastWx') ? state.lastWx + 25000 - now() : 0
if (timeoutIn > 0) {
log_debug "✋ Another connection is already in progress; it will time out in ${timeoutIn/1000} seconds"
return
}
}
log_debug "▲ Connecting to ${ipAddr} ${force ? '(forced) ' : ''}..."
disconnect()
interfaces.webSocket.connect(useSSL ? "wss://${ipAddr}:3001/" : "ws://${ipAddr}:3000/", headers: ['Content-Type': 'application/json'], ignoreSSLIssues: true)
utils_sendEvent name:'websocket', value:'connecting', descriptionText:'Websocket is connecting', type:'digital'
state.lastWx = now()
// Send hello
utils_sendMessage([type:'hello', id:'hello'], false)
// NEW: Pre-registration system info for newer TVs
Map sysInfoRequest = [type:'request', id:'pre_reg_sys_info', uri:'ssap://system/getSystemInfo', payload:[:]]
utils_sendMessage(sysInfoRequest, false)
// Delay registration slightly to ensure system info arrives first
runIn(1, 'register')
}
void disconnect() {
try { interfaces.webSocket.close() } catch (e) { }
}
/**
* Resets the pairing key so that the TV prompts for pairing again.
*/
void resetPairingKey() {
log_warn "🔑 Resetting stored client-key and forcing re-pairing..."
// Clear saved key
state.remove('pk')
// Disconnect and reconnect to trigger new pairing
disconnect()
pauseExecution(1000)
connect(true)
// Notify user
deviceNotification("Hubitat pairing reset. Please accept the new pairing request on your TV.")
}
void register() {
if (state.pk) {
utils_sendMessage([type:'register', payload:['client-key':state.pk]], false)
return
}
utils_sendMessage([
type: 'register',
payload: [
pairingType: 'PROMPT',
manifest: [
appVersion: '1.1',
manifestVersion: 1,
permissions: ['LAUNCH', 'LAUNCH_WEBAPP', 'APP_TO_APP', 'CLOSE', 'TEST_OPEN', 'TEST_PROTECTED', 'CONTROL_AUDIO', 'CONTROL_DISPLAY', 'CONTROL_INPUT_JOYSTICK', 'CONTROL_INPUT_MEDIA_RECORDING', 'CONTROL_INPUT_MEDIA_PLAYBACK', 'CONTROL_INPUT_TV', 'CONTROL_POWER', 'READ_APP_STATUS', 'READ_CURRENT_CHANNEL', 'READ_INPUT_DEVICE_LIST', 'READ_NETWORK_STATE', 'READ_RUNNING_APPS', 'READ_TV_CHANNEL_LIST', 'WRITE_NOTIFICATION_TOAST', 'READ_POWER_STATE', 'READ_COUNTRY_INFO', 'READ_SETTINGS', 'CONTROL_TV_SCREEN', 'CONTROL_TV_STANBY', 'CONTROL_FAVORITE_GROUP', 'CONTROL_USER_INFO', 'CHECK_BLUETOOTH_DEVICE', 'CONTROL_BLUETOOTH', 'CONTROL_TIMER_INFO', 'STB_INTERNAL_CONNECTION', 'CONTROL_RECORDING', 'READ_RECORDING_STATE', 'WRITE_RECORDING_LIST', 'READ_RECORDING_LIST', 'READ_RECORDING_SCHEDULE', 'WRITE_RECORDING_SCHEDULE', 'READ_STORAGE_DEVICE_LIST', 'READ_TV_PROGRAM_INFO', 'CONTROL_BOX_CHANNEL', 'READ_TV_ACR_AUTH_TOKEN', 'READ_TV_CONTENT_STATE', 'READ_TV_CURRENT_TIME', 'ADD_LAUNCHER_CHANNEL', 'SET_CHANNEL_SKIP', 'RELEASE_CHANNEL_SKIP', 'CONTROL_CHANNEL_BLOCK', 'DELETE_SELECT_CHANNEL', 'CONTROL_CHANNEL_GROUP', 'SCAN_TV_CHANNELS', 'CONTROL_TV_POWER', 'CONTROL_WOL'],
signatures: [[
signatureVersion: 1,
signature: 'eyJhbGdvcml0aG0iOiJSU0EtU0hBMjU2Iiwia2V5SWQiOiJ0ZXN0LXNpZ25pbmctY2VydCIsInNpZ25hdHVyZVZlcnNpb24iOjF9.hrVRgjCwXVvE2OOSpDZ58hR+59aFNwYDyjQgKk3auukd7pcegmE2CzPCa0bJ0ZsRAcKkCTJrWo5iDzNhMBWRyaMOv5zWSrthlf7G128qvIlpMT0YNY+n/FaOHE73uLrS/g7swl3/qH/BGFG2Hu4RlL48eb3lLKqTt2xKHdCs6Cd4RMfJPYnzgvI4BNrFUKsjkcu+WD4OO2A27Pq1n50cMchmcaXadJhGrOqH5YmHdOCj5NSHzJYrsW0HPlpuAx/ECMeIZYDh6RMqaFM2DXzdKX9NmmyqzJ3o/0lkk/N97gfVRLW5hA29yeAwaCViZNCP8iC9aO0q9fQojoa7NQnAtw==',
]],
signed: [
created: '20140509',
appId: 'com.lge.test',
vendorId: 'com.lge',
localizedAppNames: ['':'LG Remote App', 'ko-KR':'리모컨 앱', 'zxx-XX':'ЛГ Rэмotэ AПП'],
localizedVendorNames: ['': 'LG Electronics'],
permissions: ['TEST_SECURE', 'CONTROL_INPUT_TEXT', 'CONTROL_MOUSE_AND_KEYBOARD', 'READ_INSTALLED_APPS', 'READ_LGE_SDX', 'READ_NOTIFICATIONS', 'SEARCH', 'WRITE_SETTINGS', 'WRITE_NOTIFICATION_ALERT', 'CONTROL_POWER', 'READ_CURRENT_CHANNEL', 'READ_RUNNING_APPS', 'READ_UPDATE_INFO', 'UPDATE_FROM_REMOTE_APP', 'READ_LGE_TV_INPUT_EVENTS', 'READ_TV_CURRENT_TIME'],
serial: '2f930e2d2cfe083771f68e4fe7bb07'
]
]
]
], false)
}
void subscribe() {
// Setup subscriptions
utils_sendMessage([type:'subscribe', uri:'ssap://audio/getStatus'])
utils_sendMessage([type:'subscribe', uri:'ssap://tv/getCurrentChannel'])
utils_sendMessage([type:'subscribe', uri:'ssap://com.webos.applicationManager/getForegroundAppInfo'])
utils_sendMessage([type:'subscribe', uri:'ssap://com.webos.media/getForegroundAppInfo'])
utils_sendMessage([type:'subscribe', uri:'ssap://com.webos.service.tvpower/power/getPowerState'])
utils_sendMessage([type:'subscribe', uri:'ssap://settings/getSystemSettings', payload:[category:'picture', keys:['brightness', 'contrast', 'color', 'backlight', 'pictureMode']]])
utils_sendMessage([type:'subscribe', uri:'ssap://settings/getSystemSettings', payload:[category:'sound', keys:['soundMode', 'soundOutput']]])
// First time with talk with this device
if (!getDataValue('modelName')) {
// Enable Wake on LAN
log_debug 'Enabling Wake On LAN (WOL) ...'
utils_sendMessage([type:'request', uri:'ssap://settings/setSystemSettings', payload:[category:'network', settings: [wolwowlOnOff:'true']]])
// Get some device information that never changes
utils_sendMessage([type:'request', uri:'ssap://system/getSystemInfo'])
utils_sendMessage([type:'request', uri:'ssap://com.webos.service.connectionmanager/getinfo'])
// Send a notification on TV
deviceNotification "<b>Mesage from ${location.hub.name}</b><br>Well done! Configuration is now complete 👍"
}
}
// ===================================================================================================================
// Websocket callback
// ===================================================================================================================
void webSocketStatus(String message) {
log_debug "▶ webSocketStatus(${message})"
String websocket = utils_parseStatus message
// If websocket just opened, say hello (skip state checks)
if (websocket == 'online') {
utils_sendMessage([type:'hello', id:'hello'], false)
return
}
// Update "websocket" attribute
utils_sendEvent name:'websocket', value:websocket, descriptionText:"Websocket is ${websocket}", type:'physical'
}
def resetPairing() {
log_warn "Manual pairing reset requested"
try {
interfaces.webSocket.close()
} catch (e) { }
state.remove('pk')
state.remove('registered')
state.remove('lastRegister')
runIn(3, 'connect')
}
void parse(String description) {
log_debug "▶ Received message: ${description}"
state.lastRx = now()
Map msg = parseJson(description)
String type = msg.payload?.callerId == 'secondscreen.client' || msg.payload?.callerId == 'com.webos.service.apiadapter' || now() - (state.lastTx ?: 0) < 2000 ? 'digital' : 'physical'
// Empty reponse. Thanks for nothing!
if (msg.payload.keySet().size() == 1 && (msg.payload.returnValue == true || msg.payload.subscription == true)) return
switch (msg.type) {
// TV sent an error message. Oh noes!
case 'error':
def errText = msg.payload?.errorText ?: ''
if (errText.contains('client-key does not exist')) {
log_warn "Stored client-key invalid; removing and forcing re-pairing sequence."
try {
interfaces.webSocket.close()
} catch (e) {
log_debug "WebSocket close error (expected): ${e.message}"
}
state.remove('pk')
// Give Hubitat a moment to fully close before reconnecting
runIn(5, 'connect')
return
}
if (msg.payload?.errorCode == 401) {
log_warn "Authentication failed (401). Clearing pairing key."
interfaces.webSocket.close()
state.remove('pk')
runIn(12, 'connect')
return
}
// Ignore unsupported config keys and TV bind fails
if (msg.payload?.errorText?.startsWith('Some keys are not allowed') || msg.payload?.errorText?.startsWith('com.webos.service.utp/bind returns invalid result')) return
// Live TV weird error
if (msg.payload?.errorText ==~ /^broadcastId = .* is already closed$/) return
// Service not supported on some (old) devices
if (msg.error == '404 no such service or method') return
case 'error':
def errMsg = msg.payload?.errorText ?: msg.error
if (errMsg?.contains('client-key does not exist')) {
log_warn "⚠️ Invalid or missing client-key detected. Resetting pairing automatically..."
resetPairingKey()
return
}
// Ignore unsupported config keys and TV bind fails
if (msg.payload?.errorText?.startsWith('Some keys are not allowed') ||
msg.payload?.errorText?.startsWith('com.webos.service.utp/bind returns invalid result')) return
// Live TV weird error
if (msg.payload?.errorText ==~ /^broadcastId = .* is already closed$/) return
// Service not supported on some (old) devices
if (msg.error == '404 no such service or method') return
log_error "▶ Received error message: ${errMsg}"
return
// Hello (request)
case 'hello':
log_debug '▶ Proper protocol has been observed. Starting authentication ...'
// Start registration
runIn 1, 'register'
return
// Register (request)
case 'registered':
log_debug '▶ Authentication complete. Oh yeah!'
state.pk = msg.payload['client-key']
// Update "websocket" and "switch" attributes
utils_sendEvent name:'websocket', value:'online', descriptionText:'Websocket is online', type:'physical'
utils_sendEvent name:'switch', value:'on', descriptionText:'Power is on', type:'physical'
// Setup subscriptions
runIn 1, 'subscribe'
// Auto-sync device state with Hubitat
runIn 7, 'refresh'
return
// TV sent a response to a command sent by us
case 'response':
Map payload = msg.payload
switch (payload) {
// ssap://audio/getStatus (subscription)
case { contains it, [volumeStatus:null] }:
int volume = payload.volumeStatus.volume
String mute = payload.volumeStatus.muteStatus ? 'muted' : 'unmuted'
String soundOutput = payload.volumeStatus.soundOutput ?: ''
utils_sendEvent name:'volume', value:volume, unit:'%', descriptionText:"Sound volume is ${volume}%", type:type
utils_sendEvent name:'mute', value:mute, descriptionText:"Sound is ${mute}", type:type
utils_sendEvent name:'soundOutput', value:soundOutput, descriptionText:"Sound output is ${soundOutput}", type:type
return
case { contains it, [mute:null, volume:null] }:
int volume = payload.volume
String mute = payload.mute ? 'muted' : 'unmuted'
utils_sendEvent name:'volume', value:volume, unit:'%', descriptionText:"Sound volume is ${volume}%", type:type
utils_sendEvent name:'mute', value:mute, descriptionText:"Sound is ${mute}", type:type
return
// Pre-registration system info
case { contains it, [id:'pre_reg_sys_info'] }:
// Update modelName and other static info before registration
utils_dataValue('modelName', payload.modelName)
utils_dataValue('fwVersion', "${payload.product_name} / ${payload.major_ver}.${payload.minor_ver}")
log_debug "🖥️ Pre-registration system info received: ${payload.modelName} / ${payload.major_ver}.${payload.minor_ver}"
return
// ssap://tv/getCurrentChannel (subscription)
case { contains it, [channelNumber:null] }:
String channel = payload.channelNumber
String channelName = payload.channelName ?: 'unknown'
utils_sendEvent name:'channel', value:channel, descriptionText:"Channel is ${channel}", type:type
utils_sendEvent name:'channelName', value:channelName, descriptionText:"Channel name is ${channelName}", type:type
return
// ssap://com.webos.applicationManager/getForegroundAppInfo (subscription)
case { contains it, [appId:'', processId:'', windowId:''] }:
utils_sendEvent name:'switch', value:'off', descriptionText:'Power is off', type:type
utils_sendEvent name:'currentActivity', value:'off', descriptionText:"Current activity is off", type:type
disconnect()
return
case { contains it, [appId:null] }:
utils_sendEvent name:'switch', value:'on', descriptionText:'Power is on', type:type
String currentActivity = state.activities?.find { it.key == payload.appId }?.value ?: 'unknown'
utils_sendEvent name:'currentActivity', value:currentActivity, descriptionText:"Current activity is ${currentActivity}", type:type
return
// ssap://settings/getSystemSettings (subscription)
case { contains it, [category:'picture'] }:
String pictureBrightness = payload.settings?.brightness ?: ''
String pictureContrast = payload.settings?.contrast ?: ''
String pictureColor = payload.settings?.color ?: ''
String pictureBacklight = payload.settings?.backlight ?: ''
String pictureMode = payload.settings?.pictureMode ?: ''
utils_sendEvent name:'pictureBrightness', value:pictureBrightness, unit:'%', descriptionText:"Picture brightness is ${pictureBrightness}%", type:type
utils_sendEvent name:'pictureContrast', value:pictureContrast, unit:'%', descriptionText:"Picture contrast is ${pictureContrast}%", type:type
utils_sendEvent name:'pictureColor', value:pictureColor, unit:'%', descriptionText:"Picture color is ${pictureColor}%", type:type
utils_sendEvent name:'pictureBacklight', value:pictureBacklight, unit:'%', descriptionText:"Picture backlight is ${pictureBacklight}%", type:type
utils_sendEvent name:'pictureMode', value:pictureMode, descriptionText:"Picture mode is ${pictureMode}", type:type
return
case { contains it, [category:'sound'] }:
String soundOutput = payload.settings?.soundOutput
utils_sendEvent name:'soundOutput', value:soundOutput, descriptionText:"Sound output is ${soundOutput}", type:type
String soundMode = payload.settings?.soundMode
utils_sendEvent name:'soundMode', value:soundMode, descriptionText:"Sound mode is ${soundMode}", type:type
return
// ssap://com.webos.media/getForegroundAppInfo (subscription)
case { contains it, [foregroundAppInfo:null] }:
String transportStatus = utils_parseForegroundAppInfo(payload.foregroundAppInfo)
utils_sendEvent name:'transportStatus', value:transportStatus, descriptionText:"Media transport status is ${transportStatus}", type:type
return
// ssap://tv/getExternalInputList (request)
case { contains it, [devices:null] }:
Map activities = state.activities ?: [:]
payload.devices.each { activities[it.appId] = it.label }
state.activities = activities
runIn 5, 'updateActivities'
return
// ssap://com.webos.applicationManager/listLaunchPoints (request)
case { contains it, [launchPoints:null] }:
Map activities = state.activities ?: [:]
payload.launchPoints.each { activities[it.id] = it.title }
state.activities = activities
runIn 5, 'updateActivities'
return
// ssap://settings/getSystemSettings (request)
case { contains it, [category:'network'] }:
utils_dataValue 'networkName', payload.settings?.deviceName
return
// ssap://com.webos.service.update/getCurrentSWInformation (request)
case { contains it, [sw_type:null] }:
utils_dataValue 'fwVersion', "${payload.product_name} / ${payload.major_ver}.${payload.minor_ver}"
return
// ssap://com.webos.service.tvpower/power/turnOnScreen (request)
case { contains it, [state:'Screen Off'] }:
utils_sendEvent name:'screen', value:'off', descriptionText:'Screen is off', type:type
return
case { contains it, [state:'Active'] }:
utils_sendEvent name:'screen', value:'on', descriptionText:'Screen is on', type:type
return
case { contains it, [state:'Screen Saver'] }:
utils_sendEvent name:'screen', value:'screensaver', descriptionText:'Screen is in screensaver mode', type:type
return
case { contains it, [state:'Active Standby'] }:
utils_sendEvent name:'screen', value:'standby', descriptionText:'Screen is in active standby', type:type
return
// ssap://system/getSystemInfo (request)
case { contains it, [modelName:null] }:
utils_dataValue 'modelName', payload.modelName
utils_dataValue 'receiverType', payload.receiverType
return
// ssap://com.webos.service.connectionmanager/getinfo (request)
case { contains it, [wifiInfo:null] }:
case { contains it, [wiredInfo:null] }:
utils_dataValue 'wifiMacAddress', payload.wifiInfo?.macAddress
utils_dataValue 'wiredMacAddress', payload.wiredInfo?.macAddress
return
// ssap://config/getConfigs (request)
case { contains it, [configs:null] }:
utils_dataValue 'platformVersion', payload.configs['tv.nyx.platformVersion']
return
// ssap://com.webos.service.networkinput/getPointerInputSocket (request)
case { contains it, [socketPath:null] }:
getChildDevice("${device.deviceNetworkId}-Remote").pushButtons(payload.socketPath, "${state.remoteButtonSequence}")
state.remove 'remoteButtonSequence'
return
// ssap://tv/executeOneShot
case { contains it, [imageUri:null] }:
String image = payload.imageUri
utils_sendEvent name:'image', value:image, descriptionText:"Capture URL is ${image}", type:type
return
// Pairing prompt (request)
case { contains it, [pairingType:'PROMPT'] }:
log_warn '🙋♂️ Go and accept the pairing request on your TV screen. HAUL ■■■!'
return
// ssap://audio/getVolume (request)
case { contains it, [soundOutput:null] }:
String soundOutput = payload.soundOutput ?: ''
utils_sendEvent name:'soundOutput', value:soundOutput, descriptionText:"Sound output is ${soundOutput}", type:type
if (payload.containsKey('muteStatus')) {
String mute = payload.muteStatus ? 'muted' : 'unmuted'
utils_sendEvent name:'mute', value:mute, descriptionText:"Sound is ${mute}", type:type
}
return
// Other messages we don't care about
case { contains it, [muteStatus:null] }:
case { contains it, [sessionId:null] }:
case { contains it, [volume:null] }:
case { contains it, [toastId:null] }:
case { contains it, [alertId:null] }:
case { contains it, [missingConfigs:null] }:
case { contains it, [returnValue:true, method:'setSystemSettings'] }:
return
}
}
// Unexpected websocket message
log_error "🚩 Received unexpected websocket message: description=${description}"
}
// ===================================================================================================================
// Logging helpers (something like this should be part of the SDK and not implemented by each driver)
// ===================================================================================================================
private void log_debug(String message, String prefix = device.displayName) {
if (logLevel == '1') log.debug "${prefix} ${message.uncapitalize()}"
}
private void log_info(String message, String prefix = device.displayName) {
if (logLevel <= '2') log.info "${prefix} ${message.uncapitalize()}"
}
private void log_warn(String message, String prefix = device.displayName) {
if (logLevel <= '3') log.warn "${prefix} ${message.uncapitalize()}"
}
private void log_error(String message, String prefix = device.displayName) {
log.error "${prefix} ${message.uncapitalize()}"
}
// ===================================================================================================================
// Helper methods (keep them simple, keep them dumb)
// ===================================================================================================================
private void utils_sendMessage(Map message, boolean checkState = true) {
if (!ipAddr) {
log_warn 'Device IP address not set in Preferences tab. Make sure you are using DHCP reservation for the TV\'s IP Address!'
return
}
if (checkState && "${device.currentValue('switch', true)}" != 'on') {
log_info 'Device is not switched on. Command not sent.'
return
}
if (checkState && "${device.currentValue('websocket', true)}" != 'online') {
log_info 'Websocket is not connected anymore. Connecting now ...'
runIn 1, 'connect'
return
}
// Send websocket message
state.lastTx = now()
message.id = "hubitat_${now()}"
message.sourceId = 'hubitat.hub'
log_debug "◀ Sending websocket messages: ${message}"
String payload = new JsonBuilder(message)
interfaces.webSocket.sendMessage(payload)
}
private void utils_sendEvent(Map event) {
if (event.value == null || event.value == '') return
if ("${device.currentValue(event.name, true)}" != "${event.value}") {
log_info "${event.descriptionText} [${event.type}]"
} else {
log_debug "${event.descriptionText} [${event.type}]"
}
sendEvent event
}
private void utils_dataValue(String key, String value) {
if (value == null || value == '') return
log_debug "Update data value: ${key}=${value}"
updateDataValue key, value
}
private String utils_parseStatus(String message) {
switch (message) {
case 'status: open': return 'online'
case 'status: closing': return 'offline'
case ~/^failure: connect timed out.*/: return 'offline'
case ~/^failure: unexpected end of stream on http:\/\/.*/:
log_warn 'Failed to establish a stable websocket connection to your TV. You should enable SSL in the Preferences tab. DO IT!'
return 'offline'
case ~/^failure: .*/:
log_info "Oh snap! ${message}"
return 'offline'
default: return 'offline'
}
}
private String utils_parseForegroundAppInfo(List<Map> foregroundAppInfo) {
String playState = foregroundAppInfo.find { it.windowId != '' }?.playState
if (playState == 'loaded' || playState == 'playing') return 'playing'
return playState == 'paused' ? 'paused' : 'stopped'
}
private void util_wakeOnLan(String macAddr) {
if (macAddr == null || macAddr == '') return
String cmd = "wake on lan ${macAddr.replaceAll(':', '').toUpperCase()}"
log_debug "◀ Sending LAN command: ${cmd}"
sendHubCommand(new HubAction(cmd, Protocol.LAN))
}
void updateActivities() {
List<String> activities = state.activities*.value.sort()
utils_sendEvent name:'activities', value:new JsonBuilder(activities), descriptionText:"Supported activities is ${activities}", type:type
}
// switch/case syntactic sugar
@CompileStatic private boolean contains(Map msg, Map spec) {
return msg.keySet().containsAll(spec.keySet()) && spec.every { it.value == null || it.value == msg[it.key] }
}
Edit: Improved the code to reset the stored pairing key to fix "401 client-key does not exist"error,
also added a new Reset Pairing Key command for manual reset.
Basically when I made a new virtual device and tried to connect to the same tv I would get this error because it was already paired, now it handles this better and should automatically reset the pairing key and prompt for a new pair on tv.
I haven't tested backward compatibity for older webos versions but it should handle the old firmwares too, if someone can confirm is still works on older versions like the original code would be nice.
I'm having the same issue on a B5 I recently purchased. After I click save, no notification appears for approval.
i already posted the solution, pay attention
I am paying attention and I did see your solution. I'm just backing you up that there is an issue with the newer models/webos.
Released version 1.8.3 with the following fix:
Fixed
- Fix registration on WebOS 33.20.66+ - @ady.adrianu
Note: you may need to remove and re-add the devices that failed to register properly.
Kudos to @ady.adrianu (and his Black Friday TV
) for raising the issue and also providing the fix ![]()
Have fun!
