air enough, here’s everything. It’s a LAN driver that talks ADB to a Hisense Canvas (Google TV). The driver itself works fine. Rules run local, commands work from Advanced, state polls every 15s. It’s only the phone app that won’t open the device page.
Device shows `lan.provisioningState` TYPED (now DRIVER_SWITCH after I tested a driver switch). Hub fw 000.062.00007. Already tried: hub reboot, clearing app cache, category Television → Switch, removing `mode`, custom presentation with toggleSwitch instead of standbyPowerSwitch.
[details=“config.yml”]
```yaml
name: ‘Hisense Canvas TV (ADB)’
packageKey: ‘hisense-canvas-adb’
permissions:
lan: {}
discovery: {}
description: ‘Local LAN control of a Hisense Canvas Google TV over ADB: Art Mode, power off, and live Off/Art/Watching state.’
```
[/details]
[details=“profiles/hisense-canvas-v2.yml”]
```yaml
name: hisense-canvas-v2
components:
-
id: main
label: TV Power
capabilities:
-
id: switch
version: 1
-
id: refresh
version: 1
categories:
-
id: art
label: Art Mode
capabilities:
categories:
-
id: offButton
label: Power Off
capabilities:
categories:
-
id: wakeButton
label: Wake (Home)
capabilities:
categories:
metadata:
mnmn: SmartThingsCommunity
vid: 7c77eab6-b745-3f8b-a300-46cd2520e82e
deviceType: Switch
ocfDeviceType: oic.d.switch
preferences:
```
[/details]
[details=“device-config.json (vid 7c77eab6-b745-3f8b-a300-46cd2520e82e)”]
```json
{
“dashboard”: {
"states": \[
{
"component": "main",
"capability": "switch",
"version": 1,
"idx": 0,
"group": "main",
"values": \[\],
"composite": false
}
\],
"actions": \[
{
"component": "main",
"capability": "switch",
"version": 1,
"idx": 0,
"group": "main",
"inline": null
}
\],
"basicPlus": \[\]
},
“detailView”: [
{
"component": "main",
"capability": "switch",
"version": 1,
"values": \[\],
"patch": \[
{
"op": "replace",
"path": "/0/displayType",
"value": "toggleSwitch"
},
{
"op": "add",
"path": "/0/toggleSwitch",
"value": {
"command": {
"on": "on",
"off": "off"
},
"state": {
"off": "off",
"on": "on",
"value": "switch.value"
}
}
},
{
"op": "remove",
"path": "/0/standbyPowerSwitch"
}
\]
},
{
"component": "main",
"capability": "refresh",
"version": 1,
"values": \[\],
"patch": \[\]
},
{
"component": "art",
"capability": "switch",
"version": 1,
"values": \[\],
"patch": \[
{
"op": "replace",
"path": "/0/displayType",
"value": "toggleSwitch"
},
{
"op": "add",
"path": "/0/toggleSwitch",
"value": {
"command": {
"on": "on",
"off": "off"
},
"state": {
"off": "off",
"on": "on",
"value": "switch.value"
}
}
},
{
"op": "remove",
"path": "/0/standbyPowerSwitch"
}
\]
},
{
"component": "offButton",
"capability": "momentary",
"version": 1,
"values": \[\],
"patch": \[\]
},
{
"component": "wakeButton",
"capability": "momentary",
"version": 1,
"values": \[\],
"patch": \[\]
}
],
“automation”: {
"conditions": \[
{
"component": "main",
"capability": "switch",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
},
{
"component": "art",
"capability": "switch",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
}
\],
"actions": \[
{
"component": "main",
"capability": "switch",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
},
{
"component": "main",
"capability": "refresh",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
},
{
"component": "art",
"capability": "switch",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
},
{
"component": "offButton",
"capability": "momentary",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
},
{
"component": "wakeButton",
"capability": "momentary",
"version": 1,
"values": \[\],
"patch": \[\],
"exclusion": \[\]
}
\]
},
“type”: “profile”
}
```
[/details]
[details=“src/init.lua (adb.lua / rsa.lua are just the ADB protocol + signing, can post if needed)”]
```lua
local Driver = require “st.driver”
local capabilities = require “st.capabilities”
local log = require “log”
local socket = require “cosock.socket”
local Adb = require “adb”
local hubkey = require “hubkey”
local NETWORK_ID = “hisense-canvas-adb”
local PROFILE = “hisense-canvas-v2”
local ART_APP = “com.vt.arttv.artmode”
local KEY_ART = “708”
local KEY_SLEEP = “KEYCODE_SLEEP”
local KEY_WAKEUP = “KEYCODE_WAKEUP”
local KEY_HOME = “KEYCODE_HOME”
local clients = {}
local busy = {}
local function client_for(device)
local c = clients[device.id]
if not c then
c = Adb.new(socket, hubkey, function(msg) log.info(msg) end)
clients\[device.id\] = c
end
return c
end
-- Serialize ADB use per device: handlers and the poll timer can interleave while a socket call yields.
local function with_adb(device, fn)
local waited = 0
while busy[device.id] do
socket.sleep(0.1)
waited = waited + 0.1
if waited > 60 then return nil, "adb busy" end
end
busy[device.id] = true
local adb = client_for(device)
local ok, result = pcall(function()
if not adb.sock then
adb:connect(device.preferences.ipAddress, 5555, 60)
end
return fn(adb)
end)
if not ok then
log.warn("adb error: " .. tostring(result))
adb:close()
end
busy[device.id] = nil
if ok then return result end
return nil, result
end
local function parse_state(out)
local wake = out:match(“mWakefulness=(%a+)”)
if not wake then return nil end
if wake ~= “Awake” then return “off” end
local app = out:match(“mResumedActivity: ActivityRecord{%S+ %S+ ([^/%s]+)”)
if app == ART_APP then return “art” end
return “watching”, app
end
local function refresh(driver, device)
local out = with_adb(device, function(adb)
return adb:shell("dumpsys power 2>/dev/null | grep -m1 mWakefulness=; "
.. "dumpsys activity activities 2>/dev/null | grep -m1 mResumedActivity")
end)
if not out then
device:offline()
return
end
local mode, app = parse_state(out)
if not mode then
log.warn("could not parse TV state: " .. out:sub(1, 200))
return
end
device:online()
if not device:get_field(“provisioned_after_handshake”) then
log.info("handshake ok, requesting PROVISIONED")
device:try_update_metadata({ provisioning_state = "PROVISIONED" })
device:set_field("provisioned_after_handshake", true)
end
if mode ~= device:get_field(“last_mode”) then
log.info(string.format("tv mode -> %s%s", mode, app and (" (" .. app .. ")") or ""))
device:set_field("last_mode", mode)
end
local sw = capabilities.switch.switch
device:emit_event(mode == “off” and sw.off() or sw.on())
if device.profile.components.art then
device:emit_component_event(device.profile.components.art, mode == "art" and sw.on() or sw.off())
end
end
local function send_key(driver, device, key)
local _, err = with_adb(device, function(adb) return adb:shell("input keyevent " .. key) end)
if err then log.error("key " .. key .. " failed: " .. tostring(err)) end
device.thread:call_with_delay(5, function() refresh(driver, device) end)
end
local function handle_on(driver, device, command)
send_key(driver, device, KEY_ART)
end
local function handle_off(driver, device, command)
send_key(driver, device, command.component == “art” and KEY_HOME or KEY_SLEEP)
end
local BUTTON_KEYS = { offButton = KEY_SLEEP, wakeButton = KEY_WAKEUP }
local function handle_push(driver, device, command)
local key = BUTTON_KEYS[command.component]
if key then send_key(driver, device, key) end
end
local function start_polling(driver, device)
if device:get_field(“poll_timer”) then
device.thread:cancel_timer(device:get_field("poll_timer"))
end
local seconds = tonumber(device.preferences.pollSeconds) or 15
device:set_field(“poll_timer”, device.thread:call_on_schedule(seconds, function() refresh(driver, device) end))
end
local function device_init(driver, device)
if not device.profile.components.art then
log.info("migrating device to profile " .. PROFILE)
device:try_update_metadata({ profile = PROFILE })
end
– The default doConfigure transition was not persisting; the app won’t open devices left in TYPED.
device:try_update_metadata({ provisioning_state = “PROVISIONED” })
start_polling(driver, device)
device.thread:call_with_delay(2, function() refresh(driver, device) end)
end
local function info_changed(driver, device, event, args)
if args.old_st_store.preferences.ipAddress ~= device.preferences.ipAddress then
client_for(device):close()
end
start_polling(driver, device)
refresh(driver, device)
end
local function device_removed(driver, device)
if clients[device.id] then clients[device.id]:close() end
clients[device.id], busy[device.id] = nil, nil
end
local function discovery(driver, opts, should_continue)
for _, d in ipairs(driver:get_devices()) do
if d.device_network_id == NETWORK_ID then return end
end
driver:try_create_device({
type = "LAN",
device_network_id = NETWORK_ID,
label = "Living Room Canvas TV",
profile = PROFILE,
manufacturer = "Hisense",
model = "Canvas",
vendor_provided_label = "Hisense Canvas TV",
})
end
local driver = Driver(“hisense-canvas-adb”, {
discovery = discovery,
lifecycle_handlers = {
init = device_init,
infoChanged = info_changed,
removed = device_removed,
},
capability_handlers = {
\[capabilities.switch.ID\] = {
\[capabilities.switch.commands.on.NAME\] = handle_on,
\[capabilities.switch.commands.off.NAME\] = handle_off,
},
\[capabilities.refresh.ID\] = {
\[capabilities.refresh.commands.refresh.NAME\] = refresh,
},
\[capabilities.momentary.ID\] = {
\[capabilities.momentary.commands.push.NAME\] = handle_push,
},
},
})
driver:run()
```
[/details]
Logcat, normal polling. Nothing shows up in the log when I tap the tile in the app.
```
2026-10-10T15:11:46.096Z INFO Hisense Canvas TV (ADB) <Device: 0485639c-… (Living Room Canvas TV 1)> emitting event: {“attribute_id”:“switch”,“capability_id”:“switch”,“component_id”:“main”,“state”:{“value”:“on”}}
2026-10-10T15:11:46.103Z INFO Hisense Canvas TV (ADB) <Device: 0485639c-… (Living Room Canvas TV 1)> emitting event: {“attribute_id”:“switch”,“capability_id”:“switch”,“component_id”:“art”,“state”:{“value”:“on”}}
2026-10-10T15:11:46.105Z DEBUG Hisense Canvas TV (ADB) Living Room Canvas TV 1 device thread event handled
2026-10-10T15:12:01.097Z INFO Hisense Canvas TV (ADB) <Device: 0485639c-… (Living Room Canvas TV 1)> emitting event: {“attribute_id”:“switch”,“capability_id”:“switch”,“component_id”:“main”,“state”:{“value”:“on”}}
2026-10-10T15:12:01.113Z INFO Hisense Canvas TV (ADB) <Device: 0485639c-… (Living Room Canvas TV 1)> emitting event: {“attribute_id”:“switch”,“capability_id”:“switch”,“component_id”:“art”,“state”:{“value”:“on”}}
```
On startup it logs `transitioning device to “PROVISIONED”` but the state never changes.