Everything here runs over the LAN: no LG cloud, no Nabu Casa, no rooting the TV, nothing
sideloaded onto it. Home Assistant talks to the set with the built-in webostv integration,
and Google only ever sees HA's own fulfillment.
Written up because ThinQ was a dead end for me and most of the individual pieces below are either undocumented, wrong in the obvious place, or only findable by reading the integration source.
Tested on an OLED65C56LA, firmware 33.31.68, HA 2026.7.4.
Contents
- Why not LG ThinQ
- Gotcha: the TV registers twice
- Turning the TV on — Wake-on-LAN cannot work, so: IR
- Screen off — a dark panel on a TV that is still on
- Launching apps by voice
- Repurposing the remote's Netflix / Prime / Shahid keys
- Exposing it all to Google
- Files
ThinQ cannot link a Middle-East-region TV to a UK Google account. The set is region-locked, the
Google account is not, and the ThinQ side never completes. HA's lg_thinq config entry failed on
every start with Error setting up entry LG ThinQ for lg_thinq … TimeoutError and served nothing.
Going through webostv sidesteps the region check completely — the integration speaks to the TV
directly over the LAN and no LG cloud is in the path. Everything except power-on works this
way; power-on cannot use the network at all (see below).
The set shows up as two media_player entities:
| Entity | Platform | Use it? |
|---|---|---|
media_player.lg_webos_tv_<model> |
Google Cast discovery | No — reports off even while the TV is on |
media_player.lg_webos_tv_<model>_2 |
webostv |
Yes |
The _2 suffix is purely because Cast claimed the name first. Check platform in the entity
registry before wiring anything to a TV entity — a power automation on the Cast entity looks
plausible and never fires.
If you expose both to Google you also get the same TV twice in the Home app, so mark the Cast one
expose: false explicitly.
webostv can only turn the TV off. Powered down, the set leaves the LAN and there is no socket
to send anything to. HA's answer is the webostv.turn_on trigger: an automation on it is the
turn-on action, and media_player.turn_on runs whatever that automation does.
Registering it has two effects, and both are required for a working Google exposure:
- The entity stops going
unavailablewhen off — it reports a cleanoffinstead. This is deliberate in the integration:_set_availability(bool(self._turn_on))(media_player.py:330), i.e. it refuses to claim "off" when it has no way to undo that. supported_featuresgainsTURN_ON(bit 128): 24381 → 24509. Computed live from the trigger registration (supported_featuresproperty,media_player.py:339). Without such an automation Google is handed a TV it can turn off and never on.
⚠ Use the
webostv.turn_ontrigger, not a plainstatetrigger. It is aPluggableAction, and the registration itself is what the entity checks. A hand-rolled automation watching the entity would fire your turn-on action but leaveTURN_ONunadvertised to Google.
Worth checking before you spend an evening on it, as I did. My TV's network is a TP-Link UE300
USB-to-Ethernet adapter, not the C5's own port — the MAC starts 00:e0:4c, the Realtek OUI
(RTL8153). The MAC is correct and the magic packet is fine. The failure is physical:
| TV state | ARP for the TV's IP (checked from two different hosts) |
|---|---|
| on | REACHABLE, pings, webostv connects |
| off | nothing — no reply at all |
LG cuts USB port power in standby, so the adapter dies with the TV and there is no interface left to hear the packet. No setting fixes this: Quick Start+ does not power USB, and Mobile TV On only arms LG's own NICs.
The obvious fixes both had a catch for me: the C5's built-in Ethernet is 100 Mb (4K remux streaming wants the adapter's gigabit), and Wi-Fi is 2.4 GHz-only in this region.
⚠ "Turn on via Bluetooth" is not a path either, even with the TV setting enabled. It only accepts a phone registered through the LG ThinQ app — the same proprietary handshake already blocked by the region mismatch. Nothing public reverse-engineers it; every wake project (LGTVCompanion, PyWebOSTV, LG_Buddy, the HA integration itself) does WoL or ThinQ.
If your TV is on its built-in NIC, plain Wake-on-LAN under the webostv.turn_on trigger is the
easy answer and you can skip the rest of this section. Also enable Quick Start+ (Settings →
General → Devices → TV Management) so the set keeps its network stack alive when off.
Turn-on has to leave the network entirely, so: a Tuya IR blaster in the same room facing the
TV, driven by tuya_local — local push, no Tuya
cloud in the path.
⚠ HA's core Tuya integration cannot drive an IR blaster — it comes through as (unsupported)
and yields no remote entity at all. tuya_local does. For an S11+ IR+RF Pro (product id
nwpvjlq7qacvnqf3) the s11_rfir_remote config matches exactly and gives you
remote.ir_blaster on DP 201 send / 202 receive.
Two tuya_local gotchas:
- The blaster answers with no DPs at all when idle (
LOCAL DPS: {"updated_at": …}), sotuya_locallogsDevice matches None with quality of 0%and cannot auto-select a type. The right config is still offered in the list — pick it by hand. The 0% is not a failure. tuya_localdiscovers Tuya devices itself. A discovery flow for the blaster's IP may already be sitting at thelocalstep waiting for a key, so a fresh "add integration" aborts withalready_in_progress. Finish the pending flow instead — list flows over the websocket API withconfig_entries/flow/progress.
No code learning is needed. remote.send_command accepts a raw code with a b64: prefix
(remote.py:_extract_codes), and tinytuya — bundled by tuya_local — encodes NEC to Tuya's
pulse format:
from tinytuya.Contrib.IRRemoteControlDevice import IRRemoteControlDevice as IR
IR.pulses_to_base64(IR.nec_to_pulses(0x20DF23DC)) # LG discrete power ON| LG NEC code | meaning | use |
|---|---|---|
20DF23DC |
discrete power ON | the turn-on automation |
20DFA35C |
discrete power OFF | unused — webostv turns off over the socket, more reliable |
20DF10EF |
power toggle | avoid: state-dependent |
Use the discrete code and you need no state condition: re-sending it to a TV that is already on
is a no-op, so a redundant "turn on the TV" from Google cannot switch it off. Learned codes work
too (remote.learn_command), but the physical LG remote only emits the toggle.
Needs line of sight to the TV's IR receiver.
Result, driving the exact call Google makes: media_player.turn_off → off over the socket;
media_player.turn_on → IR → the TV pings after ~8 s and HA reports on after ~16 s.
"Turn off the TV screen" blanks the panel while webOS keeps running: audio keeps playing, the app
stays in the foreground, the TV stays on the LAN, and HA still reports the media_player as on.
That last part is what makes it safe to add — none of your existing to: 'off' automations see
anything happen.
Plain webOS call, no payload:
| Command | Resulting getPowerState |
|---|---|
com.webos.service.tvpower/power/turnOffScreen |
Screen Off |
com.webos.service.tvpower/power/turnOnScreen |
Active |
com.webos.service.tvpower/power/getPowerState |
Active / Screen Saver / Screen Off |
curl -s -X POST -H "Authorization: Bearer $HA_TOKEN" -H 'Content-Type: application/json' \
-d '{"entity_id":"media_player.lg_webos_tv_x_2","command":"com.webos.service.tvpower/power/turnOffScreen"}' \
"$HA_URL/api/services/webostv/command?return_response"Fronted by input_boolean.tv_screen, where ON means the panel is lit. The switch is inverted
against its name on purpose: the phrase people say is "turn off the TV screen", and that has
to land on turning a switch off. A switch literally named Screen Off would need the absurd
"turn on screen off".
⚠ The resync poll is not optional. Any remote button press wakes the panel and webOS pushes no event for it, so without polling the switch sits at
offwhile the picture is back — and the next "turn off the TV screen" is a silent no-op, because the switch is already off, so nothing changes state and nothing triggers. The poll only runs while the switch says off and the TV is on, so it is idle the rest of the time. It costs one re-fire with aturnOnScreenat an already-lit panel, which the TV ignores.
"Turn on Jellyfin" launches the app, powering the TV on first if it is off. One input_boolean
per app:
| Switch | Source selected on the TV |
|---|---|
input_boolean.tv_jellyfin |
Jellyfin |
input_boolean.tv_youtube |
YouTube |
input_boolean.tv_spotify |
Spotify - Music and Podcasts |
input_boolean.tv_ps5 |
PS5 Game Console (an HDMI input, not an app — same wiring) |
Why switches and not Google's InputSelector trait, which HA already advertises with all 16
sources on it:
- The phrasing is "switch the TV input to Jellyfin". A mouthful.
- That input list is only correct while the TV is on. After an HA restart with a dark TV,
_update_sourceshas nothing to enumerate, and if Google re-syncs in that window you get a TV whose only input isLive TV. (media_player.py:294keeps the previous list in memory — but that memory is gone across a restart.) TYPE_SWITCHis the category Gemini still acts on from a locked phone. See below.
Two load-bearing details in the automation:
- The cold-start wait.
select_sourceon a powered-off TV does nothing —webostvhas no socket. So it callsmedia_player.turn_on(→ the IR automation), waits for stateon, then sleeps 6 s more: webOS accepts the connection before its launcher is ready, and aselect_sourcesent in that window is silently dropped. input_boolean.tv_app_switching— an unexposed guard flag. Switching Jellyfin → YouTube has to clear the Jellyfin switch, and without the guard the "switch turned off" automation would read its own cleanup as "the user is done watching" and power the TV down mid-launch. Every automation that writes these switches sets the flag first.
A third automation clears all four switches when the TV goes off by any route, so Google stops showing a dark TV as running Jellyfin.
Worth knowing if you control HA by voice from a phone rather than a speaker. Gemini gates what it
will do on a locked phone by device category. It allows lights & power (lights, outlets,
switches), climate (AC, thermostats, fans), window coverings and media — and
action.devices.types.SCENE is not on that list. Asking it to "activate Movie Mode" while
locked produced an unlock prompt every time, and no request ever reached HA.
First, the setting that has to be on regardless — it is not the classic Assistant one, Gemini has its own: Gemini app → profile picture → Settings → Gemini on lock screen → "Use Gemini without unlocking".
Then: HA derives the Google device type purely from the entity domain (const.py:142), so
re-fronting something with a different domain changes its category:
| HA domain | Google type | Gemini acts on it when locked? |
|---|---|---|
scene, script, button, input_button |
TYPE_SCENE |
no |
switch, input_boolean, group |
TYPE_SWITCH |
yes |
Which is the real reason everything above is an input_boolean.
The Magic Remote's Netflix / Prime Video / Disney+ / Shahid keys are hardwired to launch those app ids. You can land them somewhere useful instead:
| Key | Lands on | How |
|---|---|---|
| Netflix | Jellyfin |
select_source |
| Prime Video | YouTube |
select_source |
| Shahid | HDMI 4 | webostv.command → system.launcher/launch com.webos.app.hdmi4 |
Nothing is modified on the TV. HA watches the source attribute and switches away ~1 s after
the app starts — the swap happens on the splash screen, so no sign-in is ever needed on the
app being hijacked.
The documented way to remap these keys (Ircama's
gist) is to uninstall the app and
sideload a shim that claims its app id — or, on a rooted set,
magic_mapper /
lginputhook. Neither fits a 2025 set:
- Root is not available. Per
faultmanager-autoroot, "for 2025 models, the only vulnerable firmware I've seen came preinstalled from the factory (webOS 10.0); any updates (webOS 10.1+) are patched." Mine is an OTA build, so patched. DejaVuln is patched on recent models too. Root would mean a firmware downgrade — the step that actually bricks TVs. - Dev-mode sideloading is safe but does not stick. Dev-mode sessions expire after ~50 h and take the sideloaded apps with them, so it needs a renewal cron forever. The gist's newest confirmed success is webOS 7.x / C2 (2022); nothing for webOS 25.
source reports the running app even when that app is filtered out of the config entry's source
list. _update_sources sets _current_source and injects the app into the list on the
app["id"] == tv_state.current_app_id branch, before the conf_sources filter is applied. So no
config-entry change is needed to see Netflix / Prime Video / MBC Shahid.
- ⚠ The apps must stay installed. The trigger is the app launching. Uninstalled, the key falls
back to the Content Store (
com.webos.app.discovery) — the same app id for every key and for opening the store by hand, so the keys become indistinguishable. Hide the icons from the home row instead of deleting the apps. - The Disney+ key is unusable if Disney+ is not installed: it falls back to the web browser,
and triggering on
Web Browserwould also fire on deliberate browser use. - HDMI labels drift. Port 4 reported
HDMI 4, thenPConce a source was attached.select_sourcematches on the label, so switch by app id (com.webos.app.hdmi4) instead. - webOS preloads Netflix at boot. Preload stays in the background and should not move
source— but if a spurious app switch shows up at power-on, that is your cause.
Via the Google Home cloud-to-cloud integration (a Google Cloud project kept permanently in Draft — no publishing, no verification, no review), account-linked to HA over OAuth, with HA reachable on a public HTTPS hostname. No Nabu Casa.
Allowlist-style exposure, which is airtight for a reason worth knowing:
GoogleConfig.should_expose() (google_assistant/http.py:170) computes
is_default_exposed = domain_exposed_by_default and not auxiliary_entity, where
domain_exposed_by_default is expose_by_default and <domain in exposed_domains>. With
expose_by_default: false that term is always False and the function collapses to
return explicit_expose — the per-entity flag. Nothing outside entity_config can ever be
advertised, whatever you add to the instance later.
⚠ There is no
filter:key forgoogle_assistant, whatever else you may read.GOOGLE_ASSISTANT_SCHEMAisextra=vol.PREVENT_EXTRAand accepts exactlyproject_id,expose_by_default,exposed_domains,entity_config,secure_devices_pin,report_state,service_account. HA refuses to start with anything else.
⚠ Changing an exposed entity id changes the Google device id. Google drops one device and gains another, so run
google_assistant.request_syncafter any such rename or the old device lingers in the Home app.
Check what Google actually sees without saying a word out loud:
curl -s -X POST -H "Authorization: Bearer $HA_TOKEN" -H 'Content-Type: application/json' \
-d '{"inputs":[{"intent":"action.devices.SYNC"}],"requestId":"chk"}' \
"$HA_URL/api/google_assistant" | python3 -m json.tool| File | What |
|---|---|
tv-turn-on-ir.yaml |
the webostv.turn_on trigger → IR power-on |
tv-screen.yaml |
screen off/on + the resync poll |
tv-apps.yaml |
app launching by voice |
tv-hotkeys.yaml |
repurposed remote keys |
google_assistant.yaml |
the entity_config block |
Replace media_player.lg_webos_tv_oled65c56la_2 with your own entity id throughout, and the IR
b64: blob with whatever tinytuya gives you (it is generic LG, so it may well work as-is).
Automation triggers arm ~60 s after a restart, not when /api/ starts answering — they wait
for homeassistant_started, and a slow integration can hold up bootstrap. A switch flipped in that
window changes state and fires nothing, which looks exactly like a broken automation. Wait for
Initialized trigger … in the log before concluding anything.