Internals¶
How this works on the inside, and the platform quirks that shaped it. Nothing here is needed to use the project - see Installing and the dashboard for that.
Architecture¶
┌─────────────────────────────────────────┐
│ LG webOS TV (Rooted) │
│ (Node 0.12) │
│ ┌───────────────────┐ ┌─────────────┐ │
│ │ HTTP Dashboard UI │ │ MiniMQTT │ │
│ │ (Port 8080) │ │ Client │ │
│ └─────────┬─────────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ In-Flight Concurrency Mutex & Caching │
│ │ │ │
│ ▼ ▼ │
│ Direct execFile (luna-send -w 2000) │
│ webOS Luna Bus & /proc telemetry │
└───────────────────────────────┬─────────┘
│
MQTT TCP 1883 │ (Telemetry + Controls)
▼
┌─────────────────────────────────────────┐
│ MQTT Broker / Mosquitto │
└───────────────────────┬─────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ Home Assistant │
│ (Auto-Discovered Entities) │
└─────────────────────────────────────────┘
High-Stability Process Execution¶
Older Linux kernels and Node 0.12 can encounter process deadlocks or child leaks when child_process.exec() is called frequently (spawning /bin/sh without timeout parameters).
tvweb.js solves this with:
1. Direct execFile: Invokes /usr/bin/luna-send directly with zero shell overhead.
2. Internal Daemon Timeout: Luna calls use -w 2000 to prevent orphaned background processes if a system bus stalls.
3. In-Flight Concurrency Mutex: If multiple HTTP pollers or MQTT intervals request stats simultaneously, they are coalesced into a single execution pipeline.
4. Memory Caching: Telemetry is cached for 1.5 seconds, delivering sub-20ms HTTP responses with zero subprocess spawning during rapid UI updates.
5. Deterministic MQTT Client Session: Uses a static client ID and periodic availability reaffirmation so TV reboots or network reconnects never leave entities trapped in an "Unavailable" state.
What it costs the TV¶
Measured with scripts/footprint.sh on 2026-10-03, over a minute each, with MQTT publishing every 10s and no dashboard open:
| TV | Server CPU (one core) | Server memory | luna-send children | TV memory available |
|---|---|---|---|---|
| OLED65B8SLC (webOS 4) | 1.4–1.7% | 29–42 MB | 5, 6 MB | 432–445 of 1976 MB |
| OLED42C24LA (webOS 22) | 1.2–1.3% | 35–38 MB | 5, 10 MB | 525–535 of 1996 MB |
The children are the five subscriptions held open for live state. Memory is the lower figure just after a start and grows towards the higher one. An open dashboard adds the reads behind each refresh; ./scripts/footprint.sh <tv-ip> measures it for any TV.
Platform constraints¶
- Node.js v0.12 (2015): webOS 4.x ships Node v0.12.2. All code in
tvweb.jsis written in strict ES5 (nolet/const, no arrow functions, no template literals, noasync/await). - BusyBox
run-partsHook Naming: The webosbrew startup system invokes user hooks withrun-parts /var/lib/webosbrew/init.d. BusyBoxrun-partsstrictly ignores any filename containing a dot (.), so the boot hook must be named50-tvwebwithout.sh. - Luna Bus Introspection: Control commands interact with webOS via native
luna-sendcalls (com.webos.audio,com.webos.service.tvpower,com.webos.applicationManager,com.webos.notification,com.webos.service.settings,com.webos.service.eim).
eMMC health vs wear¶
Under the JEDEC eMMC 5.0 specification, /sys/block/mmcblk0/device/life_time returns byte estimates for SLC and MLC partition write cycles:
- 0x01 indicates 0% – 10% of rated device write cycles used.
- This means >90% of drive life remains (Healthy).
- pre_eol_info returning 01 indicates normal endurance (<80% reserved blocks consumed).
To prevent user confusion, tvweb.js translates this into both a human-friendly health state (>90% (Healthy)) and a wear estimate (0-10% used · Normal EOL).
Where the telemetry comes from¶
webOS 4.x has no generic Linux thermal interface. /sys/class/thermal exists
but is empty, and there is no hwmon at all, so any guide pointing at
thermal_zone*/temp returns nothing on this hardware. LG exposes its own tree
instead:
| Path | Meaning |
|---|---|
/proc/lg/pm/temperature |
SoC temperature, plain °C (not millidegrees) |
/proc/lg/pm/current_load |
CPU load, % |
/proc/lg/pm/frequency |
kHz |
/proc/lg/pm/status |
per-core load, governor, AVS currents |
/sys/block/mmcblk0/device/life_time |
eMMC wear (0x01 = 0–10% used) |
/sys/block/mmcblk0/device/pre_eol_info |
01 Normal / 02 Warning / 03 Urgent |
/mnt/lg/cmn_data/mrcu/mrcu1.info |
Magic Remote battery percentage, remote model, BDAddr, and firmware |
/proc/lg/hdmi20/port[0-3]/status |
Real-time HDMI receiver PHY mode (FRL 48 Gbps vs TMDS), chroma (RGB 4:4:4), HDCP, cable error counter, ALLM, VRR |
/proc/lg/pe/hdr_status |
Picture engine live video format, colorimetry standard (BT.709, BT.2020), and peak nit levels |
/var/luna/preferences/environmentCondition |
Hardware configuration (SoC generation _O22_, DDR RAM, refresh rate, eye sensor) |
Do not read /proc/lg/pm/ts_enable — it segfaults the reading process.
webOS 3.9 has no temperature source at all: /proc/lg/pm/temperature is absent,
nothing under /proc/lg or /sys is named for temperature, /sys/class/thermal is
empty, there is no hwmon, and systemproperty rejects every temperature key. The
server reports this as capabilities.thermal: false so the dashboard can distinguish
it from the ~80s post-boot window where the file exists but reads 0.
CPU use: /proc/stat per core, not LG's load line¶
LG hot-plugs CPU cores (/proc/lg/pm/mp_enable), so the aggregate cpu line in
/proc/stat can go backwards between samples: the idle figure has been seen
dropping from 324186 to 228324 across two reads seconds apart. The per-core
lines are used instead, comparing only cores present in both samples whose
counters both advanced.
The load: line in /proc/lg/pm/status and current_load are instant readings
against the cores and clock that are up. On a C2 the line read 34% beside 6%
measured over the same seconds, and Home Assistant, sampling it once a minute
in standby, showed 6-64% for a TV doing little. So load is the busy share of
the whole processor since the previous reading (at least 5 s), with offline
cores counted as idle; cores is each online core's own share. LG's figures
are used only where /proc/stat gives nothing.
OLED panel counters, and their units¶
The panel timers do not share a unit, which is the single easiest thing to get wrong here. Furthermore, webOS 9+ (webOS 22+, e.g. LG C2) moved several counters to a dedicated service and changed filesystem file paths:
| Value | Older webOS (B8, 4.x–8.x) | Modern webOS (C2, 9.x / 22+) | Unit |
|---|---|---|---|
| Panel usage time | com.webos.service.tv.systemproperty/getSystemProperties (panelUsageTime) |
com.webos.service.panelcontroller/getPanelUsageTime (panelUsageTime) |
10-minute units — divide by 6 for hours |
| Last compensation | lastCompensationTimestamp (Luna) |
/mnt/lg/cmn_data/pnwash/autoOffRsLastTime |
10-minute units (Luna) / whole hours (fs) |
| Off-RS hours (fs) | /mnt/lg/cmn_data/pnwash/autoOffRsTime |
/mnt/lg/cmn_data/pnwash/autoOffRsLastTime |
whole panel hours |
| Refresher hours (fs) | /mnt/lg/cmn_data/pnwash/autoPnwashTime |
/mnt/lg/cmn_data/pnwash/autoJbLastTime |
whole panel hours |
| Off-RS interval | /mnt/lg/cmn_data/pnwash/autoOffRsIntervalHomeMode (24) |
/mnt/lg/cmn_data/pnwash/autoOffRsInterval (4) |
10-min units (older) / whole hours (newer) |
| Refresher cadence | Constant (2,000h) | /mnt/lg/cmn_data/pnwash/autoJbInterval (2000 ok) |
whole panel hours |
| Off-RS completed cycles | — | /mnt/lg/cmn_data/pnwash/completedOffRsCount |
integer count |
| JB refresher cycles | — | /mnt/lg/cmn_data/pnwash/completedJbCount |
integer count |
| Compensation failures | — | /mnt/lg/cmn_data/pnwash/failAlertCount |
integer count |
| GSR stress events | — | com.webos.service.oledepl/getGlobalStressReduction (stressCount) |
integer count |
| Panel silicon info | — | com.webos.service.panelcontroller/getOledCellInfo / getOledTconInfo |
Cell ID & TCON FPGA FW |
On older TVs, the interval file reading 24 means four hours, matching LG's documented
cumulative-viewing cycle — not twenty-four. It is expressed in the same 10-minute units as
the Luna counters it gets compared against, while autoOffRsTime alongside it is in
hours. Confirmed on a live TV: autoOffRsTime 3426 against a panelUsageTime
of 20576 (÷6 = 3429).
On webOS 9+ TVs, autoOffRsInterval is expressed directly in whole hours (4),
autoJbInterval reports 2000 ok, and panelcontroller/getPanelUsageTime provides
the live usage counter in 10-minute units. Confirmed on an LG C2: autoOffRsLastTime 4767
against a panelUsageTime of 28614 (÷6 = 4769).
Panel detection¶
Panel-lifecycle features are gated on panel type, detected once via
/var/luna/preferences/paneltype_oled, the model name (OLED..., or one of the OLED lines
whose model numbers lack the prefix: Flex and Objet Posé LX, Easel ART9x), or pnwash
filesystem records. The pnwash check also runs when the model name is known, so an unlisted
OLED without the prefix is still found. panelUsageTime is not a signal: LCD TVs report it too. On an LCD/QNED TV they are
omitted from the dashboard and withheld from MQTT discovery, with retained discovery configs
cleared so they do not linger in Home Assistant as orphans. Reporting 0 hours would read as a real
measurement.
Deploying over ssh¶
Two things bite when moving off telnet, both because an inline ssh command
becomes the remote shell's own argv:
pkill -f tvweb.jskills the shell running it. Its command line contains that path, so it matches itself. The bracket trick does not save you either, since the path appears again in the start command. Hencetvwebctl: inside a script file the shell's argv is just the script.setsid ... &does not detach. The child inherits the ssh session's stdin and dies when the connection closes — the server starts, publishes discovery, then vanishes.start-stop-daemon -b -msurvives.
rsync ships with the Homebrew Channel but is broken on-device: it cannot load
libcrypto.so.1.1. Use scp, which works over the sftp subsystem.
Upgrading in place¶
Five things decide how tvwebctl update works.
The HTTP client is probed, not assumed. Node 0.12's https has no CA bundle
worth trusting, so the download goes through curl or wget. The stock
/usr/bin/curl reaches GitHub on both TVs tested — 7.53.1 against OpenSSL
1.0.2p on webOS 4.4.3, 7.82.0 against OpenSSL 3.0.9 on webOS 9.2.2 — but that is
not something to assume of other firmware, and a client the owner installed can
be anywhere. Installed clients are tried before the stock one, each against the
real release endpoint until one returns usable JSON. A client that does that has
proved everything that matters. Certificate verification is never disabled: what
comes back runs as root on the next restart.
The directory is updated in place, not swapped. /var/lib/tvweb holds more
than code — config.json, adblock_hosts, the staged screen saver the boot hook
bind-mounts, services_stopped — and a wholesale swap has to carry every one of
them across or silently lose it. Replacing only the files the release ships
cannot lose state it never touches.
Every file is renamed into place, never written over. Busybox ash reads a
script as it executes, so overwriting tvwebctl corrupts the watchdog loop
already running out of it. A rename leaves that process on the old inode.
The tarball is inflated by node, not by tar. zlib is certainly present and
busybox's gzip support is not, and an inflate failure is how a truncated download
is caught — cheaper than trusting a content length. The unpacked tvweb.js then
has to declare the version that was asked for before anything is replaced.
A client that cannot answer is not the same as a request that is refused.
Treating every non-zero exit as "try the next client" reported a 404 from a
repository with no releases as no HTTP client on this TV could reach GitHub -
install a current curl, which would send someone off installing software they
already have. Both clients name the status on stderr (server returned error:
HTTP/1.1 404, ERROR 404:, returned error: 404) and both keep an exit code
for it — curl 22, wget 8 — so an HTTP answer of any kind ends the probe: the
transport has proved itself and only the request is wrong. The 403 wording stays
hedged, since a proxy or a captive portal returns that as readily as a spent
rate limit.
The upgrade runs in the server itself, with tvwebctl update invoking
node tvweb.js --update as a one-shot. One implementation serves the dashboard,
Home Assistant and the shell, and the shell path still works with the dashboard
switched off or the server not running. --update exits 3 when there is nothing
newer, which tvwebctl reads as "no restart needed" rather than as a failure.
Fonts¶
The dashboard bundles Outfit and
Manrope as variable fonts, both under the
SIL Open Font License, served by the TV so the page needs no internet access.
Licence texts ship alongside them in server/assets/fonts/.
Consent flags are rebuilt from LG's agreement documents at boot¶
/var/luna/preferences/eula is a mirror. com.webos.settingsservice holds the
values under the eulaStatus key and regenerates the file, and its eula.md5
sidecar, at boot - so editing the file directly reverts. Flipping
thirdPartySharingAllowed in the file survived inspection, the dashboard and 75
seconds of runtime, then came back byte-identical after a reboot (md5
85aca988..., mtime set during boot).
Writing through the service works, but the flag alone does not survive a boot.
eulaStatus is derived from a second record: eulaInfoNetwork, LG's agreement
documents with an accepted flag on each. At boot the firmware rebuilds every
mapped flag from the accepted documents, so a flag written on its own is
overwritten by whatever its agreement still says. Reported on a C8 (webOS 4.4.0)
in #61.
Both firmwares rebuild. An earlier note here said a B8 on 4.4.3 did not, on
the strength of cookiesAllowed surviving a reboot. That flag is absent from
eulaMappingList, so the rebuild never touches it - the one flag that was
exempt, generalised to all of them. Measured properly on the same B8:
| Write | After a reboot |
|---|---|
thirdPartySharingAllowed false, document left accepted |
back to true |
the same flag through the panel, withdrawing S_ADG |
still false |
So a write has to move both records. Switching a flag on accepts the documents it needs; switching it off withdraws those no remaining flag requires, and any flag resting on one goes off with it. A document needed by a flag that cannot be switched off is never withdrawn, which is what keeps Terms of Use in place.
Several flags share one document, so they can only be switched off together. The panel names them before they are clicked.
eulaInfoNetwork also carries the document titles - S_ADG is the "Viewing
Information Agreement" - and is the only place on the TV that names them. The
file that caches it does not exist on webOS 9, so it is read from the service.
Two quirks. getSystemSettings answers for eulaStatus only when no category
is given - general, option and the rest return "There is no matched result
from DB". And the setter takes the whole eulaStatus object, so changing one
flag is a read-modify-write.
A flag that sticks still only records what the TV stored. It does not prove LG honours it, and the value may be mirrored against the account server-side.
returnValue: true is the service accepting the call, not evidence it stored
anything - writing the file directly looks exactly as successful. Every write
from the panel is read back before it reports success, so a TV where the
setter is a no-op says so rather than showing a toggle that has not moved.
Which flags exist varies: a B8 on 4.4.3 has 21, a C2 on 9.2.2 has 23, including
marketingOnAllowed, shoppingOnAllowed and takeOnAllowed, and no
allAllowed. eulaMappingList differs too - additional1Allowed is in a group
on 4.4.3 and in none on 4.4.0. Nothing about the TV is hardcoded for that
reason: the mapping decides which flags the panel will write, and a TV that
publishes no mapping gets no toggles on undescribed flags at all.
luna-send prints nothing without a tty¶
Over a non-interactive ssh command it returns an empty string and exit 0, which
reads as a call that succeeded silently. Use ssh -tt. Calls made by tvweb.js
on the TV itself are unaffected - this bites when testing by hand, and it is an
easy way to convince yourself a change worked when nothing ran.
Shortcut button mapping is owned by LG's servers¶
Researched and built, then dropped before shipping: it cannot be made to work from a cold boot. Kept here because the constraints are expensive to rediscover and none of them are visible from the outside.
Why it was dropped. Every route needs the compositor to read a changed key
filter, and the only hook available runs too late. startup.sh invokes
run-parts /var/lib/webosbrew/init.d from the Homebrew Channel service, well
after surface-manager has started and read the stock file - measured on a C2,
the compositor was serving windows at 15s uptime and the hook ran at 35s. A
bind-mount applied then does nothing until the compositor restarts, and
restarting it mid-boot tears down the UI and fires a burst of system
notifications. So the choice is a disruptive restart on every boot, or buttons
that stay stock until something else restarts the compositor. Neither is worth
having.
The remote's streaming buttons (Netflix, Prime Video, Disney+ …) resolve through
mapping_info in the settings service, category: "other", which
/usr/lib/qml/KeyFilters/appLaunch.js reads at compositor start. Writing it
works and persists, so it looks like the place to remap a button - but
cb_getHotkeyInfo() treats LG's cloud response as authoritative: it overwrites
the in-memory table and then writes that back over the settings key. Measured on
a C2 (webOS 9), a rakutentv remap read back correctly and then returned to the
stock ui30 after a compositor restart, the payload shrinking 8319 to 7248
bytes as LG pushed its own list.
Two further details from that file:
- The subscription at
appLaunch.js:615is registered without"subscribe": true, so even an unclobbered value is read only at compositor start. Any mapping change needs asurface-managerrestart regardless. isActiveon each entry marks the buttons the model and its remote actually have, which is the per-model list to offer and needs no hardcoded table. The button-to-key-constant pairs come out ofgetPowerOnReason()in the same file, so both follow the firmware rather than this repository.
The remap therefore catches the key earlier: systemUi.js runs before
appLaunch.js, so a case WebOS.Key_webOS_<Name>: added to
handleSystemKeys() that returns KeyPolicy.Accepted launches the chosen app
and the CP-hotkey handler never sees the press. shortcut-key.sh bind-mounts a
patched copy, rebuilt each time from a pristine original so cases cannot
compound, and refuses to mount anything node --check rejects - a key filter
that does not parse takes the compositor down with it. The mount does not
survive a reboot, so a power cycle is always the way back; the boot hook
re-applies it, before the compositor starts where it can, which saves a restart.
Two traps when working on it: validate the staged copy under a name ending
.js, since node --check refuses an unknown extension like .tmp and the
check then fails every time; and /var/log/messages timestamps are UTC while
date is local, which makes a fresh button press look an hour stale.
webOS 4 differs in five ways, all of them load-bearing¶
Verified on an OLED65B8SLC. The feature works there, but nothing about it can be assumed from the webOS 9 shape:
| webOS 9 (C2) | webOS 4 (B8) | |
|---|---|---|
| Key filters | /usr/lib/qml/KeyFilters |
/usr/lib/qt5/qml/KeyFilters |
| Button named by | powerOnReason = "netflix" |
appId = "netflix" |
| Button list | mapping_info, filtered on isActive |
absent — settings returns "no matched result from DB" |
| Init | systemd, systemctl restart --no-block |
upstart, initctl restart |
node --check |
present | absent (node 0.12) |
The missing mapping_info means there is no way to know which buttons the
remote physically has, so the list falls back to every button the firmware can
launch — three on the B8, one of them ivi, which a UK remote does not carry.
Assigning a button that is not there simply never fires, so the fallback is
offered with that said plainly rather than withheld.
Without --check, the staged file is validated by compiling it instead:
new Function(src) raises on a syntax error and never runs the body, which
matters because the body expects QML globals that do not exist in node.
The insertion point is the top of the switch (key) in handleSystemKeys,
not above a named case. On webOS 4 several stock cases are a fall-through group
— Qt.Key_Super_L and Qt.Key_Menu fall into WebOS.Key_webOS_Recent — and a
case placed inside one would capture the Home and Menu keys with it. The top of
the switch belongs to no group, and a case ending in return cannot be fallen
into.
tvpower reboot does not reboot¶
luna://com.webos.service.tvpower/power/reboot accepts the request, validates
its parameters (omitting reason returns errorCode -7) and reports success -
but the kernel never restarts. Measured on an OLED65B8SLC running webOS 4.4.3:
| uptime | |
|---|---|
| before the call | 12810s |
| after (set was off the network ~65s) | 12871s |
It behaves like a standby transition. /sbin/reboot performs a real restart:
uptime reset to 60s, with services and the webosbrew boot hook all returning
cleanly. The reboot control therefore uses the kernel path, replying to the
client first because the process is about to go down with the system.
The thermal sensor lags boot¶
/proc/lg/pm/temperature reads a literal 0 for roughly the first 80 seconds
after a restart - valid at 83s uptime on the test set, still 0 at 73s. That
is not a measurement, so it is reported as null, kept out of the history ring
buffer, and shown as a dash. Publishing it would put a false 0°C spike into
Home Assistant's history on every reboot.
Remote control keys and the Home launcher across webOS versions¶
The D-pad and navigation keys (up: 103, down: 108, left: 105, right: 106, ok: 28, back: 412) operate uniformly through luna://com.webos.service.networkinput/test/sendKeyCode, but the Home button has two fundamentally different architectures across webOS generations:
- Modern webOS (webOS 6+, 2021+): The home screen was redesigned as a standalone full-screen application (
com.webos.app.home). It does not respond to standard remote evdev key codes, but launches reliably viacom.webos.applicationManager/launchwith{ id: 'com.webos.app.home' }. - Legacy webOS (webOS 3–5, 2016–2020):
com.webos.app.homedoes not exist as an installed application (returning{ errorCode: -101, errorText: "not exist" }). Instead, the Home launcher is an integrated system UI overlay ribbon (superRibboninside Qtsurface-manager).
Why sendSpecialKey fails silently on webOS 3–5¶
Calling com.webos.service.networkinput/sendSpecialKey with {"key": "HOME"} returns { returnValue: true }, but fails to bring up the Home launcher on screen.
In Qt's KeyFilters/systemUi.js, Qt.Key_Super_L (Linux keycode 125, KEY_LEFTMETA) implements a long-press discriminator:
if (key === Qt.Key_Super_L) {
if (pressed) {
if (autoRepeat) return KeyPolicy.Accepted;
global.prepareToGoHome();
if (!longPressTimer.isRunning(key))
longPressTimer.set(key, 1000, global.gotoRecents);
} else {
if (longPressTimer.isRunning(key)) {
global.goHome();
longPressTimer.cancel();
}
}
}
On key release, global.goHome() is called only if longPressTimer.isRunning(key) is true. network-input-service's internal UInputWriter::sendKeyPress sends key press and key release back-to-back with 0ms delay. When both evdev events arrive in the same event tick, Qt processes them before the timer is active, so goHome() is never triggered.
The dual-strategy solution¶
The server attempts to launch com.webos.app.home first. If that succeeds (webOS 6+), it returns immediately. If the launch returns returnValue: false, it falls back to direct event injection using injectKey(125, cb, 100).
The 100ms hold duration between key-down and key-up gives Qt's event loop sufficient time to arm longPressTimer, ensuring global.goHome() fires on release (LSM NL_HOME_SHOWN) and toggles the native Home launcher ribbon on webOS 3–5.
Older hardware capabilities (webOS 3.x and LCD models)¶
Testing against 2016 hardware (such as 43UH610V-ZB and 55UH6030-UC on webOS 3.4.3) highlights several differences between older LCD platforms and modern OLED sets:
- OLED Protections & Metrics: 2016 UH-series models use IPS LCD panels. Features such as Pixel Refresher, Screen Shift, Logo Luminance Dimming, GSR stress counts, and panel hours do not exist on LCD hardware. The server inspects the model name at startup, sets
capabilities.oled: false, and hides the OLED Care tab and associated MQTT discovery entities. - SoC Temperature: webOS 3.x kernels (Linux 3.10) do not expose
/proc/lg/pm/temperature,/sys/class/thermal, orhwmon. The server detects the absence of the file, markscapabilities.thermal: false, withholds the Home Assistant entity, and displays"NO THERMAL SENSOR"rather than printing a fake 0°C reading. - eMMC Flash Wear: Older eMMC 5.0 controllers and Linux 3.10 lack the
/sys/block/mmcblk0/device/life_timeandpre_eol_infosysfs nodes. The server flagscapabilities.emmcWear: falseand prunes the flash wear and health cells from the Storage section. - Screen Off & Screen Saver: Luna commands
com.webos.service.tvpower/power/turnOffScreenandturnOnScreenSaverare designed for OLED panel protection without system standby. On LCD sets, the backlight and panel cannot be powered down independently of the main SoC, so these calls fail or are no-ops. - Startup Duration: Older dual/quad-core Cortex-A9 chipsets paired with slower flash memory require significant time to complete boot initialization. The server's boot startup delay was tuned to 5s in v0.37.4, which provides sufficient margin for network interfaces and Luna routing daemons to settle without causing service crashes.
The blocker's second tier takes LG's own platform with it¶
The blocklist is applied by bind-mounting a generated hosts file over
/etc/hosts, which is the only way to change it on a read-only rootfs - the
same technique webosbrew uses for /etc/shadow and /etc/motd. Verified
working: getent hosts ad.lgsmartad.com returns 0.0.0.0.
Nine of the nineteen domains are ad, tracking and diagnostics hosts that nothing on the TV needs. The other ten are LG infrastructure rather than advertising, which is why they are a separate tier:
| Domain | What it actually serves |
|---|---|
ngfts.lge.com, aic-ngfts.lge.com |
Content and firmware delivery CDN |
lgtvsdp.com (and us./gb./eu.) |
Service platform behind the Content Store on webOS 4 |
nextlgsdp.com (and us./gb./eu.) |
The same on webOS 9 |
com.webos.appInstallService names the one its own set installs from:
http://GB.lgtvsdp.com on a B8, http://GB.nextlgsdp.com on a C2. The full
tier adds whatever that file says, so a firmware using neither is still covered.
Blocking them is a defensible choice, but it means firmware updates and the app store may stop working on that tier. Anyone who turns it on and later finds the Content Store broken will not connect the two events unless told, so it is stated at the control in the UI as well as here.
Entity state must come from the TV, not from the command¶
Entities derive state from the telemetry payload via a value_template, so
they re-assert the truth on every tick whatever changed it - dashboard, remote,
the TV's own menus, or Home Assistant.
The display panel switch originally published only when a command arrived over
MQTT, plus a retained ON on every connect. Blanking the panel from the
dashboard left Home Assistant showing it on indefinitely. It is now reconciled
against powerState each telemetry publish.
So: prefer state_topic: telemetryTopic with a template. An entity on its own
topic must be republished from real state every tick, or it is a guess that
holds until someone notices.
scripts/check-entities.py resolves every entity's value_json paths against a
live /api/stats. A renamed field otherwise leaves an entity at unknown with
no error anywhere.
Rotating a log the server is holding open¶
/var/lib is flash and nothing trimmed tvweb.log, so a broker the TV could
not reach appended a line every five seconds - the retry interval - for as long
as the outage lasted. Two changes: repeated MQTT connection errors are counted
and reported once rather than logged individually, and the watchdog in
tvwebctl trims the file at 256k.
The trim keeps one previous generation and truncates in place rather than
renaming. Renaming does not work here: the server writes to a descriptor it
already holds, so it follows the file under its new name and the fresh one
stays empty. Truncating in place only works if that descriptor was opened
O_APPEND, which is why start_app redirects with >> and not >. Without
it the server keeps its own offset and carries on writing past the old end,
leaving a sparse file that still reports the size the trim just reclaimed -
measured at 19MB apparent against 3MB allocated, which would send the watchdog
into rotating it on every pass.
The checks¶
scripts/ holds five static checks. Four need nothing but the repository and
run in CI alongside shellcheck; check-entities.py needs a live
/api/stats, so it is run by hand against the TV.
| Check | What it catches |
|---|---|
check-es5.py |
An ES6 construct in tvweb.js. Node 0.12 treats one as a parse error, so the server never starts and logs nothing. |
check-ui-ids.py |
An id the dashboard reaches for that no element defines. |
check-screensavers.py |
QML newer than the import QtQuick line it declares. |
check-drift.py |
A documented entity count the code has moved past, and an asset deploy.sh would never install. |
check-entities.py |
An entity template naming a field the telemetry no longer has. |
check-es5.py blanks strings, comments and regex literals before scanning, and
checks syntax only: an ES6 library call parses and fails at the call, which the
log shows, while a parse error leaves no process to log anything.
In-place updater and binary probing¶
The in-place updater (server/lib/updater.js) downloads the release tarball from GitHub directly onto the TV and unpacks it over /var/lib/tvweb/ without needing a computer or deploy.sh.
Download Client Probing¶
The download requires curl or wget on the TV. LG's stock /usr/bin/curl reaches GitHub on tested TVs, but third-party tools or stripped setups might place modern clients in non-standard paths. To ensure reliable downloads across webOS generations, the updater probes executable binaries in prioritized order:
/media/developer/bin(Homebrew Channel package directory)/usr/local/bin/opt/binand/opt/usr/bin(Optware / Entware)/var/lib/webosbrew/bin/home/root/bin/usr/binand/bin(stock system binaries)
A custom client path can also be configured in config.json via "update": { "client": "/path/to/curl" }.
Safe In-Place Staging & Rollback¶
- Extraction & Validation: The tarball is decompressed via Node's
zlib.gunzipSyncand unpacked into/var/lib/tvweb/.update/. It validates that the downloaded package contains a validserver/tvweb.jsdeclaring the expected release version. - Non-Destructive Upgrade: Files are copied over
/var/lib/tvweb/.config.json, the ad blocker's hosts file (adblock_hosts), staged screensavers, and stopped service lists are strictly preserved. - Rollback Backup: A complete copy of the replaced version is retained in
/var/lib/tvweb/.previous/. Runningtvwebctl rollback(or manually copying.previous/.back to/var/lib/tvweb/) restores the previous version without redeploying.
Installing apps from the catalog¶
server/lib/repo.js merges the Homebrew Channel catalog with listApps, and server/lib/installer.js runs one install job at a time. The catalog is https://repo.webosbrew.org/api/apps.json plus any apps.repos, paged as apps/<n>.json, cached for an hour, and each entry needs an https ipkUrl and a 64-digit sha256 or it is dropped. Update detection reuses the updater's version compare, which reads four numeric parts.
The install service¶
The call is luna://com.webos.appInstallService/dev/install with {id, ipkUrl: <local path>, subscribe: true}.
Completion is statusValue 30 with details.packageId. The service does not necessarily send state: "installed", so waiting for that alone can hang on a finished install; both are accepted. Failure is returnValue: false (text in errorText) or details.errorCode (text in details.reason).
luna-send is spawned directly, not through luna.Subscription. The subscription's close handler reconnects, and a reconnect would issue the install a second time. The child is killed on success, failure, timeout and server exit. The service keeps working after luna-send is given up on, so a timeout (120 s) checks listApps for the expected version before reporting a failure. Cancelling is possible only before the install starts; killing luna-send does not stop one in progress.
dev/install kills the package's services. The Homebrew Channel and the dashboard's own package would lose root that way, so the installer refuses both and leaves them to update themselves.
Root¶
Elevation runs /media/developer/apps/usr/palm/services/org.webosbrew.hbchannel.service/elevate-service <service> once per service in the package. The argument is a service name, not an app id, and each name must pass the same id rules as the install. The script is absent without the Homebrew Channel, which is how the installer knows to refuse the option.
A reinstall rewrites the luna-service2-dev files that elevation changed, so root is gone after every update. /var/lib/tvweb/elevated.json records the services that were elevated, and a catalog update of the package is elevated again for those services whether or not the box was ticked. A url or file package is never elevated again from it.
Staging and space¶
The package is staged in /media/developer/temp/glasshouse-install/, not /tmp, which is RAM-backed. Free space is read from df -k on that directory, since node 0.12 has no statfs; the parser accepts BusyBox output, one line per filesystem, and GNU output, where a long device name wraps onto its own line. After the download, twice the package size must be free on top of the staged file, for the installer's unpacking and the installed copy. The check is skipped when df cannot be read. The size limit is 512 MB.
Reading the package¶
The package is hashed as a stream and read as a stream; it is never held in memory or unpacked to disk. The parser is bounded because the file is untrusted until it has been checked:
- The
ararchive must hold exactlydebian-binary,control.tar.gzanddata.tar.gz, once each, every member inside the file. Odd-length members are padded to even, and the last may omit the pad. - Each tarball is gunzipped as a stream and abandoned once it unpacks past its cap: 1 MB for the control tarball, 2 GB for the data tarball. A header checksum is verified on every entry, at most 20,000 entries are read, and
appinfo.jsonmay be at most 64 KB. - Long-name (
L,K), pax (x,g,X) and base-256 size entries are refused rather than interpreted, as are entry types other than files, directories and links, and any path that is absolute or contains... A tar that has data after its end-of-archive blocks is refused, and so is anarmember of the tarballs under 18 bytes, which cannot be gzip. - A link (symbolic or hard) is accepted only at least one level inside
usr/palm/{applications,services}/<id>/, with a target that stays in that<id>directory, and no later entry may sit at or below a link's path. The path rules look at names, so a link that left its directory would let a later entry land where they never looked. - Only
usr/palm/applications/<id>/andusr/palm/services/<name>/are read. Of the data, onlyappinfo.jsonis kept, plus the first 20 bytes of binaries in a service or an app'sbin/, whose ELFe_machineis compared with that ofluna-sendor node.uname -mis not used, because some models report aarch64 with a 32-bit armhf userspace. - Every app and service id must be the package name or begin with it followed by a dot, and none may be under
com.webos.,com.palm.orcom.lge., protected, or already present in a system app directory.
Job file and recovery¶
The job state (downloading, verifying, awaiting-confirm, installing, elevating, installed, error) is written to /var/lib/tvweb/install-job.json at every step. At boot the staging directory is always cleared, and a job file in one of the unfinished states is reported as interrupted, which tells the owner to check the app list. The job is not resumed, since whether dev/install completed is unknown. An unconfirmed preview expires after ten minutes and its staged file is deleted. isBusy() (any unfinished state, including awaiting-confirm) stops another install from starting; isWorking() (downloading, verifying, installing, elevating) is what settings saves, network switches, restarts, the updater and install-app.sh wait on, so an open preview holds none of them. An app that came from the LG store (under /media/cryptofs/apps) is replaced only with a separate confirmation, since store updates stop afterwards.
Installing from a URL or a file¶
POST /api/apps/install/url takes {url, sha256?} and POST /api/apps/install/upload takes the package as the request body. Both come after the Host check and the token check, and both are refused with 403 unless sideloadVia() in routes.js finds a reason: apps.sideload === true (validateSettings drops the whole apps section, so only POST /api/apps/install/sideload {enabled}, the Apps tab's switch, or the file sets it), a configured token, or a loopback remote address. GET /api/apps/install/status reports sideload and sideloadVia (config, token, tv or null) so the page can explain how to enable it.
A URL goes through fetch.validateUrl (http or https, 2048 characters, no whitespace or control characters) and an optional 64-digit sha256, then installer.start({source: 'url'}).
An upload is refused before any of it is read unless:
- the type is exactly
application/octet-stream, sincemultipart/form-datais a simple request that needs no preflight (415); Content-Lengthis present andTransfer-Encodingis not (411), and the length is a positive integer (400);- the length is within the 512 MB cap (413), no job or other upload is running (409) and three times the length is free on the staging directory, when
dfcan be read (507).
postGuard holds the content type and Origin checks that readJsonBody shares. installer.prepareUpload makes the checks above, clears leftovers from the staging directory and reserves upload-<random>.ipk there; a reservation also makes other installs refuse, since a job's cleanup empties the directory, and lapses after 30 minutes without upload data. The route writes the stream with req.on('data') and a fs.createWriteStream, pausing the request until drain. The file is deleted when the connection aborts or closes early, on a write error, and when the byte count differs from Content-Length. A complete upload is handed over as installer.start({source: 'file', path}), which accepts only a path directly inside the staging directory.
The preview carries source (catalog, url or file) and the package's sha256. For a url or file source the page asks again before elevating, naming each service that would run as root, and the confirm request must carry the same list as confirmRoot or the installer refuses. willElevate tells whether a catalog update will be elevated again from elevated.json; the on-TV page offers the root switch for catalog packages only.
Exclusion¶
While a job is in a running state, restartSelf in tvweb.js refuses, which covers every restart path including controls.doRestartSelf over HTTP and MQTT. The routes that write the config and then restart (settings save, network access, phone setup) check first, so that nothing is written for a restart that will not happen. The updater, its rollback and install-app.sh refuse as well, and a new job refuses while the updater is downloading or installing.
Home Screen Tile Hiding & Cold Boot Sequence¶
Home screen bloatware tile hiding allows built-in or preloaded system apps (which lack uninstallation mechanisms on the Luna bus) to be removed from the launcher view without modifying the read-only rootfs (/).
Non-Destructive Manifest Bind-Mounts¶
- Manifest Overrides: For each hidden app ID,
server/lib/apps.jsstages a modifiedappinfo.jsonin/var/lib/tvweb/appinfo-overrides/<id>.jsonwith"visible": false. - Multi-Base Probing: Overrides are bind-mounted over every discovered location for the target app manifest (prioritizing
/media/system/apps/usr/palm/applicationsfor OTA-updated system apps, followed by/usr/palm/applicationsand flash mounts/mnt/otncabi//mnt/otycabi). - SAM Refresh: SAM (Surface Application Manager) caches
appinfo.jsononce per launch point. To force an immediate update,apps.jssignals SAM (killall -9 LunaExecutableandsystemctl kill -s 9 sam.serviceon systemd /initctl restart samon Upstart). SAM restarts in sub-seconds and drops the hidden tiles from the launcher.
Cold Boot vs. Quick Start+ (Standby)¶
- Normal Usage (Quick Start+): LG webOS defaults to Quick Start+ (Active Standby / Suspend-to-RAM). When the TV is powered off and on with the remote, the Linux kernel, active bind-mounts, and SAM remain running in memory. The overrides remain intact, and hidden tiles never appear.
- Cold Boot (Full Reboot / Power Loss): On a true cold boot, webOS boots from an early checkpoint/snapshot (CRIU), displaying the Home screen (
com.webos.app.home) before late userland root hooks run. The Home screen briefly shows the stock tiles for a few seconds untildevmode.serviceinvokes/var/lib/webosbrew/startup.sh→run-parts /var/lib/webosbrew/init.d/50-tvweb. 50-tvwebreapplies the bind-mounts from/var/lib/tvweb/hidden_appsin ~50ms and respawns SAM, at which point the Home screen drops the tiles from view. This brief appearance on cold boot is architectural to webOS's read-only root partition: root hooks run safely in user space without modifying rootfs systemd units.
Background Services & Debloating Safety¶
The debloating engine (server/lib/services.js) manages background system daemons using transient systemd unit masks (/run/systemd/transient/<unit>) and Upstart controls. Any background unit considered for inclusion in CATALOG must be verified against webOS boot-time dependency graphs and ActivityManager definitions.
Forbidden Service: tvdataexchanger (Hotel / USB Channel Cloning)¶
tvdataexchanger (tvdataexchanger.service) must never be added to the debloat catalog or disabled on webOS.
- Symptom When Disabled: The TV powers on, UI and audio function normally, but the display video plane remains completely black and muted (
sink: MAIN, muted: true, connected: false) when launching media apps (YouTube, Netflix, HDMI). - Root Cause:
/etc/palm/activities/com.webos.service.tvdataexchanger/activity-com.webos.service.tvdataexchanger.jsonstatically registers an activity with"foreground": trueand"continuous": truethat triggers onluna://com.webos.service.tvpower/power/getPowerStateduring"Prepare Power On"and"Prepare Resume".- Because the activity is marked
foreground: true, ActivityManager treats this as an essential foreground transition barrier. - When
tvdataexchanger.serviceis masked by systemd, the Luna callback (luna://com.webos.service.tvdataexchanger/startPowerOnDefault) fails or hangs. - On webOS 24+ (e.g. LG G4), this failure stalls the power-on handoff to
com.webos.applicationManager. - As a result,
videooutputdnever receives a matchingvssForegroundAppIdfromgetForegroundApps. Invideooutputd, theMAINsink starts muted at boot and only unmutes when the connecting app matchesvssForegroundAppId. With the foreground state stalled atunknown,videooutputdholdsmuted: trueindefinitely. - Why Disabling Offers No Benefit: On consumer/retail TVs where Hotel Mode is disabled (
enableHotelMode == 0),tvdataexchanger's power-on handlers (CHotel::loadAvSettings()andCHotel::runAspectRatio()) abort immediately. It does not touch AV settings or display configurations on consumer sets, uses negligible RAM (~1.8 MB), and consumes 0% CPU after boot.
Forbidden Service: alwaysready (Always Ready Ambient Mode)¶
alwaysready (alwaysready.service) was on the list until 0.73.2 and is now retired the same way.
- Symptom When Disabled: On an OLED65C4PUA (webOS 10.3.1, firmware 33.31.68), the native Plex app showed video stretched vertically. Switching the service back on and rebooting restored the aspect ratio (#374).
- Why Disabling Offers No Benefit: It saves about 3 MB of RAM. The Always Ready screen itself is switched off with the Always Ready setting under Advanced → Power (
lifeOnScreenMode), which leaves the daemon in place. - Migration:
services.init()drops both retired IDs (RETIRED) fromdisabled_services.json, removes their masks under/run/systemd/transient, starts them and rewrites the boot hook. The reported Plex fault cleared only after a reboot with the service running.
Screensaver Staging & Boot Persistence¶
Custom QML screensavers are staged in /var/lib/tvweb/screensaver and bind-mounted over /usr/palm/applications/com.webos.app.screensaver.
- Cold Boot vs Quick Start+: On Quick Start+ suspend-to-RAM, active bind-mounts persist in memory. On a cold boot (such as after the 4-hour OLED Pixel Cleaning cycle or extended standby on webOS 24/25),
/var/lib/webosbrew/init.d/50-tvwebre-applies the bind-mount if/var/lib/tvweb/screensaver/.tvweb-screensaveris present. - Reverting to Stock: When the user selects "LG default" (
stock),screensavers.setScreensaver('stock')unmounts the live overlay and deletes.tvweb-screensaveralong with all staged files. Without this cleanup, the boot hook would see the stale marker file on cold boot and re-mount the previously staged screensaver.screensavers.init()also auto-heals any orphaned marker files if the live screensaver is currently stock.
PicCap¶
The server asks PicCap's service only where /media/developer/apps/usr/palm/applications/org.webosbrew.piccap exists, so a TV without it starts no luna-send for it. For Home Assistant it polls every piccap.pollIntervalMs milliseconds (30 seconds by default) while MQTT is connected and the piccap entity is not switched off in the entity settings. An old "piccap": {"enabled": false} is turned into exactly that at start-up, and the key removed. Telemetry includes boolean piccap.power while PicCap answers; the bridge publishes retained ON or OFF to <prefix>/state/piccap/power, and <prefix>/command/piccap/power accepts ON or OFF when allowControl is enabled. Discovery carries the piccap switch only while a status check has answered, and is republished when that changes. /api/stats adds piccapCapture: {running} where PicCap answers, for the Advanced tab's switch, which sends the piccap control.