Compare commits

...
8 Commits
Author SHA1 Message Date
Kaloyan DanchevandClaude Opus 4.6 e29050dc32 Add curtain/blinds cover cards with daylight glow and navy darkening
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
New HaCardType::Curtain and HaCardType::Blinds with:
- Scroll to adjust position (10% steps via cover.set_cover_position)
- OK to toggle open/close
- Dark navy overlay when closed, warm daylight glow when open
- Curtain glow radiates from center, blinds from top

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 14:46:09 +03:00
Kaloyan DanchevandClaude Opus 4.6 c8e6fcb659 Redesign HA cards with fill-based backgrounds and add media player
Navigation: arrow keys now always navigate the card grid (removed
left/right value adjustment from Range/Climate cards). Scroll wheel
on the Magic Remote adjusts range values instead.

Cards: 4-column layout with taller (160px) cards. Each card shows a
fill bar proportional to its value — brightness for lights, target
temperature for climate, volume for media. State-based accent colors
(warm yellow for lights, orange/red for heating, blue for cooling,
purple for media playback).

New card type: Media (media_player) — shows artist, track title,
volume fill, and album art placeholder. OK toggles play/pause,
scroll wheel adjusts volume.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 13:48:10 +03:00
Kaloyan DanchevandClaude Opus 4.6 a7dd876c63 Fix D-pad input by using RPATH instead of RUNPATH
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Buster's linker defaults to RUNPATH, which is searched after
LD_LIBRARY_PATH. The TV's LD_LIBRARY_PATH includes /usr/lib which
has the system SDL2 (without remote control input support). With
RPATH, $ORIGIN/lib is searched first, loading the SDL-webOS community
fork that handles Magic Remote D-pad key events.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 13:25:06 +03:00
Kaloyan DanchevandClaude Opus 4.6 df271d738c Add Docker cross-compilation for webOS ARM (glibc 2.28)
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Debian buster base matches the TV's glibc version. Includes fake
WEBOS_NDK wrapping standard cross-compiler, stub shared libraries
for luna-service2 and glib-2.0, and a build script that produces
a deployable ARM soft-float binary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:56:57 +03:00
Kaloyan DanchevandClaude Opus 4.6 dce4f172ca Add visual config editor for HA tab
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
Self-contained HTML page that connects to HA WebSocket for entity
discovery, provides visual editing of rooms/cards/weather/vacuum
config, and exports config.json. Hosted from HA's /local/ directory.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:23:21 +03:00
Kaloyan DanchevandClaude Opus 4.6 764af371b9 Add glass card rendering and 6 card types for HA tab
Implement the full Home Assistant tab with room-based navigation
and glass-morphism card rendering:

- Toggle card: on/off for lights, switches, covers with state indicator
- Range card: dimmable lights with brightness bar and D-pad ±10% control
- Climate card: current/target temp, HVAC mode, D-pad ±0.5°C, sparkline
- Action card: one-shot activation for scenes and scripts
- Vacuum card: summary card that opens room selection modal
- Weather card: hero card with condition icon, temp, humidity, wind

Navigation: room tabs (horizontal pills), card grid (3-col), vacuum
modal with room multi-select, cleaning mode cycle, and start button.
Back button cascades: vacuum modal → card grid → room tabs → Home page.

Glass design: rgba(255,255,255,0.12) fill, 22px radius, 1px border at
0.15 opacity, elevated shadow on focus. Accent color #7EB8DA for active
entities, warm orange for heating, cool blue for cooling.

Wires Effect::Ha to dispatch toggle/brightness/temperature/scene/vacuum
service calls through ha_client. Adds 6 new SVG icons matching the
existing Lucide stroke style.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:14:07 +03:00
Kaloyan DanchevandClaude Opus 4.6 98dd75ab0e Add Notification Center and Game Optimizer themes
Extend Luma-style dark theme to two more Enact web overlays:
- com.webos.app.notificationcenter: dark pills, transparent
  compositor, notification-specific components
- com.webos.app.gameoptimizer: same plus GameItem, GameButton,
  GameSwitch, GamePanel custom components

Rename build.js → build.cjs (ESM compat) and update to build
all three themes from a single script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:07:09 +03:00
Kaloyan DanchevandClaude Opus 4.6 14db4f6e71 Add Home Assistant WebSocket client module
CI / Identify a merged version PR (push) Canceled after 0s
CI / Rust checks, rendering, and native package (push) Canceled after 0s
CI / Publish release and Homebrew feed (push) Canceled after 0s
Release PR / Maintain Version Packages PR (push) Canceled after 0s
- New ha_client.rs: background-thread WebSocket client using tungstenite
  with rustls TLS. Handles auth, state subscription, service calls, and
  recorder/statistics history fetch for climate graphs. Auto-reconnects
  on disconnect with 5s delay.
- HaState added to State struct: tracks connection status, entity states,
  and temperature history per entity.
- main.rs loads ha-home/config.json at startup, extracts all referenced
  entity IDs, and creates the HaService. ha_tick() drains responses into
  state each frame, matching the weather_tick pattern.
- tungstenite 0.24 added to Cargo.toml with handshake + rustls features.

All 84 existing tests pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-24 12:00:09 +03:00
21 changed files with 3990 additions and 71 deletions

No files matched your search

Generated
+294 -2
View File
@@ -50,12 +50,27 @@ version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [
"generic-array",
]
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
@@ -84,6 +99,31 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "cpufeatures"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
dependencies = [
"libc",
]
[[package]]
name = "crc32fast"
version = "1.5.3"
@@ -93,6 +133,22 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
"typenum",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "data-url"
version = "0.3.2"
@@ -105,6 +161,16 @@ version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
[[package]]
name = "digest"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
]
[[package]]
name = "euclid"
version = "0.22.14"
@@ -152,6 +218,16 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]]
name = "generic-array"
version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
]
[[package]]
name = "getrandom"
version = "0.2.17"
@@ -291,6 +367,7 @@ dependencies = [
"serde",
"serde_json",
"time",
"tungstenite",
"ureq",
]
@@ -410,6 +487,12 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -456,6 +539,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -486,6 +578,36 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -497,7 +619,7 @@ dependencies = [
"getrandom",
"libc",
"untrusted",
"windows-sys",
"windows-sys 0.52.0",
]
[[package]]
@@ -521,6 +643,28 @@ dependencies = [
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
@@ -541,6 +685,15 @@ dependencies = [
"untrusted",
]
[[package]]
name = "schannel"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "sdl2"
version = "0.38.0"
@@ -565,6 +718,29 @@ dependencies = [
"version-compare",
]
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "serde"
version = "1.0.229"
@@ -592,7 +768,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
"syn 3.0.9",
]
[[package]]
@@ -608,6 +784,17 @@ dependencies = [
"zmij",
]
[[package]]
name = "sha1"
version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [
"cfg-if",
"cpufeatures",
"digest",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -666,6 +853,17 @@ dependencies = [
"siphasher",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
@@ -677,6 +875,26 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "time"
version = "0.3.55"
@@ -726,6 +944,33 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log",
"rand",
"rustls",
"rustls-native-certs",
"rustls-pki-types",
"sha1",
"thiserror",
"utf-8",
]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicode-ident"
version = "1.0.27"
@@ -788,6 +1033,12 @@ dependencies = [
"xmlwriter",
]
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-zero"
version = "0.8.1"
@@ -800,6 +1051,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
@@ -815,6 +1072,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -824,6 +1087,15 @@ dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -900,6 +1172,26 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9"
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zeroize"
version = "1.9.1"
+1
View File
@@ -26,6 +26,7 @@ sdl2 = { version = "0.38", features = ["use-pkgconfig"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
time = { version = "0.3", features = ["local-offset", "formatting", "parsing"] }
tungstenite = { version = "0.24", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
ureq = { version = "3.4", default-features = false, features = ["rustls"] }
[profile.release]
+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M2 4h20"></path><path d="M2 4v16"></path><path d="M22 4v16"></path><path d="M5 4c0 6 3 8 7 8"></path><path d="M19 4c0 6-3 8-7 8"></path><path d="M5 4c0 10 3 16 7 16"></path><path d="M19 4c0 10-3 16-7 16"></path></svg>

After

Width:  |  Height:  |  Size: 426 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M12 22a7 7 0 0 0 7-7c0-2-1-3.9-3-5.5s-3.5-4-4-6.5c-.5 2.5-2 4.9-4 6.5C6 11.1 5 13 5 15a7 7 0 0 0 7 7z"></path></svg>

After

Width:  |  Height:  |  Size: 325 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"></path><path d="M9 18h6"></path><path d="M10 22h4"></path></svg>

After

Width:  |  Height:  |  Size: 375 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><path d="M15 4V2"></path><path d="M15 16v-2"></path><path d="M8 9h2"></path><path d="M20 9h2"></path><path d="M17.8 11.8 19 13"></path><path d="M15 9h.01"></path><path d="M17.8 6.2 19 5"></path><path d="m3 21 9-9"></path><path d="M12.2 6.2 11 5"></path></svg>

After

Width:  |  Height:  |  Size: 459 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><rect x="2" y="6" width="20" height="12" rx="6"></rect><circle cx="16" cy="12" r="2"></circle></svg>

After

Width:  |  Height:  |  Size: 300 B

+1
View File
@@ -0,0 +1 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.65" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" width="128" height="128" color="#fff"><circle cx="12" cy="12" r="9"></circle><circle cx="12" cy="12" r="3"></circle><path d="M12 9V4"></path><path d="m14.6 10.5 4.3-2.5"></path><path d="m14.6 13.5 4.3 2.5"></path><path d="M12 15v5"></path><path d="m9.4 13.5-4.3 2.5"></path><path d="m9.4 10.5-4.3-2.5"></path></svg>

After

Width:  |  Height:  |  Size: 477 B

+3 -2
View File
@@ -39,8 +39,9 @@
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z1", "name": "LED Strip 1" },
{ "type": "range", "entity_id": "light.rgbic_strip_lights_z2", "name": "LED Strip 2" },
{ "type": "toggle", "entity_id": "light.picture_frame_lamp", "name": "Picture Lamp" },
{ "type": "toggle", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" }
{ "type": "curtain", "entity_id": "cover.living_room_curtain", "name": "Curtain" },
{ "type": "vacuum", "entity_id": "vacuum.living_room_roborock_s7_pro_ultra", "name": "Roborock" },
{ "type": "media", "entity_id": "media_player.music_assistant", "name": "Music" }
]
},
{
+771
View File
@@ -0,0 +1,771 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Luma Home Config Editor</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d26;
--card: #22262f;
--border: rgba(255,255,255,0.08);
--border-focus: rgba(126,184,218,0.5);
--text: #e8e8ea;
--text-dim: #888a90;
--accent: #7eb8da;
--accent-bg: rgba(126,184,218,0.12);
--danger: #e06060;
--danger-bg: rgba(224,96,96,0.12);
--success: #60c080;
--radius: 12px;
--radius-sm: 8px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color-scheme: dark;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg); color: var(--text); min-height: 100vh; padding: 24px; max-width: 1200px; margin: 0 auto; }
h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
h2 { font-size: 18px; font-weight: 600; margin-bottom: 12px; color: var(--accent); }
h3 { font-size: 15px; font-weight: 500; margin-bottom: 8px; }
.subtitle { color: var(--text-dim); font-size: 14px; margin-bottom: 24px; }
.section { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px; margin-bottom: 16px; }
label { display: block; font-size: 13px; color: var(--text-dim); margin-bottom: 4px; }
input[type="text"], input[type="url"], input[type="number"], select {
width: 100%; padding: 8px 12px; background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-size: 14px; outline: none;
transition: border-color .15s;
}
input:focus, select:focus { border-color: var(--border-focus); }
input[type="number"] { width: 80px; }
.row { display: flex; gap: 12px; align-items: end; margin-bottom: 12px; }
.row > * { flex: 1; min-width: 0; }
.row > .shrink { flex: 0 0 auto; }
button {
padding: 8px 16px; border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--card); color: var(--text); font-size: 13px; cursor: pointer;
transition: all .15s; white-space: nowrap;
}
button:hover { border-color: var(--border-focus); background: var(--accent-bg); }
button.primary { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
button.primary:hover { opacity: .85; }
button.danger { color: var(--danger); border-color: var(--danger); }
button.danger:hover { background: var(--danger-bg); }
button.small { padding: 4px 10px; font-size: 12px; }
.pill {
display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px;
background: var(--accent-bg); color: var(--accent); margin-right: 4px;
}
.entity-picker { position: relative; }
.entity-picker .dropdown {
position: absolute; top: 100%; left: 0; right: 0; max-height: 260px; overflow-y: auto;
background: var(--card); border: 1px solid var(--border-focus); border-radius: var(--radius-sm);
z-index: 100; display: none;
}
.entity-picker .dropdown.open { display: block; }
.entity-picker .dropdown .item {
padding: 8px 12px; cursor: pointer; font-size: 13px; border-bottom: 1px solid var(--border);
display: flex; justify-content: space-between;
}
.entity-picker .dropdown .item:hover { background: var(--accent-bg); }
.entity-picker .dropdown .item .domain { color: var(--text-dim); font-size: 11px; }
.entity-picker .dropdown .item .name { color: var(--text); }
.card-item {
background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-sm);
padding: 12px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center;
}
.card-item .handle { cursor: grab; color: var(--text-dim); font-size: 18px; user-select: none; }
.card-item .info { flex: 1; min-width: 0; }
.card-item .info .eid { font-size: 12px; color: var(--text-dim); }
.card-item .actions { display: flex; gap: 4px; }
.room-tab {
display: inline-block; padding: 8px 16px; border-radius: 20px; cursor: pointer;
font-size: 14px; margin-right: 6px; margin-bottom: 8px; border: 1px solid var(--border);
background: var(--card); transition: all .15s;
}
.room-tab.active { background: var(--accent); color: #111; border-color: var(--accent); font-weight: 600; }
.room-tab:hover:not(.active) { border-color: var(--border-focus); }
.toast {
position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
padding: 10px 20px; border-radius: var(--radius); background: var(--success);
color: #111; font-weight: 600; font-size: 14px; z-index: 999; opacity: 0;
transition: opacity .3s; pointer-events: none;
}
.toast.show { opacity: 1; }
.status { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 16px; }
.status .dot { width: 8px; height: 8px; border-radius: 50%; }
.status .dot.on { background: var(--success); }
.status .dot.off { background: var(--danger); }
.empty { text-align: center; padding: 32px; color: var(--text-dim); }
.vacuum-room-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 12px; }
.vacuum-room-item { display: flex; gap: 8px; align-items: center; }
.vacuum-room-item input[type="number"] { width: 60px; }
.vacuum-room-item input[type="text"] { flex: 1; }
#json-preview {
width: 100%; min-height: 300px; background: var(--card); border: 1px solid var(--border);
border-radius: var(--radius-sm); color: var(--text); font-family: "SF Mono", Menlo, monospace;
font-size: 13px; padding: 12px; resize: vertical; tab-size: 2;
}
.tabs { display: flex; gap: 4px; margin-bottom: 16px; border-bottom: 1px solid var(--border); padding-bottom: 8px; }
.tab-btn {
padding: 6px 14px; border: none; background: none; color: var(--text-dim);
font-size: 14px; cursor: pointer; border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.tab-btn.active { color: var(--accent); background: var(--accent-bg); font-weight: 600; }
.tab-content { display: none; }
.tab-content.active { display: block; }
</style>
</head>
<body>
<h1>Luma Home Config</h1>
<p class="subtitle">Visual editor for the Home Assistant tab on your LG TV</p>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('connection')">Connection</button>
<button class="tab-btn" onclick="switchTab('weather')">Weather</button>
<button class="tab-btn" onclick="switchTab('rooms')">Rooms</button>
<button class="tab-btn" onclick="switchTab('vacuum')">Vacuum</button>
<button class="tab-btn" onclick="switchTab('export')">Export</button>
</div>
<!-- Connection Tab -->
<div id="tab-connection" class="tab-content active">
<div class="section">
<h2>Home Assistant Connection</h2>
<div class="row">
<div>
<label>WebSocket URL</label>
<input type="url" id="ha-url" placeholder="wss://ha.example.org/api/websocket">
</div>
</div>
<div class="row">
<div>
<label>Long-Lived Access Token</label>
<input type="text" id="ha-token" placeholder="eyJ...">
</div>
</div>
<div class="row">
<div class="shrink">
<button class="primary" onclick="connect()">Connect</button>
</div>
<div class="shrink">
<button onclick="disconnect()">Disconnect</button>
</div>
</div>
<div class="status">
<div class="dot" id="conn-dot"></div>
<span id="conn-status">Not connected</span>
<span style="margin-left:auto;color:var(--text-dim)" id="entity-count"></span>
</div>
</div>
<div class="section">
<h2>Import Existing Config</h2>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Paste a config.json or load from file to pre-populate the editor.</p>
<div class="row">
<div class="shrink"><button onclick="document.getElementById('file-input').click()">Load File</button></div>
<div class="shrink"><button onclick="importFromTextarea()">Paste JSON Below</button></div>
</div>
<input type="file" id="file-input" accept=".json" style="display:none" onchange="loadFile(event)">
<textarea id="import-json" rows="4" placeholder='Paste config.json here...' style="width:100%;margin-top:8px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-family:monospace;font-size:13px;padding:8px;resize:vertical"></textarea>
</div>
</div>
<!-- Weather Tab -->
<div id="tab-weather" class="tab-content">
<div class="section">
<h2>Weather Card</h2>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Hero card shown at the top of every room. Leave empty to hide.</p>
<div class="row">
<div class="entity-picker">
<label>Weather Entity</label>
<input type="text" id="weather-entity" placeholder="weather.forecast_home" oninput="filterEntities(this,'weather')">
<div class="dropdown" id="weather-entity-dropdown"></div>
</div>
</div>
</div>
</div>
<!-- Rooms Tab -->
<div id="tab-rooms" class="tab-content">
<div class="section">
<h2>Rooms</h2>
<div id="room-tabs"></div>
<button onclick="addRoom()" style="margin-bottom:16px">+ Add Room</button>
<div id="room-editor"></div>
</div>
</div>
<!-- Vacuum Tab -->
<div id="tab-vacuum" class="tab-content">
<div class="section">
<h2>Vacuum Configuration</h2>
<div class="row">
<div class="entity-picker">
<label>Vacuum Entity</label>
<input type="text" id="vacuum-entity" placeholder="vacuum.roborock" oninput="filterEntities(this,'vacuum')">
<div class="dropdown" id="vacuum-entity-dropdown"></div>
</div>
<div class="entity-picker">
<label>Cleaning Mode Entity</label>
<input type="text" id="vacuum-mode-entity" placeholder="select.roborock_cleaning_mode" oninput="filterEntities(this,'select')">
<div class="dropdown" id="vacuum-mode-entity-dropdown"></div>
</div>
</div>
<h3>Room Segments</h3>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">Map Roborock segment IDs to room names.</p>
<div id="vacuum-rooms"></div>
<button onclick="addVacuumRoom()" class="small">+ Add Segment</button>
</div>
</div>
<!-- Export Tab -->
<div id="tab-export" class="tab-content">
<div class="section">
<h2>Generated config.json</h2>
<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">
Copy this to <code>ha-home/config.json</code> in your Luma installation, then restart Luma.
</p>
<textarea id="json-preview" readonly></textarea>
<div class="row" style="margin-top:12px">
<div class="shrink"><button class="primary" onclick="copyJson()">Copy to Clipboard</button></div>
<div class="shrink"><button onclick="downloadJson()">Download config.json</button></div>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
<script>
// ── State ────────────────────────────────────────────────────────
let ws = null;
let msgId = 0;
let entities = {};
let config = {
version: 1,
ha: { url: '', token: '' },
weather: { entity_id: '' },
vacuum: { entity_id: '', cleaning_mode_entity: '', rooms: {} },
rooms: []
};
let activeRoom = 0;
// ── Init ─────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
const saved = localStorage.getItem('luma-editor-config');
if (saved) {
try { Object.assign(config, JSON.parse(saved)); } catch {}
}
const url = localStorage.getItem('luma-ha-url');
const token = localStorage.getItem('luma-ha-token');
if (url) document.getElementById('ha-url').value = url;
if (token) document.getElementById('ha-token').value = token;
if (config.ha.url) document.getElementById('ha-url').value = config.ha.url;
if (config.weather?.entity_id) document.getElementById('weather-entity').value = config.weather.entity_id;
if (config.vacuum?.entity_id) document.getElementById('vacuum-entity').value = config.vacuum.entity_id;
if (config.vacuum?.cleaning_mode_entity) document.getElementById('vacuum-mode-entity').value = config.vacuum.cleaning_mode_entity;
renderRoomTabs();
renderVacuumRooms();
renderExport();
document.addEventListener('click', (e) => {
document.querySelectorAll('.dropdown.open').forEach(d => {
if (!d.parentElement.contains(e.target)) d.classList.remove('open');
});
});
});
// ── Tabs ─────────────────────────────────────────────────────────
function switchTab(name) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('tab-' + name).classList.add('active');
if (name === 'export') renderExport();
}
// ── HA WebSocket ─────────────────────────────────────────────────
function connect() {
const url = document.getElementById('ha-url').value.trim();
const token = document.getElementById('ha-token').value.trim();
if (!url || !token) return toast('Enter URL and token');
config.ha.url = url;
config.ha.token = ''; // never persist token in config
localStorage.setItem('luma-ha-url', url);
localStorage.setItem('luma-ha-token', token);
saveState();
if (ws) ws.close();
setStatus('connecting');
try {
ws = new WebSocket(url);
} catch (e) {
setStatus('error', e.message);
return;
}
ws.onopen = () => {};
ws.onclose = () => setStatus('disconnected');
ws.onerror = () => setStatus('error');
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'auth_required') {
ws.send(JSON.stringify({ type: 'auth', access_token: token }));
} else if (msg.type === 'auth_ok') {
setStatus('connected');
fetchEntities();
} else if (msg.type === 'auth_invalid') {
setStatus('error', 'Invalid token');
ws.close();
} else if (msg.type === 'result' && msg.success && Array.isArray(msg.result)) {
processEntities(msg.result);
}
};
}
function disconnect() {
if (ws) ws.close();
ws = null;
entities = {};
setStatus('disconnected');
document.getElementById('entity-count').textContent = '';
}
function fetchEntities() {
msgId++;
ws.send(JSON.stringify({ id: msgId, type: 'get_states' }));
}
function processEntities(states) {
entities = {};
for (const s of states) {
entities[s.entity_id] = {
entity_id: s.entity_id,
state: s.state,
friendly_name: s.attributes?.friendly_name || s.entity_id,
domain: s.entity_id.split('.')[0],
attributes: s.attributes || {}
};
}
document.getElementById('entity-count').textContent = `${Object.keys(entities).length} entities`;
}
function setStatus(state, detail) {
const dot = document.getElementById('conn-dot');
const label = document.getElementById('conn-status');
dot.className = 'dot';
if (state === 'connected') {
dot.classList.add('on');
label.textContent = 'Connected';
} else if (state === 'connecting') {
label.textContent = 'Connecting...';
} else if (state === 'error') {
dot.classList.add('off');
label.textContent = 'Error' + (detail ? ': ' + detail : '');
} else {
dot.classList.add('off');
label.textContent = 'Disconnected';
}
}
// ── Entity Picker ────────────────────────────────────────────────
function filterEntities(input, domainFilter) {
const query = input.value.toLowerCase();
const dropdown = input.parentElement.querySelector('.dropdown');
if (query.length < 2) { dropdown.classList.remove('open'); return; }
const matches = Object.values(entities)
.filter(e => {
if (domainFilter && e.domain !== domainFilter) return false;
return e.entity_id.includes(query) || e.friendly_name.toLowerCase().includes(query);
})
.slice(0, 30);
if (matches.length === 0) { dropdown.classList.remove('open'); return; }
dropdown.innerHTML = matches.map(e => `
<div class="item" onclick="pickEntity(this, '${e.entity_id}')">
<span class="name">${esc(e.friendly_name)}</span>
<span class="domain">${esc(e.entity_id)}</span>
</div>
`).join('');
dropdown.classList.add('open');
}
function pickEntity(el, entityId) {
const input = el.closest('.entity-picker').querySelector('input');
input.value = entityId;
el.closest('.dropdown').classList.remove('open');
input.dispatchEvent(new Event('change'));
}
// ── Room Editor ──────────────────────────────────────────────────
function addRoom() {
const id = 'room_' + (config.rooms.length + 1);
config.rooms.push({ id, name: 'New Room', background: '', sensors: [], cards: [] });
activeRoom = config.rooms.length - 1;
renderRoomTabs();
renderRoomEditor();
saveState();
}
function removeRoom(index) {
config.rooms.splice(index, 1);
if (activeRoom >= config.rooms.length) activeRoom = Math.max(0, config.rooms.length - 1);
renderRoomTabs();
renderRoomEditor();
saveState();
}
function selectRoom(index) {
activeRoom = index;
renderRoomTabs();
renderRoomEditor();
}
function renderRoomTabs() {
const container = document.getElementById('room-tabs');
container.innerHTML = config.rooms.map((r, i) =>
`<span class="room-tab${i === activeRoom ? ' active' : ''}" onclick="selectRoom(${i})">${esc(r.name)}</span>`
).join('');
renderRoomEditor();
}
function renderRoomEditor() {
const container = document.getElementById('room-editor');
if (config.rooms.length === 0) {
container.innerHTML = '<div class="empty">No rooms. Click "+ Add Room" to start.</div>';
return;
}
const room = config.rooms[activeRoom];
if (!room) { container.innerHTML = ''; return; }
container.innerHTML = `
<div class="row">
<div>
<label>Room Name</label>
<input type="text" value="${esc(room.name)}" onchange="updateRoom('name', this.value)">
</div>
<div>
<label>Room ID (slug)</label>
<input type="text" value="${esc(room.id)}" onchange="updateRoom('id', this.value)">
</div>
<div class="shrink" style="padding-bottom:1px">
<button class="danger small" onclick="removeRoom(${activeRoom})">Delete Room</button>
</div>
</div>
<h3>Cards</h3>
<div id="cards-list">${renderCards(room.cards)}</div>
<div class="row" style="margin-top:8px">
<div class="shrink"><button onclick="addCard('toggle')">+ Toggle</button></div>
<div class="shrink"><button onclick="addCard('range')">+ Range</button></div>
<div class="shrink"><button onclick="addCard('climate')">+ Climate</button></div>
<div class="shrink"><button onclick="addCard('action')">+ Action</button></div>
<div class="shrink"><button onclick="addCard('vacuum')">+ Vacuum</button></div>
</div>
`;
}
function renderCards(cards) {
if (cards.length === 0) return '<div class="empty" style="padding:16px">No cards in this room.</div>';
return cards.map((card, i) => `
<div class="card-item" draggable="true" ondragstart="dragCard(event,${i})" ondragover="event.preventDefault()" ondrop="dropCard(event,${i})">
<span class="handle">⠿</span>
<div class="info">
<div><span class="pill">${card.type}</span> <strong>${esc(card.name || '')}</strong></div>
<div class="eid">${esc(card.entity_id)}</div>
</div>
<div class="actions">
<button class="small" onclick="editCard(${i})">Edit</button>
<button class="small danger" onclick="removeCard(${i})">×</button>
</div>
</div>
`).join('');
}
function updateRoom(field, value) {
config.rooms[activeRoom][field] = value;
renderRoomTabs();
saveState();
}
function addCard(type) {
const room = config.rooms[activeRoom];
room.cards.push({ type, entity_id: '', name: '' });
renderRoomEditor();
editCard(room.cards.length - 1);
saveState();
}
function removeCard(index) {
config.rooms[activeRoom].cards.splice(index, 1);
renderRoomEditor();
saveState();
}
let dragIndex = -1;
function dragCard(e, index) { dragIndex = index; e.dataTransfer.effectAllowed = 'move'; }
function dropCard(e, index) {
e.preventDefault();
if (dragIndex === index) return;
const cards = config.rooms[activeRoom].cards;
const [moved] = cards.splice(dragIndex, 1);
cards.splice(index, 0, moved);
renderRoomEditor();
saveState();
}
function editCard(index) {
const room = config.rooms[activeRoom];
const card = room.cards[index];
const overlay = document.createElement('div');
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:200;display:flex;align-items:center;justify-content:center';
overlay.innerHTML = `
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:24px;width:480px;max-width:90vw">
<h2 style="margin-bottom:16px">Edit Card</h2>
<div style="margin-bottom:12px">
<label>Type</label>
<select id="edit-type">
${['toggle','range','climate','action','vacuum'].map(t => `<option${t===card.type?' selected':''}>${t}</option>`).join('')}
</select>
</div>
<div style="margin-bottom:12px" class="entity-picker">
<label>Entity ID</label>
<input type="text" id="edit-entity" value="${esc(card.entity_id)}" oninput="filterEntities(this, document.getElementById('edit-type').value === 'climate' ? 'climate' : null)">
<div class="dropdown" id="edit-entity-dropdown"></div>
</div>
<div style="margin-bottom:12px">
<label>Display Name</label>
<input type="text" id="edit-name" value="${esc(card.name || '')}" placeholder="Leave empty to use HA friendly_name">
</div>
<div style="display:flex;gap:8px;justify-content:end">
<button onclick="this.closest('div[style*=fixed]').remove()">Cancel</button>
<button class="primary" onclick="saveCard(${index})">Save</button>
</div>
</div>
`;
document.body.appendChild(overlay);
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}
function saveCard(index) {
const card = config.rooms[activeRoom].cards[index];
card.type = document.getElementById('edit-type').value;
card.entity_id = document.getElementById('edit-entity').value.trim();
card.name = document.getElementById('edit-name').value.trim();
if (!card.name && entities[card.entity_id]) {
card.name = entities[card.entity_id].friendly_name;
}
document.querySelector('div[style*="fixed"]').remove();
renderRoomEditor();
saveState();
}
// ── Vacuum ───────────────────────────────────────────────────────
function renderVacuumRooms() {
const container = document.getElementById('vacuum-rooms');
const rooms = config.vacuum?.rooms || {};
const entries = Object.entries(rooms).sort((a, b) => Number(a[0]) - Number(b[0]));
if (entries.length === 0) {
container.innerHTML = '<div class="empty" style="padding:12px">No segments configured.</div>';
return;
}
container.innerHTML = entries.map(([id, name]) => `
<div class="vacuum-room-item" style="margin-bottom:6px;display:flex;gap:8px;align-items:center">
<input type="number" value="${id}" onchange="renameVacuumSegment('${id}', this.value, null)" style="width:70px" placeholder="ID">
<input type="text" value="${esc(name)}" onchange="renameVacuumSegment('${id}', null, this.value)" placeholder="Room name">
<button class="small danger" onclick="removeVacuumRoom('${id}')">×</button>
</div>
`).join('');
}
function addVacuumRoom() {
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
const nextId = Math.max(0, ...Object.keys(config.vacuum.rooms).map(Number)) + 1;
config.vacuum.rooms[String(nextId)] = 'Room ' + nextId;
renderVacuumRooms();
saveState();
}
function removeVacuumRoom(id) {
delete config.vacuum.rooms[id];
renderVacuumRooms();
saveState();
}
function renameVacuumSegment(oldId, newId, newName) {
if (newId !== null && newId !== oldId) {
const name = config.vacuum.rooms[oldId];
delete config.vacuum.rooms[oldId];
config.vacuum.rooms[newId] = name;
}
if (newName !== null) {
config.vacuum.rooms[newId || oldId] = newName;
}
renderVacuumRooms();
saveState();
}
// Sync vacuum inputs
document.addEventListener('change', (e) => {
if (e.target.id === 'vacuum-entity') {
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
config.vacuum.entity_id = e.target.value;
saveState();
}
if (e.target.id === 'vacuum-mode-entity') {
if (!config.vacuum) config.vacuum = { entity_id: '', cleaning_mode_entity: '', rooms: {} };
config.vacuum.cleaning_mode_entity = e.target.value;
saveState();
}
if (e.target.id === 'weather-entity') {
config.weather = { entity_id: e.target.value };
saveState();
}
});
// ── Import ───────────────────────────────────────────────────────
function loadFile(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
importConfig(JSON.parse(e.target.result));
toast('Config loaded');
} catch (err) {
toast('Invalid JSON: ' + err.message);
}
};
reader.readAsText(file);
event.target.value = '';
}
function importFromTextarea() {
const text = document.getElementById('import-json').value.trim();
if (!text) return toast('Paste JSON first');
try {
importConfig(JSON.parse(text));
toast('Config imported');
} catch (err) {
toast('Invalid JSON: ' + err.message);
}
}
function importConfig(data) {
config = {
version: data.version || 1,
ha: { url: data.ha?.url || '', token: '' },
weather: data.weather || { entity_id: '' },
vacuum: data.vacuum || { entity_id: '', cleaning_mode_entity: '', rooms: {} },
rooms: data.rooms || []
};
if (config.ha.url) document.getElementById('ha-url').value = config.ha.url;
document.getElementById('weather-entity').value = config.weather?.entity_id || '';
document.getElementById('vacuum-entity').value = config.vacuum?.entity_id || '';
document.getElementById('vacuum-mode-entity').value = config.vacuum?.cleaning_mode_entity || '';
activeRoom = 0;
renderRoomTabs();
renderVacuumRooms();
saveState();
}
// ── Export ────────────────────────────────────────────────────────
function buildExport() {
const out = {
version: 1,
ha: {
url: document.getElementById('ha-url').value.trim(),
token: ''
}
};
const weatherId = document.getElementById('weather-entity').value.trim();
if (weatherId) out.weather = { entity_id: weatherId };
const vacId = document.getElementById('vacuum-entity').value.trim();
if (vacId) {
out.vacuum = {
entity_id: vacId,
cleaning_mode_entity: document.getElementById('vacuum-mode-entity').value.trim(),
rooms: config.vacuum?.rooms || {}
};
}
out.rooms = config.rooms.map(r => {
const room = { id: r.id, name: r.name };
if (r.background) room.background = r.background;
room.sensors = r.sensors || [];
room.cards = r.cards.filter(c => c.entity_id).map(c => {
const card = { type: c.type, entity_id: c.entity_id };
if (c.name) card.name = c.name;
return card;
});
return room;
});
return out;
}
function renderExport() {
document.getElementById('json-preview').value = JSON.stringify(buildExport(), null, 2);
}
function copyJson() {
renderExport();
const text = document.getElementById('json-preview').value;
navigator.clipboard.writeText(text).then(() => toast('Copied!')).catch(() => {
document.getElementById('json-preview').select();
toast('Select all + copy manually');
});
}
function downloadJson() {
renderExport();
const blob = new Blob([document.getElementById('json-preview').value], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'config.json';
a.click();
URL.revokeObjectURL(url);
}
// ── Helpers ──────────────────────────────────────────────────────
function saveState() {
const save = { ...config };
save.ha = { url: save.ha.url, token: '' };
localStorage.setItem('luma-editor-config', JSON.stringify(save));
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(el._t);
el._t = setTimeout(() => el.classList.remove('show'), 2500);
}
</script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
const fs = require('fs');
const path = require('path');
const APPS = [
'com.palm.app.settings',
'com.webos.app.notificationcenter',
'com.webos.app.gameoptimizer',
];
const outDir = path.join(__dirname, 'dist');
fs.mkdirSync(outDir, { recursive: true });
for (const app of APPS) {
const srcPath = path.join(__dirname, app + '.js');
if (!fs.existsSync(srcPath)) {
console.log('SKIP:', app, '(source not found)');
continue;
}
const code = fs.readFileSync(srcPath, 'utf8');
let minified = code
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\n\s*\n/g, '\n')
.replace(/^\s+/gm, '')
.replace(/\n/g, '')
.replace(/ +/g, ' ');
console.log(app + ':');
console.log(' Original:', code.length, 'bytes');
console.log(' Minified:', minified.length, 'bytes');
const minPath = path.join(outDir, app + '.min.js');
fs.writeFileSync(minPath, minified);
const b64 = Buffer.from(minified).toString('base64');
const b64Path = path.join(outDir, app + '.b64');
fs.writeFileSync(b64Path, b64);
console.log(' Base64: ', b64.length, 'bytes');
}
-29
View File
@@ -1,29 +0,0 @@
const fs = require('fs');
const path = require('path');
const src = path.join(__dirname, 'com.palm.app.settings.js');
const code = fs.readFileSync(src, 'utf8');
let minified = code
.replace(/\/\/[^\n]*/g, '')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\n\s*\n/g, '\n')
.replace(/^\s+/gm, '')
.replace(/\n/g, '')
.replace(/ +/g, ' ');
console.log('Original size:', code.length, 'bytes');
console.log('Minified size:', minified.length, 'bytes');
const outDir = path.join(__dirname, 'dist');
fs.mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, 'com.palm.app.settings.min.js');
fs.writeFileSync(outPath, minified);
console.log('Written to:', outPath);
const b64 = Buffer.from(minified).toString('base64');
const b64Path = path.join(outDir, 'com.palm.app.settings.b64');
fs.writeFileSync(b64Path, b64);
console.log('Base64 size:', b64.length, 'bytes');
console.log('Base64 written to:', b64Path);
@@ -0,0 +1,282 @@
// LG webOS Game Optimizer — Luma-style Dark Theme V1.0
// Same visual language as Settings theme: transparent compositor,
// nuclear background strip, dark pills, white focus.
// CSS class hashes from webOS 6.5.3 (kisscurl-koli).
(function () {
'use strict';
var STYLE_ID = 'luma-go-v10';
// === COMPOSITOR TRANSPARENCY ===
try {
if (window.PalmSystem && PalmSystem.setWindowProperties) {
PalmSystem.setWindowProperties(JSON.stringify({ transparent: true }));
}
} catch (e) {}
try {
if (window.webOSSystem && webOSSystem.setWindowProperty) {
webOSSystem.setWindowProperty('transparent', 'true');
}
} catch (e) {}
var CSS = [
// -------------------------------------------------------
// 1. NUCLEAR — strip all backgrounds
// -------------------------------------------------------
'*, *::before, *::after {',
' background-color: transparent !important;',
' background-image: none !important;',
' box-shadow: none !important;',
' transition: none !important;',
' animation: none !important;',
'}',
'html, body {',
' background: transparent !important;',
' background-color: transparent !important;',
' background-image: none !important;',
'}',
'body { color: rgba(255,255,255,0.85) !important; }',
// -------------------------------------------------------
// 2. DARK PILLS on items
// -------------------------------------------------------
// Standard Enact Item bg
'.Item_bg__166C- {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// Game-specific Item bg
'.GameItem_bg__1Px-3 {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// Other bg elements: pill radius
'[class*="_bg__"]:not(.Item_bg__166C-):not(.GameItem_bg__1Px-3) {',
' border-radius: 9999px !important;',
'}',
// -------------------------------------------------------
// 3. FOCUS — white pill + dark text
// -------------------------------------------------------
// Standard Item focus
'.Item_item__1Q0oR.spottable:focus,',
'.Item_item__2eTQF.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Item_item__1Q0oR.spottable:focus .Item_bg__166C-,',
'.Item_item__2eTQF.spottable:focus .Item_bg__166C- {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Item_item__1Q0oR.spottable:focus .Item_label__yk-Xa,',
'.Item_item__2eTQF.spottable:focus .Item_label__yk-Xa,',
'.Item_item__1Q0oR.spottable:focus .Item_itemContent__372MG,',
'.Item_item__2eTQF.spottable:focus .Item_itemContent__372MG,',
'.Item_item__1Q0oR.spottable:focus .Item_slotAfter__2fc4G,',
'.Item_item__2eTQF.spottable:focus .Item_slotAfter__2fc4G,',
'.Item_item__1Q0oR.spottable:focus .Item_slotBefore__37DXJ,',
'.Item_item__2eTQF.spottable:focus .Item_slotBefore__37DXJ,',
'.Item_item__1Q0oR.spottable:focus .Icon_icon__1UDrL,',
'.Item_item__1Q0oR.spottable:focus .Icon_icon__2ch_u,',
'.Item_item__2eTQF.spottable:focus .Icon_icon__1UDrL,',
'.Item_item__2eTQF.spottable:focus .Icon_icon__2ch_u {',
' color: #1a1a1a !important;',
'}',
// GameItem focus
'.GameItem_item__1e3fq.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.GameItem_item__1e3fq.spottable:focus .GameItem_bg__1Px-3 {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.GameItem_item__1e3fq.spottable:focus .GameItem_label__2HOiY,',
'.GameItem_item__1e3fq.spottable:focus .Icon_icon__1UDrL,',
'.GameItem_item__1e3fq.spottable:focus .Icon_icon__2ch_u {',
' color: #1a1a1a !important;',
'}',
// Disabled items
'[disabled] .Item_itemContent__372MG,',
'[aria-disabled="true"] .Item_itemContent__372MG,',
'[disabled] .Item_label__yk-Xa,',
'[aria-disabled="true"] .Item_label__yk-Xa,',
'[disabled] .Item_slotAfter__2fc4G,',
'[aria-disabled="true"] .Item_slotAfter__2fc4G,',
'[disabled] .Item_slotBefore__37DXJ,',
'[aria-disabled="true"] .Item_slotBefore__37DXJ,',
'[disabled] .Icon_icon__1UDrL,',
'[disabled] .Icon_icon__2ch_u,',
'[aria-disabled="true"] .Icon_icon__1UDrL,',
'[aria-disabled="true"] .Icon_icon__2ch_u {',
' opacity: 1 !important;',
' color: rgba(255,255,255,0.5) !important;',
'}',
// Button focus (standard Enact)
'.Button_button__2TM80.spottable:focus,',
'.Button_button__2eYtX.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Button_button__2TM80.spottable:focus .Button_bg__2zdeb,',
'.Button_button__2TM80.spottable:focus .Button_bg__2zkI-,',
'.Button_button__2eYtX.spottable:focus .Button_bg__2zdeb,',
'.Button_button__2eYtX.spottable:focus .Button_bg__2zkI- {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Button_button__2TM80.spottable:focus .Button_client__1-mQb,',
'.Button_button__2TM80.spottable:focus .Button_client__2pFZm,',
'.Button_button__2eYtX.spottable:focus .Button_client__1-mQb,',
'.Button_button__2eYtX.spottable:focus .Button_client__2pFZm,',
'.Button_button__2TM80.spottable:focus .Icon_icon__1UDrL,',
'.Button_button__2TM80.spottable:focus .Icon_icon__2ch_u,',
'.Button_button__2eYtX.spottable:focus .Icon_icon__1UDrL,',
'.Button_button__2eYtX.spottable:focus .Icon_icon__2ch_u {',
' color: #1a1a1a !important;',
'}',
// GameButton focus
'.GameButton_button__2_0__.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.GameButton_button__2_0__.spottable:focus .GameButton_bg__10LOj {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// GameIconButton focus
'.GameIconButton_button__2mofS.spottable:focus .GameIconButton_bg__121PO {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// GameSwitch focus
'.GameSwitch_switch__OWz1z.spottable:focus .GameSwitch_bg__1mRa0 {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// GameRadioItem focus
'.GameRadioItem_radioItem__2GUcI.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.spottable:focus { outline: none !important; }',
// -------------------------------------------------------
// 4. TEXT & MISC
// -------------------------------------------------------
'.Item_item__1Q0oR, .Item_item__2eTQF {',
' color: rgba(255,255,255,0.85) !important;',
'}',
'.GameItem_item__1e3fq {',
' color: rgba(255,255,255,0.85) !important;',
'}',
'.Button_button__2TM80, .Button_button__2eYtX {',
' color: rgba(255,255,255,0.85) !important;',
' border-radius: 9999px !important;',
'}',
'.Header_header__2Xmak {',
' color: #fff !important;',
' border-bottom: 1px solid rgba(255,255,255,0.06) !important;',
'}',
'h1, h2, h3 { color: #fff !important; }',
'.Heading_heading__Y4gPw { color: #fff !important; }',
'.GameHeading_gameHeading__2dHP1 { color: #fff !important; }',
// GamePanel backdrop — semi-transparent dark instead of opaque
'.GamePanel_panelBackdrop__1nX3U {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.GamePanel_panel__2770_ { color: rgba(255,255,255,0.85) !important; }',
// AI Indicator
'.AIIndicatorUnit_title__1EVRE { color: rgba(255,255,255,0.7) !important; }',
'.AIIndicatorUnit_value__pC9jL { color: #fff !important; }',
// Alert popup
'.Alert_alert__gVwa9,',
'.Alert_popup__1b3Xc {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Alert_title__1HfX1 { color: #fff !important; }',
'.Alert_body__3g69H { color: rgba(255,255,255,0.85) !important; }',
// FixedPopupPanels
'.FixedPopupPanels_fixedPopupPanels__wpe6K,',
'.FixedPopupPanels_body__2ARIw,',
'.FixedPopupPanels_panel__38yei {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
// Popup container
'.Popup_popup__3sv6e {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Popup_body__2J-hw { color: rgba(255,255,255,0.85) !important; }',
// Picker styling
'.Picker_picker__fngq1 { color: rgba(255,255,255,0.85) !important; }',
'* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }',
''
].join('\n');
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
if (document.body) {
injectStyle();
} else {
document.addEventListener('DOMContentLoaded', injectStyle);
}
new MutationObserver(function () {
if (!document.getElementById(STYLE_ID)) injectStyle();
}).observe(document.documentElement, { childList: true, subtree: true });
})();
@@ -0,0 +1,237 @@
// LG webOS Notification Center — Luma-style Dark Theme V1.0
// Same visual language as Settings theme: transparent compositor,
// nuclear background strip, dark pills, white focus.
// CSS class hashes from webOS 6.5.3 (kisscurl-koli).
(function () {
'use strict';
var STYLE_ID = 'luma-nc-v10';
// === COMPOSITOR TRANSPARENCY ===
try {
if (window.PalmSystem && PalmSystem.setWindowProperties) {
PalmSystem.setWindowProperties(JSON.stringify({ transparent: true }));
}
} catch (e) {}
try {
if (window.webOSSystem && webOSSystem.setWindowProperty) {
webOSSystem.setWindowProperty('transparent', 'true');
}
} catch (e) {}
var CSS = [
// -------------------------------------------------------
// 1. NUCLEAR — strip all backgrounds
// -------------------------------------------------------
'*, *::before, *::after {',
' background-color: transparent !important;',
' background-image: none !important;',
' box-shadow: none !important;',
' transition: none !important;',
' animation: none !important;',
'}',
'html, body {',
' background: transparent !important;',
' background-color: transparent !important;',
' background-image: none !important;',
'}',
'body { color: rgba(255,255,255,0.85) !important; }',
// -------------------------------------------------------
// 2. DARK PILLS on items
// -------------------------------------------------------
'.Item_bg__2MM1D {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// Other bg elements stay hidden until focus
'[class*="_bg__"]:not(.Item_bg__2MM1D) {',
' border-radius: 9999px !important;',
'}',
// FormCheckboxItem bg
'.FormCheckboxItem_bg__3GHsc {',
' opacity: 1 !important;',
' background-color: rgba(0,0,0,0.65) !important;',
' background-image: none !important;',
' border-radius: 9999px !important;',
' filter: none !important;',
'}',
// -------------------------------------------------------
// 3. FOCUS — white pill + dark text
// -------------------------------------------------------
// Item focus
'.Item_item__3OZOO.spottable:focus,',
'.Item_item__3gT3d.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Item_item__3OZOO.spottable:focus .Item_bg__2MM1D,',
'.Item_item__3gT3d.spottable:focus .Item_bg__2MM1D {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Item_item__3OZOO.spottable:focus .Item_label__2Wgl2,',
'.Item_item__3gT3d.spottable:focus .Item_label__2Wgl2,',
'.Item_item__3OZOO.spottable:focus .Item_itemContent__19m1V,',
'.Item_item__3gT3d.spottable:focus .Item_itemContent__19m1V,',
'.Item_item__3OZOO.spottable:focus .Item_slotAfter__hf946,',
'.Item_item__3gT3d.spottable:focus .Item_slotAfter__hf946,',
'.Item_item__3OZOO.spottable:focus .Item_slotBefore__39lzy,',
'.Item_item__3gT3d.spottable:focus .Item_slotBefore__39lzy,',
'.Item_item__3OZOO.spottable:focus .Icon_icon__3xbFH,',
'.Item_item__3OZOO.spottable:focus .Icon_icon__uDPTY,',
'.Item_item__3gT3d.spottable:focus .Icon_icon__3xbFH,',
'.Item_item__3gT3d.spottable:focus .Icon_icon__uDPTY {',
' color: #1a1a1a !important;',
'}',
// FormCheckboxItem focus
'.FormCheckboxItem_item__s8bcU.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.FormCheckboxItem_item__s8bcU.spottable:focus .FormCheckboxItem_bg__3GHsc {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
// Disabled items: force content children visible
'[disabled] .Item_itemContent__19m1V,',
'[aria-disabled="true"] .Item_itemContent__19m1V,',
'[disabled] .Item_label__2Wgl2,',
'[aria-disabled="true"] .Item_label__2Wgl2,',
'[disabled] .Item_slotAfter__hf946,',
'[aria-disabled="true"] .Item_slotAfter__hf946,',
'[disabled] .Item_slotBefore__39lzy,',
'[aria-disabled="true"] .Item_slotBefore__39lzy,',
'[disabled] .Icon_icon__3xbFH,',
'[disabled] .Icon_icon__uDPTY,',
'[aria-disabled="true"] .Icon_icon__3xbFH,',
'[aria-disabled="true"] .Icon_icon__uDPTY {',
' opacity: 1 !important;',
' color: rgba(255,255,255,0.5) !important;',
'}',
// Button focus
'.Button_button__2U0Zv.spottable:focus,',
'.Button_button__msIzD.spottable:focus {',
' color: #1a1a1a !important;',
'}',
'.Button_button__2U0Zv.spottable:focus .Button_bg__1Dcsq,',
'.Button_button__2U0Zv.spottable:focus .Button_bg__AoDJy,',
'.Button_button__msIzD.spottable:focus .Button_bg__1Dcsq,',
'.Button_button__msIzD.spottable:focus .Button_bg__AoDJy {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.Button_button__2U0Zv.spottable:focus .Button_client__2HSGm,',
'.Button_button__2U0Zv.spottable:focus .Button_client__3Hkj3,',
'.Button_button__msIzD.spottable:focus .Button_client__2HSGm,',
'.Button_button__msIzD.spottable:focus .Button_client__3Hkj3,',
'.Button_button__2U0Zv.spottable:focus .Icon_icon__3xbFH,',
'.Button_button__2U0Zv.spottable:focus .Icon_icon__uDPTY,',
'.Button_button__msIzD.spottable:focus .Icon_icon__3xbFH,',
'.Button_button__msIzD.spottable:focus .Icon_icon__uDPTY {',
' color: #1a1a1a !important;',
'}',
// Checkbox focus
'.Checkbox_checkbox__1mTpP.spottable:focus .Checkbox_bg__1-1hH {',
' opacity: 1 !important;',
' background-color: #fff !important;',
' background-image: none !important;',
' filter: none !important;',
' border-radius: 9999px !important;',
'}',
'.spottable:focus { outline: none !important; }',
// -------------------------------------------------------
// 4. TEXT & MISC
// -------------------------------------------------------
'.Item_item__3OZOO, .Item_item__3gT3d {',
' color: rgba(255,255,255,0.85) !important;',
'}',
'.Button_button__2U0Zv, .Button_button__msIzD {',
' color: rgba(255,255,255,0.85) !important;',
' border-radius: 9999px !important;',
'}',
'.Header_header__1wT5v {',
' color: #fff !important;',
' border-bottom: 1px solid rgba(255,255,255,0.06) !important;',
'}',
'h1, h2, h3 { color: #fff !important; }',
'.Heading_heading__Exc3p { color: #fff !important; }',
// Notification-specific: NotiHeader
'.NotiHeader_children__5zAyB { color: rgba(255,255,255,0.85) !important; }',
'.NotiHeader_headerButton__Y2scK { color: rgba(255,255,255,0.7) !important; }',
'.NotiIcon_icon__3m2-f { color: rgba(255,255,255,0.85) !important; }',
// Alert popup styling
'.Alert_alert__1hTPw,',
'.Alert_popup__2t0iD {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Alert_title__1Qf_f { color: #fff !important; }',
'.Alert_body__3frtV { color: rgba(255,255,255,0.85) !important; }',
// FixedPopupPanels (detail views)
'.FixedPopupPanels_fixedPopupPanels__12XNO,',
'.FixedPopupPanels_body__jZC_5,',
'.FixedPopupPanels_panel__3qAUZ {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
// Popup container
'.Popup_popup__K1Ygj {',
' background-color: rgba(0,0,0,0.75) !important;',
' border-radius: 24px !important;',
'}',
'.Popup_body__1N5b9 { color: rgba(255,255,255,0.85) !important; }',
'* { scrollbar-width: thin; scrollbar-color: rgba(255,255,255,0.08) transparent; }',
''
].join('\n');
function injectStyle() {
if (document.getElementById(STYLE_ID)) return;
var style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = CSS;
document.head.appendChild(style);
}
if (document.body) {
injectStyle();
} else {
document.addEventListener('DOMContentLoaded', injectStyle);
}
new MutationObserver(function () {
if (!document.getElementById(STYLE_ID)) injectStyle();
}).observe(document.documentElement, { childList: true, subtree: true });
})();
+469
View File
@@ -0,0 +1,469 @@
//! Home Assistant WebSocket client running on a dedicated background thread.
//! Follows the same mpsc/thread pattern as weather-service.rs — no async runtime.
use serde_json::{Value, json};
use std::{
collections::HashMap,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
const RECONNECT_DELAY: Duration = Duration::from_secs(5);
const PING_INTERVAL: Duration = Duration::from_secs(30);
const READ_TIMEOUT: Duration = Duration::from_secs(45);
// ── Public types ──────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct EntityState {
pub entity_id: String,
pub state: String,
pub attributes: Value,
pub last_changed: String,
}
#[derive(Clone, Debug)]
pub struct HistoryPoint {
pub timestamp: f64,
pub value: f64,
}
pub enum HaResponse {
Connected,
Disconnected(String),
StateChanged(EntityState),
AllStates(Vec<EntityState>),
History {
entity_id: String,
points: Vec<HistoryPoint>,
},
ServiceResult {
id: u64,
success: bool,
},
}
pub enum HaCommand {
CallService {
id: u64,
domain: String,
service: String,
target: Value,
data: Value,
},
FetchHistory {
entity_id: String,
hours: u32,
},
}
// ── Config ────────────────────────────────────────────────────────────
#[derive(Clone, Debug)]
pub struct HaConfig {
pub url: String,
pub token: String,
pub entity_ids: Vec<String>,
}
// ── Service (main-thread handle) ──────────────────────────────────────
pub struct HaService {
pub receiver: mpsc::Receiver<HaResponse>,
commands: mpsc::Sender<HaCommand>,
closed: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl HaService {
pub fn new(config: HaConfig) -> Self {
let (resp_tx, resp_rx) = mpsc::channel();
let (cmd_tx, cmd_rx) = mpsc::channel();
let closed = Arc::new(AtomicBool::new(false));
let closed_flag = closed.clone();
let worker = thread::spawn(move || {
worker_loop(config, resp_tx, cmd_rx, closed_flag);
});
Self {
receiver: resp_rx,
commands: cmd_tx,
closed,
worker: Some(worker),
}
}
pub fn send(&self, command: HaCommand) {
let _ = self.commands.send(command);
}
}
impl Drop for HaService {
fn drop(&mut self) {
self.closed.store(true, Ordering::SeqCst);
if let Some(worker) = self.worker.take() {
let _ = worker.join();
}
}
}
// ── Worker thread ─────────────────────────────────────────────────────
fn worker_loop(
config: HaConfig,
responses: mpsc::Sender<HaResponse>,
commands: mpsc::Receiver<HaCommand>,
closed: Arc<AtomicBool>,
) {
while !closed.load(Ordering::SeqCst) {
match connect_and_run(&config, &responses, &commands, &closed) {
Ok(()) => break,
Err(e) => {
eprintln!("HA WebSocket: {e}");
let _ = responses.send(HaResponse::Disconnected(e));
if closed.load(Ordering::SeqCst) {
break;
}
thread::sleep(RECONNECT_DELAY);
}
}
}
}
fn connect_and_run(
config: &HaConfig,
responses: &mpsc::Sender<HaResponse>,
commands: &mpsc::Receiver<HaCommand>,
closed: &Arc<AtomicBool>,
) -> Result<(), String> {
use tungstenite::{Message, connect};
let (mut ws, _) =
connect(&config.url).map_err(|e| format!("connect failed: {e}"))?;
match ws.get_ref() {
tungstenite::stream::MaybeTlsStream::Plain(tcp) => {
tcp.set_read_timeout(Some(READ_TIMEOUT)).ok();
}
tungstenite::stream::MaybeTlsStream::Rustls(tls) => {
tls.get_ref().set_read_timeout(Some(READ_TIMEOUT)).ok();
}
_ => {}
}
let mut msg_id: u64 = 0;
let mut next_id = || {
msg_id += 1;
msg_id
};
// Phase 1: auth_required
let auth_required = read_json(&mut ws)?;
if auth_required.get("type").and_then(Value::as_str) != Some("auth_required") {
return Err(format!("expected auth_required, got: {auth_required}"));
}
// Phase 2: authenticate
ws_send(
&mut ws,
&json!({ "type": "auth", "access_token": config.token }),
)?;
let auth_result = read_json(&mut ws)?;
match auth_result.get("type").and_then(Value::as_str) {
Some("auth_ok") => {}
Some("auth_invalid") => {
return Err(format!(
"auth failed: {}",
auth_result
.get("message")
.and_then(Value::as_str)
.unwrap_or("invalid token")
));
}
_ => return Err(format!("unexpected auth response: {auth_result}")),
}
let _ = responses.send(HaResponse::Connected);
// Phase 3: get all states
let states_id = next_id();
ws_send(&mut ws, &json!({ "id": states_id, "type": "get_states" }))?;
// Phase 4: subscribe to state changes
let sub_id = next_id();
ws_send(
&mut ws,
&json!({
"id": sub_id,
"type": "subscribe_events",
"event_type": "state_changed"
}),
)?;
// Pending service call / history requests mapped by HA message id
let mut pending_service: HashMap<u64, u64> = HashMap::new(); // ha_id -> caller_id
let mut pending_history: HashMap<u64, String> = HashMap::new(); // ha_id -> entity_id
let mut last_ping = Instant::now();
loop {
if closed.load(Ordering::SeqCst) {
let _ = ws.close(None);
return Ok(());
}
// Drain commands from the main thread
while let Ok(cmd) = commands.try_recv() {
match cmd {
HaCommand::CallService {
id: caller_id,
domain,
service,
target,
data,
} => {
let ha_id = next_id();
pending_service.insert(ha_id, caller_id);
ws_send(
&mut ws,
&json!({
"id": ha_id,
"type": "call_service",
"domain": domain,
"service": service,
"target": target,
"service_data": data,
}),
)?;
}
HaCommand::FetchHistory { entity_id, hours } => {
let ha_id = next_id();
pending_history.insert(ha_id, entity_id.clone());
let now = time::OffsetDateTime::now_utc();
let start = now - time::Duration::hours(hours as i64);
let format =
time::format_description::well_known::Rfc3339;
let start_str = start.format(&format).unwrap_or_default();
let end_str = now.format(&format).unwrap_or_default();
ws_send(
&mut ws,
&json!({
"id": ha_id,
"type": "recorder/statistics_during_period",
"start_time": start_str,
"end_time": end_str,
"statistic_ids": [entity_id],
"period": "5minute",
"types": ["mean"],
}),
)?;
}
}
}
// Send ping if needed
if last_ping.elapsed() >= PING_INTERVAL {
let ping_id = next_id();
ws_send(&mut ws, &json!({ "id": ping_id, "type": "ping" }))?;
last_ping = Instant::now();
}
// Read one message (with timeout set on the socket)
let msg = match ws.read() {
Ok(msg) => msg,
Err(tungstenite::Error::Io(ref e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut =>
{
continue;
}
Err(e) => return Err(format!("read error: {e}")),
};
let text = match msg {
Message::Text(t) => t,
Message::Ping(data) => {
let _ = ws.send(Message::Pong(data));
continue;
}
Message::Close(_) => return Err("server closed connection".into()),
_ => continue,
};
let value: Value = match serde_json::from_str(&text) {
Ok(v) => v,
Err(_) => continue,
};
let msg_type = value.get("type").and_then(Value::as_str).unwrap_or("");
let id = value.get("id").and_then(Value::as_u64).unwrap_or(0);
match msg_type {
"result" if id == states_id => {
if let Some(result) = value.get("result").and_then(Value::as_array) {
let states: Vec<EntityState> = result
.iter()
.filter_map(parse_entity_state)
.filter(|e| config.entity_ids.contains(&e.entity_id))
.collect();
let _ = responses.send(HaResponse::AllStates(states));
}
}
"event" => {
if let Some(event_data) = value
.get("event")
.and_then(|e| e.get("data"))
.and_then(|d| d.get("new_state"))
{
if let Some(entity) = parse_entity_state(event_data) {
if config.entity_ids.contains(&entity.entity_id) {
let _ = responses.send(HaResponse::StateChanged(entity));
}
}
}
}
"result" if pending_service.contains_key(&id) => {
if let Some(caller_id) = pending_service.remove(&id) {
let success = value
.get("success")
.and_then(Value::as_bool)
.unwrap_or(false);
let _ = responses.send(HaResponse::ServiceResult {
id: caller_id,
success,
});
}
}
"result" if pending_history.contains_key(&id) => {
if let Some(entity_id) = pending_history.remove(&id) {
let points = parse_statistics(&value, &entity_id);
let _ = responses.send(HaResponse::History {
entity_id,
points,
});
}
}
"pong" => {}
_ => {}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────
type WsStream = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>;
fn ws_send(ws: &mut WsStream, value: &Value) -> Result<(), String> {
let text = serde_json::to_string(value).map_err(|e| e.to_string())?;
ws.send(tungstenite::Message::Text(text))
.map_err(|e| format!("send failed: {e}"))
}
fn read_json(ws: &mut WsStream) -> Result<Value, String> {
loop {
match ws.read() {
Ok(tungstenite::Message::Text(text)) => {
return serde_json::from_str(&text)
.map_err(|e| format!("invalid JSON: {e}"));
}
Ok(tungstenite::Message::Ping(data)) => {
let _ = ws.send(tungstenite::Message::Pong(data));
}
Ok(tungstenite::Message::Close(_)) => {
return Err("connection closed during handshake".into());
}
Ok(_) => continue,
Err(e) => return Err(format!("read failed: {e}")),
}
}
}
fn parse_entity_state(value: &Value) -> Option<EntityState> {
Some(EntityState {
entity_id: value.get("entity_id")?.as_str()?.to_owned(),
state: value.get("state")?.as_str()?.to_owned(),
attributes: value.get("attributes").cloned().unwrap_or(Value::Object(Default::default())),
last_changed: value
.get("last_changed")
.and_then(Value::as_str)
.unwrap_or("")
.to_owned(),
})
}
fn parse_statistics(value: &Value, entity_id: &str) -> Vec<HistoryPoint> {
let result = match value.get("result") {
Some(r) => r,
None => return vec![],
};
let stats = match result.get(entity_id).and_then(Value::as_array) {
Some(a) => a,
None => return vec![],
};
stats
.iter()
.filter_map(|entry| {
let ts = entry.get("start")?.as_f64()?;
let val = entry.get("mean")?.as_f64()?;
Some(HistoryPoint {
timestamp: ts,
value: val,
})
})
.collect()
}
/// Collect all entity IDs referenced in the HA config JSON.
pub fn entity_ids_from_config(config: &Value) -> Vec<String> {
let mut ids = Vec::new();
if let Some(weather_id) = config
.get("weather")
.and_then(|w| w.get("entity_id"))
.and_then(Value::as_str)
{
ids.push(weather_id.to_owned());
}
if let Some(vacuum) = config.get("vacuum") {
if let Some(id) = vacuum.get("entity_id").and_then(Value::as_str) {
ids.push(id.to_owned());
}
if let Some(id) = vacuum.get("cleaning_mode_entity").and_then(Value::as_str) {
ids.push(id.to_owned());
}
}
if let Some(media) = config.get("media").and_then(|m| m.get("entity_id")).and_then(Value::as_str) {
ids.push(media.to_owned());
}
if let Some(rooms) = config.get("rooms").and_then(Value::as_array) {
for room in rooms {
if let Some(sensors) = room.get("sensors").and_then(Value::as_array) {
for sensor in sensors {
if let Some(id) = sensor.as_str() {
ids.push(id.to_owned());
}
}
}
if let Some(cards) = room.get("cards").and_then(Value::as_array) {
for card in cards {
if let Some(id) = card.get("entity_id").and_then(Value::as_str) {
ids.push(id.to_owned());
}
}
}
}
}
ids.sort();
ids.dedup();
ids
}
+270 -27
View File
@@ -2,6 +2,7 @@ mod app_artwork;
mod branding;
mod diagnostics;
mod font;
mod ha_client;
mod i18n;
#[cfg(all(feature = "webos", target_os = "linux", target_arch = "arm"))]
mod luna;
@@ -177,6 +178,7 @@ fn effects(
platform: &Platform,
preferences: &std::path::Path,
weather: &weather_service::Service,
ha: Option<&ha_client::HaService>,
) -> bool {
for effect in effects {
match effect {
@@ -224,11 +226,155 @@ fn effects(
}
Effect::Quit => return false,
Effect::Weather(command) => weather.send(command),
Effect::Ha(ha_effect) => {
if let Some(service) = ha {
ha_dispatch(service, ha_effect);
}
}
}
}
true
}
fn ha_dispatch(service: &ha_client::HaService, effect: state::HaEffect) {
use state::HaEffect;
static CALL_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1000);
let next_id = || CALL_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
match effect {
HaEffect::Toggle(entity_id) => {
let (domain, service_name) = if entity_id.starts_with("light.") {
("light", "toggle")
} else if entity_id.starts_with("switch.") {
("switch", "toggle")
} else if entity_id.starts_with("cover.") {
("cover", "toggle")
} else {
("homeassistant", "toggle")
};
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: domain.into(),
service: service_name.into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::SetBrightness(entity_id, brightness) => {
if brightness <= 0. {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "light".into(),
service: "turn_off".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
} else {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "light".into(),
service: "turn_on".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "brightness": brightness as u8 }),
});
}
}
HaEffect::SetClimateTemp(entity_id, temp) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "climate".into(),
service: "set_temperature".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "temperature": temp }),
});
}
HaEffect::ActivateScene(entity_id) => {
let (domain, service_name) = if entity_id.starts_with("script.") {
("script", "turn_on")
} else {
("scene", "turn_on")
};
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: domain.into(),
service: service_name.into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::VacuumClean { entity_id, segments, mode } => {
if let Some(mode_name) = &mode {
let cleaning_mode_entity = format!(
"select.{}_cleaning_mode",
entity_id.strip_prefix("vacuum.").unwrap_or(&entity_id)
);
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "select".into(),
service: "select_option".into(),
target: serde_json::json!({ "entity_id": cleaning_mode_entity }),
data: serde_json::json!({ "option": mode_name }),
});
}
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "vacuum".into(),
service: "send_command".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({
"command": "app_segment_clean",
"params": { "segments": segments, "repeat": 1 }
}),
});
}
HaEffect::MediaPlayPause(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_play_pause".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::MediaNext(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_next_track".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::MediaPrev(entity_id) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "media_previous_track".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({}),
});
}
HaEffect::SetVolume(entity_id, volume) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "media_player".into(),
service: "volume_set".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "volume_level": volume }),
});
}
HaEffect::SetCoverPosition(entity_id, position) => {
service.send(ha_client::HaCommand::CallService {
id: next_id(),
domain: "cover".into(),
service: "set_cover_position".into(),
target: serde_json::json!({ "entity_id": entity_id }),
data: serde_json::json!({ "position": position }),
});
}
}
}
fn script(
action: &str,
state: &mut State,
@@ -339,6 +485,52 @@ fn weather_tick(state: &mut State, service: &weather_service::Service, visible:
}
}
fn ha_tick(state: &mut State, service: &ha_client::HaService) {
while let Ok(response) = service.receiver.try_recv() {
match response {
ha_client::HaResponse::Connected => {
state.ha.connected = true;
state.dirty = true;
}
ha_client::HaResponse::Disconnected(_) => {
state.ha.connected = false;
state.dirty = true;
}
ha_client::HaResponse::AllStates(entities) => {
for entity in entities {
state.ha.entities.insert(
entity.entity_id,
state::HaEntity {
state: entity.state,
attributes: entity.attributes,
},
);
}
state.dirty = true;
}
ha_client::HaResponse::StateChanged(entity) => {
state.ha.entities.insert(
entity.entity_id,
state::HaEntity {
state: entity.state,
attributes: entity.attributes,
},
);
state.dirty = true;
}
ha_client::HaResponse::History { entity_id, points } => {
let data: Vec<(f64, f64)> =
points.into_iter().map(|p| (p.timestamp, p.value)).collect();
state.ha.history.insert(entity_id, data);
state.dirty = true;
}
ha_client::HaResponse::ServiceResult { .. } => {
state.dirty = true;
}
}
}
}
fn scripted_click(
sdl: &sdl2::Sdl,
window: &sdl2::video::Window,
@@ -470,6 +662,35 @@ fn run() -> Result<(), String> {
weather_service::Client::new(options.weather_test_server.as_deref())?,
weather_cache,
);
let ha_config_path = if webos {
PathBuf::from("/media/developer/apps/usr/palm/applications/com.ecklf.luma/ha-home/config.json")
} else {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("ha-home/config.json")
};
let ha_service = if let Ok(bytes) = std::fs::read(&ha_config_path) {
if let Ok(config_value) = serde_json::from_slice::<serde_json::Value>(&bytes) {
state.ha.config = state::HaTabConfig::parse(&config_value);
let url = config_value.get("ha").and_then(|h| h.get("url")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
let token = config_value.get("ha").and_then(|h| h.get("token")).and_then(|v| v.as_str()).unwrap_or("").to_owned();
if !url.is_empty() && !token.is_empty() {
let entity_ids = ha_client::entity_ids_from_config(&config_value);
Some(ha_client::HaService::new(ha_client::HaConfig {
url,
token,
entity_ids,
}))
} else {
eprintln!("HA config: missing url or token, HA tab disabled");
None
}
} else {
eprintln!("HA config: invalid JSON at {}", ha_config_path.display());
None
}
} else {
eprintln!("HA config not found at {}, HA tab disabled", ha_config_path.display());
None
};
let platform = Platform::new(
webos,
options.preferences.is_none() && options.test_extra_apps == 0,
@@ -480,6 +701,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
platform.sync_splash(&state.preferences.background);
startup_stage("preferences and workers");
@@ -650,6 +872,9 @@ fn run() -> Result<(), String> {
}
}
weather_tick(&mut state, &weather_service, visible);
if let Some(ref ha) = ha_service {
ha_tick(&mut state, ha);
}
// Rebuild hit regions before input whenever inventory/layout changed.
if state.dirty {
scene = compose(&mut state, &assets, pill, pill_width, now);
@@ -847,34 +1072,46 @@ fn run() -> Result<(), String> {
mouse_y,
..
} if state.overlay.is_none() => {
let over_favorites = state.page == state::Page::Home
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
(64. ..1856.).contains(&x)
&& scene.controls.iter().any(|control| {
matches!(control.target, Target::App(_))
&& control.rect.contains(x, y)
})
});
if state.page == state::Page::Home && (x != 0 || over_favorites) {
state.scroll_favorites(
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
);
press = None;
// HA card scroll: adjust range/climate/media values
let ha_scroll = state.page == state::Page::Ha
&& matches!(&state.focus, state::Target::HaCard(room, index)
if matches!(
state.ha.config.rooms[*room].cards[*index].card_type,
state::HaCardType::Range | state::HaCardType::Climate | state::HaCardType::Media
))
&& y != 0;
if ha_scroll {
pending = state.scroll_ha(y);
} else {
let max = scene
.controls
.iter()
.filter(|c| c.scrollable)
.map(|c| {
c.rect.y + c.rect.h + state.scroll
- if state.page == state::Page::Home {
1041.6
} else {
982.4
}
})
.fold(0., f32::max);
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
let over_favorites = state.page == state::Page::Home
&& pointer_position(&window, mouse_x, mouse_y).is_some_and(|(x, y)| {
(64. ..1856.).contains(&x)
&& scene.controls.iter().any(|control| {
matches!(control.target, Target::App(_))
&& control.rect.contains(x, y)
})
});
if state.page == state::Page::Home && (x != 0 || over_favorites) {
state.scroll_favorites(
(if x != 0 { x } else { -y }) as f32 * state::HOME_TILE_PITCH,
);
press = None;
} else {
let max = scene
.controls
.iter()
.filter(|c| c.scrollable)
.map(|c| {
c.rect.y + c.rect.h + state.scroll
- if state.page == state::Page::Home {
1041.6
} else {
982.4
}
})
.fold(0., f32::max);
state.scroll = (state.scroll - y as f32 * 90.).clamp(0., max.max(0.));
}
}
state.pointer = true;
state.dirty = true;
@@ -887,6 +1124,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
if press
.as_ref()
@@ -930,10 +1168,14 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
}
}
weather_tick(&mut state, &weather_service, visible);
if let Some(ref ha) = ha_service {
ha_tick(&mut state, ha);
}
// SDL-webOS disables text input when its own panel is dismissed, even
// if it did not forward a Back/Return key to the application.
if text_active
@@ -948,6 +1190,7 @@ fn run() -> Result<(), String> {
&platform,
&preferences_path,
&weather_service,
ha_service.as_ref(),
);
}
let keyboard = state.text_input_active();
+463 -1
View File
@@ -62,6 +62,14 @@ pub enum Target {
WeatherSearch,
WeatherClear,
WeatherResult(usize),
HaRoom(usize),
HaCard(usize, usize),
HaWeather,
HaVacuumOpen,
HaVacuumRoom(usize),
HaVacuumMode,
HaVacuumStart,
HaVacuumClose,
}
#[derive(Clone, Copy, Debug, Default)]
@@ -139,6 +147,25 @@ pub enum Effect {
Launch(String),
Quit,
Weather(weather::Command),
Ha(HaEffect),
}
#[derive(Debug, PartialEq)]
pub enum HaEffect {
Toggle(String),
SetBrightness(String, f32),
SetClimateTemp(String, f32),
ActivateScene(String),
VacuumClean {
entity_id: String,
segments: Vec<u32>,
mode: Option<String>,
},
MediaPlayPause(String),
MediaNext(String),
MediaPrev(String),
SetVolume(String, f32),
SetCoverPosition(String, u8),
}
pub const KEYS: [&str; 40] = [
@@ -147,6 +174,146 @@ pub const KEYS: [&str; 40] = [
"Delete", "Clear", "Done",
];
#[derive(Clone, Debug)]
pub enum HaCardType {
Toggle,
Range,
Climate,
Action,
Vacuum,
Media,
Curtain,
Blinds,
}
#[derive(Clone, Debug)]
pub struct HaCardConfig {
pub card_type: HaCardType,
pub entity_id: String,
pub name: String,
}
#[derive(Clone, Debug)]
pub struct HaRoomConfig {
pub id: String,
pub name: String,
pub cards: Vec<HaCardConfig>,
}
#[derive(Clone, Debug, Default)]
pub struct HaWeatherConfig {
pub entity_id: String,
}
#[derive(Clone, Debug)]
pub struct HaVacuumRoom {
pub segment_id: u32,
pub name: String,
}
#[derive(Clone, Debug, Default)]
pub struct HaVacuumConfig {
pub entity_id: String,
pub cleaning_mode_entity: String,
pub rooms: Vec<HaVacuumRoom>,
}
#[derive(Clone, Debug, Default)]
pub struct HaTabConfig {
pub rooms: Vec<HaRoomConfig>,
pub weather: Option<HaWeatherConfig>,
pub vacuum: Option<HaVacuumConfig>,
}
#[derive(Default)]
pub struct HaState {
pub connected: bool,
pub entities: std::collections::HashMap<String, HaEntity>,
pub history: std::collections::HashMap<String, Vec<(f64, f64)>>,
pub config: HaTabConfig,
pub room: usize,
pub vacuum_selected: Vec<bool>,
pub vacuum_mode: usize,
pub vacuum_open: bool,
}
#[derive(Clone, Debug)]
pub struct HaEntity {
pub state: String,
pub attributes: serde_json::Value,
}
impl HaTabConfig {
pub fn parse(value: &serde_json::Value) -> Self {
let mut config = HaTabConfig::default();
if let Some(w) = value.get("weather") {
if let Some(eid) = w.get("entity_id").and_then(|v| v.as_str()) {
config.weather = Some(HaWeatherConfig {
entity_id: eid.to_owned(),
});
}
}
if let Some(v) = value.get("vacuum") {
let mut vc = HaVacuumConfig::default();
if let Some(eid) = v.get("entity_id").and_then(|v| v.as_str()) {
vc.entity_id = eid.to_owned();
}
if let Some(cme) = v.get("cleaning_mode_entity").and_then(|v| v.as_str()) {
vc.cleaning_mode_entity = cme.to_owned();
}
if let Some(rooms) = v.get("rooms").and_then(|v| v.as_object()) {
for (id, name) in rooms {
if let (Ok(seg), Some(n)) = (id.parse::<u32>(), name.as_str()) {
vc.rooms.push(HaVacuumRoom {
segment_id: seg,
name: n.to_owned(),
});
}
}
vc.rooms.sort_by_key(|r| r.segment_id);
}
if !vc.entity_id.is_empty() {
config.vacuum = Some(vc);
}
}
if let Some(rooms) = value.get("rooms").and_then(|v| v.as_array()) {
for room in rooms {
let id = room.get("id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let name = room.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let mut cards = Vec::new();
if let Some(card_arr) = room.get("cards").and_then(|v| v.as_array()) {
for card in card_arr {
let ct = match card.get("type").and_then(|v| v.as_str()) {
Some("toggle") => HaCardType::Toggle,
Some("range") => HaCardType::Range,
Some("climate") => HaCardType::Climate,
Some("action") => HaCardType::Action,
Some("vacuum") => HaCardType::Vacuum,
Some("media") => HaCardType::Media,
Some("curtain") => HaCardType::Curtain,
Some("blinds") => HaCardType::Blinds,
_ => continue,
};
let eid = card.get("entity_id").and_then(|v| v.as_str()).unwrap_or("").to_owned();
let n = card.get("name").and_then(|v| v.as_str()).unwrap_or("").to_owned();
cards.push(HaCardConfig {
card_type: ct,
entity_id: eid,
name: n,
});
}
}
config.rooms.push(HaRoomConfig { id, name, cards });
}
}
config
}
}
pub struct State {
pub page: Page,
pub focus: Target,
@@ -172,6 +339,7 @@ pub struct State {
pub previous_background: Option<(String, Instant)>,
pub panel_started: Option<Instant>,
pub weather: weather::Weather,
pub ha: HaState,
pub keyboard_field: TextField,
pub greeting_name_draft: String,
pub input_name: String,
@@ -203,6 +371,7 @@ impl State {
previous_background: None,
panel_started: None,
weather: weather::Weather::default(),
ha: HaState::default(),
keyboard_field: TextField::Apps,
greeting_name_draft: String::new(),
input_name: String::new(),
@@ -395,7 +564,13 @@ impl State {
.first()
.map(|app| Target::App(app.id.clone()))
.unwrap_or(Target::Filter(1)),
Page::Ha => Target::Nav(2),
Page::Ha => {
if self.ha.config.rooms.is_empty() {
Target::Nav(2)
} else {
Target::HaRoom(self.ha.room)
}
}
Page::Settings => Target::Sidebar(0),
}
}
@@ -621,6 +796,80 @@ impl State {
return vec![];
}
}
// ── HA page navigation ────────────────────────────────
if self.page == Page::Ha && !self.ha.config.rooms.is_empty() {
if self.ha.vacuum_open {
return self.ha_vacuum_direction(direction, controls);
}
if let Target::HaRoom(room) = self.focus {
match direction {
Direction::Left if room > 0 => {
self.ha.room = room - 1;
self.focus(Target::HaRoom(room - 1), controls);
self.scroll = 0.;
}
Direction::Right if room + 1 < self.ha.config.rooms.len() => {
self.ha.room = room + 1;
self.focus(Target::HaRoom(room + 1), controls);
self.scroll = 0.;
}
Direction::Up => self.focus(Target::Nav(2), controls),
Direction::Down => {
if let Some(weather) = &self.ha.config.weather
&& self.ha.entities.contains_key(&weather.entity_id)
{
self.focus(Target::HaWeather, controls);
} else if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
self.focus(Target::HaCard(self.ha.room, 0), controls);
}
}
_ => {}
}
return vec![];
}
if self.focus == Target::HaWeather {
match direction {
Direction::Up => self.focus(Target::HaRoom(self.ha.room), controls),
Direction::Down => {
if !self.ha.config.rooms[self.ha.room].cards.is_empty() {
self.focus(Target::HaCard(self.ha.room, 0), controls);
}
}
_ => {}
}
return vec![];
}
if let Target::HaCard(room, index) = self.focus {
let room_cfg = &self.ha.config.rooms[room];
let cols = self.ha_card_columns();
match direction {
Direction::Left if index % cols > 0 => {
self.focus(Target::HaCard(room, index - 1), controls);
}
Direction::Right if index % cols + 1 < cols && index + 1 < room_cfg.cards.len() => {
self.focus(Target::HaCard(room, index + 1), controls);
}
Direction::Up => {
if index >= cols {
self.focus(Target::HaCard(room, index - cols), controls);
} else if self.ha.config.weather.is_some() {
self.focus(Target::HaWeather, controls);
} else {
self.focus(Target::HaRoom(self.ha.room), controls);
}
}
Direction::Down => {
let next = index + cols;
if next < room_cfg.cards.len() {
self.focus(Target::HaCard(room, next), controls);
}
}
_ => {}
}
return vec![];
}
}
let setting_rows = match self.subpage {
Some(Subpage::Greeting) => Some([Target::GreetingName, Target::GreetingNameClear]),
Some(Subpage::AppNames) => Some([Target::AppNamesHome, Target::AppNamesApps]),
@@ -967,6 +1216,124 @@ impl State {
vec![]
}
fn ha_card_columns(&self) -> usize {
4
}
fn ha_entity_brightness(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("brightness"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
fn ha_entity_target_temp(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("temperature"))
.and_then(|v| v.as_f64())
.unwrap_or(20.) as f32
}
fn ha_entity_volume(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("volume_level"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
fn ha_entity_position(&self, entity_id: &str) -> f32 {
self.ha.entities.get(entity_id)
.and_then(|e| e.attributes.get("current_position"))
.and_then(|v| v.as_f64())
.unwrap_or(0.) as f32
}
pub fn scroll_ha(&mut self, delta: i32) -> Vec<Effect> {
if let Target::HaCard(room, index) = &self.focus {
let card = &self.ha.config.rooms[*room].cards[*index];
match card.card_type {
HaCardType::Range => {
let brightness = self.ha_entity_brightness(&card.entity_id);
let new = (brightness + delta as f32 * 25.5).clamp(0., 255.);
return vec![Effect::Ha(HaEffect::SetBrightness(card.entity_id.clone(), new))];
}
HaCardType::Climate => {
let temp = self.ha_entity_target_temp(&card.entity_id);
let new = temp + delta as f32 * 0.5;
return vec![Effect::Ha(HaEffect::SetClimateTemp(card.entity_id.clone(), new))];
}
HaCardType::Media => {
let vol = self.ha_entity_volume(&card.entity_id);
let new = (vol + delta as f32 * 0.05).clamp(0., 1.);
return vec![Effect::Ha(HaEffect::SetVolume(card.entity_id.clone(), new))];
}
HaCardType::Curtain | HaCardType::Blinds => {
let pos = self.ha_entity_position(&card.entity_id);
let new = (pos + delta as f32 * 10.).clamp(0., 100.) as u8;
return vec![Effect::Ha(HaEffect::SetCoverPosition(card.entity_id.clone(), new))];
}
_ => {}
}
}
vec![]
}
fn ha_vacuum_direction(&mut self, direction: Direction, controls: &[Control]) -> Vec<Effect> {
let vac = match &self.ha.config.vacuum {
Some(v) => v,
None => return vec![],
};
let room_count = vac.rooms.len();
let cols = 3usize;
match &self.focus {
Target::HaVacuumRoom(index) => {
let index = *index;
match direction {
Direction::Left if index % cols > 0 => {
self.focus(Target::HaVacuumRoom(index - 1), controls);
}
Direction::Right if index % cols + 1 < cols && index + 1 < room_count => {
self.focus(Target::HaVacuumRoom(index + 1), controls);
}
Direction::Up if index >= cols => {
self.focus(Target::HaVacuumRoom(index - cols), controls);
}
Direction::Up => {
self.focus(Target::HaVacuumClose, controls);
}
Direction::Down => {
let next = index + cols;
if next < room_count {
self.focus(Target::HaVacuumRoom(next), controls);
} else {
self.focus(Target::HaVacuumMode, controls);
}
}
_ => {}
}
}
Target::HaVacuumMode => match direction {
Direction::Up => {
let last_row_start = (room_count.saturating_sub(1)) / cols * cols;
let idx = (last_row_start).min(room_count.saturating_sub(1));
self.focus(Target::HaVacuumRoom(idx), controls);
}
Direction::Down => self.focus(Target::HaVacuumStart, controls),
_ => {}
},
Target::HaVacuumStart => match direction {
Direction::Up => self.focus(Target::HaVacuumMode, controls),
_ => {}
},
Target::HaVacuumClose => match direction {
Direction::Down => self.focus(Target::HaVacuumRoom(0), controls),
_ => {}
},
_ => {}
}
vec![]
}
pub fn options(&mut self) {
if self.overlay.is_some() || self.moving.is_some() {
return;
@@ -1315,6 +1682,88 @@ impl State {
return vec![Effect::Save];
}
}
Target::HaRoom(index) => {
self.ha.room = index;
self.scroll = 0.;
if !self.ha.config.rooms[index].cards.is_empty() {
self.focus = Target::HaCard(index, 0);
}
}
Target::HaCard(room, index) => {
let card = &self.ha.config.rooms[room].cards[index];
match card.card_type {
HaCardType::Toggle => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
}
HaCardType::Action => {
return vec![Effect::Ha(HaEffect::ActivateScene(card.entity_id.clone()))];
}
HaCardType::Vacuum => {
if let Some(vac) = &self.ha.config.vacuum {
self.ha.vacuum_open = true;
self.ha.vacuum_selected = vec![false; vac.rooms.len()];
self.ha.vacuum_mode = 0;
self.focus = Target::HaVacuumRoom(0);
}
}
HaCardType::Media => {
return vec![Effect::Ha(HaEffect::MediaPlayPause(card.entity_id.clone()))];
}
HaCardType::Curtain | HaCardType::Blinds => {
return vec![Effect::Ha(HaEffect::Toggle(card.entity_id.clone()))];
}
_ => {}
}
}
Target::HaVacuumRoom(index) => {
if index < self.ha.vacuum_selected.len() {
self.ha.vacuum_selected[index] = !self.ha.vacuum_selected[index];
}
}
Target::HaVacuumMode => {
if let Some(vac) = &self.ha.config.vacuum {
if let Some(entity) = self.ha.entities.get(&vac.cleaning_mode_entity) {
if let Some(options) = entity.attributes.get("options").and_then(|o| o.as_array()) {
self.ha.vacuum_mode = (self.ha.vacuum_mode + 1) % options.len().max(1);
}
}
}
}
Target::HaVacuumStart => {
if let Some(vac) = &self.ha.config.vacuum {
let segments: Vec<u32> = vac.rooms.iter()
.enumerate()
.filter(|(i, _)| self.ha.vacuum_selected.get(*i).copied().unwrap_or(false))
.map(|(_, r)| r.segment_id)
.collect();
if !segments.is_empty() {
let mode = self.ha.entities.get(&vac.cleaning_mode_entity)
.and_then(|e| e.attributes.get("options"))
.and_then(|o| o.as_array())
.and_then(|opts| opts.get(self.ha.vacuum_mode))
.and_then(|v| v.as_str())
.map(|s| s.to_owned());
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
return vec![Effect::Ha(HaEffect::VacuumClean {
entity_id: vac.entity_id.clone(),
segments,
mode,
})];
}
}
}
Target::HaVacuumClose => {
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
}
Target::HaVacuumOpen | Target::HaWeather => {}
Target::Sidebar(index) => {
self.section = index;
self.scroll = 0.;
@@ -1545,6 +1994,19 @@ impl State {
self.scroll = 0.;
return vec![];
}
if self.page == Page::Ha && self.ha.vacuum_open {
self.ha.vacuum_open = false;
self.focus = Target::HaCard(self.ha.room,
self.ha.config.rooms[self.ha.room].cards.iter()
.position(|c| matches!(c.card_type, HaCardType::Vacuum))
.unwrap_or(0));
return vec![];
}
if self.page == Page::Ha && matches!(self.focus, Target::HaCard(..)) {
self.focus = Target::HaRoom(self.ha.room);
self.scroll = 0.;
return vec![];
}
if self.page == Page::Apps && matches!(self.focus, Target::Search | Target::ClearSearch) {
self.query.clear();
self.focus = Target::Nav(1);
+1070 -9
View File
File diff suppressed because it is too large. Load diff
+36
View File
@@ -0,0 +1,36 @@
FROM debian:buster-slim
# Buster is archived — point sources at archive.debian.org
RUN sed -i 's|deb.debian.org|archive.debian.org|g; s|security.debian.org|archive.debian.org|g; /buster-updates/d' /etc/apt/sources.list && \
apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates pkg-config cmake git \
gcc libc6-dev gcc-arm-linux-gnueabi binutils-arm-linux-gnueabi libc6-dev-armel-cross \
python3 \
xz-utils && \
rm -rf /var/lib/apt/lists/*
# Node.js 20 binary (supports glibc 2.28+, has import.meta.dirname)
RUN curl -fsSL https://nodejs.org/dist/v20.18.1/node-v20.18.1-linux-x64.tar.xz | \
tar -xJ --strip-components=1 -C /usr/local && \
npm install -g pnpm@9
# Rust with ARM soft-float target
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --default-toolchain stable --profile minimal && \
. /root/.cargo/env && \
rustup target add armv7-unknown-linux-gnueabi
# Create a fake WEBOS_NDK that wraps the standard cross-compiler
RUN mkdir -p /opt/webos-ndk/bin && \
ln -s /usr/bin/arm-linux-gnueabi-gcc /opt/webos-ndk/bin/arm-webos-linux-gnueabi-gcc && \
ln -s /usr/bin/arm-linux-gnueabi-ar /opt/webos-ndk/bin/arm-webos-linux-gnueabi-ar && \
ln -s /usr/bin/arm-linux-gnueabi-strip /opt/webos-ndk/bin/arm-webos-linux-gnueabi-strip && \
mkdir -p /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/include && \
cp -a /usr/arm-linux-gnueabi/include/* /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/include/ && \
mkdir -p /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/lib && \
cp -a /usr/arm-linux-gnueabi/lib/* /opt/webos-ndk/arm-webos-linux-gnueabi/sysroot/usr/lib/
ENV WEBOS_NDK=/opt/webos-ndk
ENV PATH="/root/.cargo/bin:${PATH}"
WORKDIR /build
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
set -e
CC=/usr/bin/arm-linux-gnueabi-gcc
STUBDIR=/usr/arm-linux-gnueabi/lib
echo "=== Creating webOS stub libraries ==="
cat > /tmp/luna-stub.c << 'STUBEOF'
typedef int gint;
typedef unsigned int guint;
typedef void* gpointer;
int LSErrorInit(void *e) { return 1; }
void LSErrorFree(void *e) {}
int LSRegister(const char *n, void **h, void *e) { return 1; }
int LSUnregister(void *h, void *e) { return 1; }
int LSGmainContextAttach(void *h, void *c, void *e) { return 1; }
int LSCallOneReply(void *h, const char *u, const char *p, void *cb, void *ud, unsigned long *t, void *e) { return 1; }
int LSCallCancel(void *h, unsigned long t, void *e) { return 1; }
const char* LSMessageGetPayload(void *m) { return "{}"; }
STUBEOF
$CC -shared -o $STUBDIR/libluna-service2.so /tmp/luna-stub.c
echo " Created libluna-service2.so stub"
cat > /tmp/glib-stub.c << 'STUBEOF'
void* g_main_context_new(void) { return (void*)0; }
int g_main_context_iteration(void *ctx, int may_block) { return 0; }
void g_main_context_unref(void *ctx) {}
STUBEOF
$CC -shared -o $STUBDIR/libglib-2.0.so /tmp/glib-stub.c
echo " Created libglib-2.0.so stub"
echo "=== Installing npm deps ==="
pnpm install --frozen-lockfile 2>&1 | tail -5 || true
echo "=== Preparing SDL-webOS runtime ==="
node tools/webos-runtime.mjs 2>&1 | tail -5
echo "=== Building for webOS ARM ==="
sh tools/build-webos.sh 2>&1 | tail -30
echo "=== Build result ==="
ls -la target/armv7-unknown-linux-gnueabi/release/luma
arm-linux-gnueabi-strip target/armv7-unknown-linux-gnueabi/release/luma
ls -la target/armv7-unknown-linux-gnueabi/release/luma
echo "=== Done ==="
+1 -1
View File
@@ -18,7 +18,7 @@ export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_LINKER="$CC"
# The target's soft-float *calling convention* is required by webOS, but its
# default software FP codegen is not. Match the NDK's ARMv7/VFP baseline while
# retaining the gnueabi ABI; keep NEON off for portable Rust scalar code.
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS:-} -C target-cpu=cortex-a9 -C target-feature=-neon,-soft-float -C link-arg=--sysroot=$SYSROOT -C link-arg=-Wl,-rpath,\$ORIGIN/lib"
export CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS="${CARGO_TARGET_ARMV7_UNKNOWN_LINUX_GNUEABI_RUSTFLAGS:-} -C target-cpu=cortex-a9 -C target-feature=-neon,-soft-float -C link-arg=--sysroot=$SYSROOT -C link-arg=-Wl,-rpath,\$ORIGIN/lib -C link-arg=-Wl,--disable-new-dtags"
export CC_armv7_unknown_linux_gnueabi="$CC"
export AR_armv7_unknown_linux_gnueabi="$WEBOS_NDK/bin/arm-webos-linux-gnueabi-ar"
export CFLAGS_armv7_unknown_linux_gnueabi="${CFLAGS_armv7_unknown_linux_gnueabi:-} --sysroot=$SYSROOT"