Support configurable Hyperion and HyperHDR receivers with token login

This commit is contained in:
matze19999 committed 2026-10-06 18:05:33 +02:00
1 parent c3a2ac8002
commit 90c4c88942
19 files changed
+264 -34

No files matched your search

+3
View File
@@ -28,3 +28,6 @@ test-retry
test-playback
test-handover
test-target
tests/receiver-proxy.log
+5 -5
View File
@@ -1,15 +1,15 @@
{
"id": "org.webosbrew.histogramhyperion",
"version": "0.7.2",
"version": "0.8.0",
"type": "web",
"title": "HyperDRM",
"appDescription": "Ambient lighting for webOS and Hyperion",
"appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR",
"iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png",
"sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM",
"rootRequired": true,
"ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.7.2_arm.ipk",
"ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.8.0_arm.ipk",
"ipkHash": {
"sha256": "cc73ed6b93caf86b32df52a87b0160a8cdec34b79802fcc71fc1f0f04d5fcf74"
"sha256": "7cbb9c3a6e9dc5fe0c421cff6082807231c06a697420767761735f405855ae4a"
},
"ipkSize": 1169234
"ipkSize": 1173194
}
Binary file not shown.
+17 -5
View File
@@ -1,6 +1,6 @@
# HyperDRM
Ambient Light for webOS and Hyperion. An own webOS app and native ARM service that reads LG color histograms and sends spatial LED colors to a local Hyperion instance.
Ambient Light for webOS, Hyperion and HyperHDR. An own webOS app and native ARM service that reads LG color histograms and sends spatial LED colors to a configurable Hyperion or HyperHDR receiver.
![HyperDRM logo](app/icon.png)
@@ -11,11 +11,11 @@ Ambient Light for webOS and Hyperion. An own webOS app and native ARM service th
- Automatic detection of top/bottom black bars on the calibrated LG G4.
- Original saturation and brightness (both gains 1.0).
- Start, stop, status and persistent boot autostart switch in the TV app.
- Local Hyperion JSON connection on `127.0.0.1:19444`, priority 149, up to 10 updates per second.
- Configurable IPv4/IPv6 receiver and JSON TCP port, optional token authentication, priority 149, up to 10 updates per second.
## Requirements and limits
Tested on a rooted LG G4 with webOS 24, Homebrew Channel and Hyperion 2.0.16 installed on the TV. The native service requires root elevation and the TV's existing `libvtcapture`, Luna, GLib and JSON-C libraries. Other TV models or firmware versions have not been validated.
Tested on a rooted LG G4 with webOS 24, Homebrew Channel and Hyperion 2.0.16 and HyperHDR 22.0.0 tested on the TV. The native service requires root elevation and the TV's existing `libvtcapture`, Luna, GLib and JSON-C libraries. Other TV models or firmware versions have not been validated.
DRM playback color output was confirmed on the test TV. Support depends on LG's capture implementation; this does not guarantee support on other devices or apps. HyperDRM transmits colors, not captured video frames. The video preview in Hyperion therefore does not show the TV picture.
@@ -25,7 +25,7 @@ Only the own service is modified. Optional Auto PicCap can start and stop the in
## Installation
The current package is [HyperDRM_0.7.2_arm.ipk](HyperDRM_0.7.2_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow.
The current package is [HyperDRM_0.8.0_arm.ipk](HyperDRM_0.8.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow.
Elevate the installed own service through Homebrew Channel:
@@ -35,7 +35,7 @@ luna-send -n 1 luna://org.webosbrew.hbchannel.service/elevateService '{"id":"org
Perform elevation before first opening the app or calling its service. If the service was already started without root, elevation updates its launch permissions but does not restart the existing process: terminate only the own `histogram-service` process (verify its executable path first), then reopen the app or call start again. Closing the UI alone does not terminate the native service.
Open **HyperDRM** on the TV and select **Start**. Use the remote arrow buttons to move between controls and OK to activate the selected control. Magic Remote pointer input is also supported. Hyperion's JSON server must be enabled on port 19444. The existing Hyperion LED device/output configuration remains responsible for controlling the hardware.
Open **HyperDRM** on the TV and select **Start**. Use the remote arrow buttons to move between controls and OK to activate the selected control. Magic Remote pointer input is also supported. The receiver's JSON TCP server must be enabled at the IP/port saved in Receiver settings (default 127.0.0.1:19444). The existing Hyperion LED device/output configuration remains responsible for controlling the hardware.
The internal app/service IDs remain `org.webosbrew.histogramhyperion` and `org.webosbrew.histogramhyperion.service` so existing installations and the autostart hook remain compatible. Hyperion displays the source as **HyperDRM**. After changing the LED layout, restart the service.
@@ -99,3 +99,15 @@ The app displays the selected source and PicCap's Hyperion connection status. Hi
## Capture interruption recovery
LG may revoke histogram capture during playback transitions. From 0.7.1, HyperDRM releases capture while leaving its last color update to expire normally after 1.5 seconds, then retries initialization after 200 ms and up to two additional 400 ms intervals. Successful recovery clears this rapid retry budget. Persistent failure returns to the existing five-second retry interval; manual Stop, PicCap handover and shutdown still explicitly clear the own color source. Status exposes `captureRecoveries` and `lastRecoveryMs` for diagnosis.
## Hyperion and HyperHDR receiver settings
Choose **Receiver: Hyperion/HyperHDR**, enter an IPv4 or IPv6 address and the JSON TCP port, then select **Save receiver**. These receivers use the same color, clear and serverinfo commands. Use the JSON port (usually 19444), not the FlatBuffer port (usually 19400) or web interface port. Numeric IP addresses are supported; hostnames and scoped IPv6 addresses are not currently accepted. Layout and LED count come from the selected receiver, including remote receivers.
Settings are saved in `target.json` inside the own service container and loaded after service restart/boot. Saving valid settings switches a running session to the new receiver and clears the own priority at the old receiver. Invalid settings leave the current target intact. Saving while manually stopped does not start a session. Uninstalling removes the saved settings with the container.
For a receiver requiring authentication, enter its API token. The token file is mode 0600; status only reports whether a token is saved. Leaving the input blank retains it; **Remove saved token** deletes it when saving. A token login happens before requesting the layout or initializing LG capture. JSON TCP traffic is unencrypted, so use a trusted network. Receiver authorization rules are unchanged.
Auto PicCap remains available for the default local receiver at 127.0.0.1/::1:19444. For custom targets, automatic PicCap capture is paused and HyperDRM supplies colors for all playback; the PicCap preference remains saved. PicCap has a separate FlatBuffer endpoint managed by its own app, which HyperDRM does not rewrite.
`native/test_target.c` verifies validation, token retention/removal, private persistence and TCP connection/refusal. `tests/receiver-proxy.py` is a test-only relay with synthetic token login and fragmented replies. Local HyperHDR 22.0.0 and a remote relay using a different IP/port were tested on the G4; the relay forwarded real color updates to HyperHDR without changing its configuration.
+20 -3
View File
@@ -1,5 +1,6 @@
'use strict';
var pending=[];
var targetDirty=false,receiver='Hyperion';
function call(method,done,data){
if(typeof PalmServiceBridge==='undefined'){done({returnValue:false,errorText:'This app requires an LG TV with the native service installed.'});return;}
var bridge=new PalmServiceBridge(),timer,finished=false;
@@ -9,6 +10,12 @@ function call(method,done,data){
bridge.call('luna://org.webosbrew.histogramhyperion.service/'+method,JSON.stringify(data||{}));
}
function display(result){
if(result.host && !targetDirty){
document.getElementById('host').value=result.host;document.getElementById('port').value=result.port;receiver=result.receiver;
document.getElementById('receiver').textContent='Receiver: '+receiver;
}
document.getElementById('token').placeholder=result.tokenSet?'Saved token (leave blank to keep)':'No token saved';
if(result.endpoint)document.getElementById('endpoint').textContent=result.receiver+' · '+result.endpoint+' · Priority 149 · 10 updates per second';
var handover=document.getElementById('handover');handover.disabled=!result.piccapInstalled;
handover.setAttribute('aria-checked',String(!!result.piccapHandover));
handover.textContent=result.piccapInstalled?'Auto PicCap: '+(result.piccapHandover?'On':'Off'):'Auto PicCap: Not installed';
@@ -16,15 +23,16 @@ function display(result){
document.getElementById('error').textContent=result.errorText||result.error||'';
document.getElementById('status').textContent=result.returnValue===false?'Service unavailable':(result.running?'Running':'Stopped')+' · '+(result.frames||0)+' acknowledged color updates';
if(result.activeSource==='PicCap'){
document.getElementById('status').textContent='PicCap · '+(result.running?'Running':'Switching capture source')+' · '+(result.piccapConnected?'Hyperion connected':'Waiting for Hyperion');
document.getElementById('status').textContent='PicCap · '+(result.running?'Running':'Switching capture source')+' · '+(result.piccapConnected?'Receiver connected':'Waiting for receiver');
document.getElementById('regions').textContent='';return;
}
if(result.piccapHandover)document.getElementById('status').textContent+=' · HyperDRM · '+result.playbackProtection;
if(result.piccapHandover && !result.piccapTargetCompatible)document.getElementById('status').textContent+=' · Auto PicCap paused for custom target';
if(result.colorValid)document.getElementById('status').textContent+=result.spatialOutput?' · '+result.ledCount+' LEDs from 16 color regions':' · Output RGB: '+result.outputColor.join(', ');
else if(result.returnValue!==false)document.getElementById('status').textContent+=' · No current color output';
if(result.blackBarDetection && result.running)document.getElementById('status').textContent+=result.letterboxDetected?' · Top/bottom black bars detected':' · Full-screen capture';
if(result.spatialInterpolation)document.getElementById('status').textContent+=' · Smooth color transitions';
if(result.retryScheduled)document.getElementById('status').textContent+=' · Retrying startup every 5 seconds';
if(result.retryScheduled)document.getElementById('status').textContent+=' · Retrying receiver or capture startup';
if(result.retryPaused)document.getElementById('status').textContent+=' · Automatic retry paused until Start';
var box=document.getElementById('regions');box.textContent='';
(result.regions||[]).forEach(function(rgb,index){var e=document.createElement('div');e.className='swatch';e.style.backgroundColor='rgb('+rgb.join(',')+')';e.title='Histogram '+index;box.appendChild(e);});
@@ -38,4 +46,13 @@ document.getElementById('start').focus();
document.getElementById('autostart').onclick=function(){var button=this;var enabled=button.getAttribute('aria-checked')!=='true';button.disabled=true;call('autostart',function(r){button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:enabled});};
document.getElementById('handover').onclick=function(){var button=this;button.disabled=true;call('handover',function(r){button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:button.getAttribute('aria-checked')!=='true'});};
setupRemoteNavigation(['start','stop','refresh','autostart','handover'].map(function(id){return document.getElementById(id);}),document,window);
['host','port','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});});
document.getElementById('receiver').onclick=function(){receiver=receiver==='Hyperion'?'HyperHDR':'Hyperion';this.textContent='Receiver: '+receiver;targetDirty=true;};
document.getElementById('saveTarget').onclick=function(){
var port=Number(document.getElementById('port').value),button=this;
if(!Number.isInteger(port)||port<1||port>65535){document.getElementById('error').textContent='JSON port must be an integer from 1 to 65535.';return;}
var settings={host:document.getElementById('host').value.trim(),port:port,receiver:receiver,clearToken:document.getElementById('clearToken').checked};
var token=document.getElementById('token').value.trim();if(token)settings.token=token;
button.disabled=true;call('settings',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}targetDirty=false;document.getElementById('token').value='';document.getElementById('clearToken').checked=false;call('status',display);},settings);
};
setupRemoteNavigation(['start','stop','refresh','autostart','handover','receiver','host','port','token','clearToken','saveTarget'].map(function(id){return document.getElementById(id);}),document,window);
+1 -1
View File
@@ -1 +1 @@
{"id": "org.webosbrew.histogramhyperion", "version": "0.7.2", "vendor": "Local development", "type": "web", "main": "index.html", "title": "HyperDRM", "icon": "icon.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, "largeIcon": "large-icon.png", "appDescription": "Ambient lighting for webOS and Hyperion", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
{"id": "org.webosbrew.histogramhyperion", "version": "0.8.0", "vendor": "Local development", "type": "web", "main": "index.html", "title": "HyperDRM", "icon": "icon.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, "largeIcon": "large-icon.png", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
+10 -3
View File
@@ -1,9 +1,16 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>HyperDRM</title>
<style>body{background:#101827;color:#f1f5ff;font:28px sans-serif;margin:70px;max-width:1400px}h1{font-size:52px;display:flex;align-items:center;gap:24px}h1 img{border-radius:20px}button{font:inherit;padding:20px 32px;margin:12px 12px 12px 0;background:#223654;color:white;border:2px solid #739be0;border-radius:12px}button:focus{outline:5px solid #66ddff}#regions{display:flex;flex-wrap:wrap;gap:8px}.swatch{width:64px;height:64px;border:1px solid #abc}p{line-height:1.5}#error{color:#ffbc99}small{color:#c0cbe0}</style>
<h1><img src="logo.png" alt="" width="104" height="104">HyperDRM</h1><p>Ambient lighting for webOS and Hyperion.</p><p id="status">Checking service &hellip;</p><p id="error" role="alert"></p>
<style>body{background:#101827;color:#f1f5ff;font:28px sans-serif;margin:70px;max-width:1400px}h1{font-size:52px;display:flex;align-items:center;gap:24px}h1 img{border-radius:20px}button{font:inherit;padding:20px 32px;margin:12px 12px 12px 0;background:#223654;color:white;border:2px solid #739be0;border-radius:12px}button:focus{outline:5px solid #66ddff}#regions{display:flex;flex-wrap:wrap;gap:8px}.swatch{width:64px;height:64px;border:1px solid #abc}p{line-height:1.5}#error{color:#ffbc99}small{color:#c0cbe0}fieldset{border:2px solid #739be0;border-radius:12px;padding:16px}label{display:inline-flex;flex-direction:column;font-size:24px;margin:8px 20px 8px 0}input{font:inherit;padding:12px;background:#223654;color:white;border:2px solid #739be0;border-radius:8px}input:focus{outline:5px solid #66ddff}#host,#token{width:340px}#port{width:140px}#clearToken{width:28px;height:28px}</style>
<h1><img src="logo.png" alt="" width="104" height="104">HyperDRM</h1><p>Ambient lighting for webOS, Hyperion and HyperHDR.</p><p id="status">Checking service &hellip;</p><p id="error" role="alert"></p>
<div role="group" aria-label="Service controls"><button id="start">Start</button><button id="stop">Stop</button><button id="refresh">Refresh status</button>
<button id="autostart" role="switch" aria-checked="false">Autostart: Off</button>
<button id="handover" role="switch" aria-checked="false" disabled>Auto PicCap: Off</button></div><p><small>Use the arrow buttons to select a control and OK to activate it.</small></p><p><small>Autostart starts the service when the TV boots. Stop ends the current session. Auto PicCap uses installed PicCap for non-secure video and HyperDRM for protected or unknown playback. Keep PicCap's own autostart off.</small></p>
<p><small>127.0.0.1:19444 &middot; Priority 149 &middot; 10 updates per second</small></p>
<fieldset><legend>Receiver settings</legend><button id="receiver" type="button">Receiver: Hyperion</button>
<label>IP address <input id="host" type="text" maxlength="45" value="127.0.0.1" spellcheck="false"></label>
<label>JSON port <input id="port" type="number" min="1" max="65535" value="19444"></label>
<label>API token (optional) <input id="token" type="password" maxlength="256" autocomplete="off"></label>
<label><input id="clearToken" type="checkbox"> Remove saved token</label><button id="saveTarget" type="button">Save receiver</button>
<p><small>Use the receiver's JSON TCP port, usually 19444. IPv4 and IPv6 addresses are supported. Leave the token blank to keep a saved token. Save applies the new target to a running session. Use Up/Down to move between fields; Left/Right edit text.</small></p>
<p id="piccapTargetHint"><small>Auto PicCap requires the default local receiver; custom targets use HyperDRM for all playback. PicCap settings are managed in PicCap.</small></p></fieldset>
<p><small id="endpoint">Priority 149 &middot; 10 updates per second</small></p>
<div id="regions" aria-label="Histogram colors"></div><p><small>The Hyperion layout determines the number and positions of LEDs. Colors come from 16 LG histogram regions with interpolated edge colors. Restart the service after changing the LED layout.</small></p>
<script src="remote.js"></script><script src="app.js"></script></html>
+2
View File
@@ -11,6 +11,8 @@ function setupRemoteNavigation(buttons, doc, win) {
event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0;
var enter=event.key==='Enter'||code===13;
if(!direction&&!enter)return;
var editing=doc.activeElement && doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox';
if(editing && (enter||code===37||code===39||event.key==='ArrowLeft'||event.key==='ArrowRight'))return;
event.preventDefault();
var enabled=buttons.filter(function(button){return !button.disabled;});
if(!enabled.length)return;
+8
View File
@@ -63,3 +63,11 @@ A live 0.7.0 observation reproduced the reported blackout while Auto PicCap was
Version 0.7.1 preserves the last update's existing 1.5-second expiry during a revocation recovery, instead of explicitly clearing it. Initialization retries begin after 200 ms with two additional 400 ms retry intervals before returning to normal five-second pacing. This does not extend stale colors indefinitely. Explicit capture stops and PicCap handovers retain their clear behavior. Status records recovery count and elapsed initialization time.
ARM retry and handover regression tests passed on the G4, as did remote navigation tests. Version 0.7.1 was installed and elevated on the TV. A later actual LG revocation recovered in 762 ms, including one refused initialization attempt before success. A 55-second Hyperion observation contained 125 color samples with no all-black output. These measurements do not guarantee that prolonged LG capture unavailability or actual black video scenes cannot darken the LEDs.
## Configurable Hyperion/HyperHDR receiver (0.8.0)
The app can save receiver type, numeric IPv4/IPv6 address, JSON TCP port and an optional API token. Targets and tokens are validated before replacing the configuration or interrupting capture. The own container stores `target.json` atomically with mode 0600; status omits the token value. TCP connection attempts are bounded to one second. Authentication and remote layout validation happen before acquiring LG capture. The receiver's own authorization and LED-device settings are not changed. Auto PicCap remains limited to the default local target; custom targets use histogram capture rather than sending PicCap data to an independently configured receiver.
Native target tests passed on the G4, including invalid values, token retention/removal, private persistence and TCP success/refusal. Handover and input-field remote-navigation regression tests passed. HyperDRM 0.8.0 was installed and tested against the user's HyperHDR 22.0.0 on localhost:19444: acknowledged spatial color updates for its 120-LED layout. A temporary test relay on another LAN host at port 19445 exercised synthetic token login and fragmented replies, forwarding actual layout/color/clear commands to HyperHDR. Omitting the token preserved it; an invalid target was rejected without changing the active target. A verified own-service process restart loaded the saved HyperHDR target. The test relay was stopped and the target restored to localhost:19444 with no saved token. Network tests used IPv4; IPv6 parsing is covered by unit tests, but an IPv6 receiver was not available for an end-to-end test. Cold boot remains untested.
The API compatibility was checked against official HyperHDR `sources/api/HyperAPI.cpp` and `sources/base/HyperHdrInstance.cpp`: serverinfo includes the LED layout, color accepts RGB arrays, and token authorization precedes API commands.
+1 -1
View File
@@ -80,7 +80,7 @@ static bool handover_tick(void){
media_deadline=now+3000;
error_free(&e);next_media_probe=now+1000;
}
bool clear=handover_enabled && piccap_installed() && !retries.paused && !retries.blocked &&
bool clear=handover_enabled && target_piccap_local(&target) && piccap_installed() && !retries.paused && !retries.blocked &&
protection==PLAYBACK_CLEAR && clear_samples>=2 && now-protection_at<3000 && now>=handover_cooldown;
if(capture_source==SOURCE_HISTOGRAM && clear){
stop_capture();retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING;
+35 -14
View File
@@ -24,6 +24,7 @@
#include "layout.h"
#include "autostart.h"
#include "retry.h"
#include "target.h"
#define SERVICE "org.webosbrew.histogramhyperion.service"
#define PRIORITY 149
typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError;
@@ -113,6 +114,18 @@ static bool read_layout(void){
}
bool ok=complete && layout_from_json(data,&led_blends,&led_count);free(data);interpolation_enabled=ok;return ok;
}
static bool target_login(void){
if(!target.token[0])return true;
char request[384],answer[4096];size_t used=0;
snprintf(request,sizeof(request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",target.token);
if(send_line(request)!=0)return false;
while(used+1<sizeof(answer)){
ssize_t n=recv(output,answer+used,sizeof(answer)-used-1,0);if(n<=0)return false;
used+=(size_t)n;answer[used]=0;if(strchr(answer,'\n'))break;
}
PlaybackJson json={0};if(!playback_json_init(&json))return false;
void *root=json.parse(answer);bool ok=playback_true(&json,root,"success");if(root)json.put(root);dlclose(json.lib);return ok;
}
static void release_capture(bool clear_output){
free(led_blends);led_blends=NULL;led_count=0;interpolation_enabled=false;
color_valid=false;memset(rgb,0,sizeof(rgb));memset(sent_rgb,0,sizeof(sent_rgb));
@@ -129,17 +142,15 @@ static bool start_failure(void *h,void *m,const char *message){
}
static bool start(void *h,void *m,void *ctx){
(void)ctx;const char *p=m?payload(m):NULL;
if(p && strcmp(p,"{}")!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Start expects an empty object; endpoint is localhost:19444\"}");
if(p && strcmp(p,"{}")!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Start expects an empty object; use settings to configure the receiver\"}");
if(m)retry_reset(&retries);
if(piccap_owned)return respond(h,m,"{\"returnValue\":true,\"running\":true,\"activeSource\":\"PicCap\"}");
if(capture)return respond(h,m,"{\"returnValue\":true,\"running\":true}");
frames=0;revoked=0;last_error[0]=0;lg_diagnostic[0]=0;
output=socket(AF_INET,SOCK_STREAM,0);
struct timeval timeout={1,0};
if(output>=0){setsockopt(output,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));setsockopt(output,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));}
struct sockaddr_in a={.sin_family=AF_INET,.sin_port=htons(19444),.sin_addr={htonl(INADDR_LOOPBACK)}};
if(output<0||connect(output,(void*)&a,sizeof(a))!=0){stop_capture();set_error("Hyperion JSON server is unavailable on localhost:19444");return start_failure(h,m,"Cannot connect to local Hyperion");}
if(!read_layout()){stop_capture();set_error("Cannot read a valid LED layout from Hyperion");return start_failure(h,m,"Cannot read Hyperion LED layout");}
output=target_connect(&target);
if(output<0){stop_capture();set_error("Cannot connect to configured receiver IP and JSON port");return start_failure(h,m,"Configured receiver is unavailable");}
if(!target_login()){stop_capture();set_error("Receiver token login failed; check the saved API token");return start_failure(h,m,"Receiver authentication failed");}
if(!read_layout()){stop_capture();set_error("Cannot read receiver LED layout; check JSON port and API authorization");return start_failure(h,m,"Receiver layout unavailable; check API authorization");}
if(!enter_developer_mode()){stop_capture();set_error("Developer initialization needs root. Elevate this own service through Homebrew Channel.");return start_failure(h,m,"Root access is required for temporary LG developer initialization");}
capture=vt_init(policy);
if(!leave_developer_mode()){stop_capture();retries.blocked=true;set_error("Developer marker cleanup failed; manual intervention required");return start_failure(h,m,"Developer marker cleanup failed");}
@@ -172,10 +183,20 @@ static bool handover(void *h,void *m,void *ctx){
handover_enabled=enabled;protection=PLAYBACK_UNKNOWN;clear_samples=0;next_media_probe=0;
return respond(h,m,"{\"returnValue\":true}");
}
static bool settings(void *h,void *m,void *ctx){
(void)ctx;TargetConfig next=target;
if(!playback_json.lib || !target_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Enter a valid IPv4/IPv6 address, integer port (1-65535), receiver and optional API token\"}");
if(!target_save(TARGET_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}");
bool active=capture || piccap_owned || retries.due;
stop_capture();target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;
if(active && !retries.paused && !retries.blocked)retries.due=milliseconds();
return respond(h,m,"{\"returnValue\":true}");
}
static bool status(void *h,void *m,void *ctx){
(void)ctx;char json[4096],safe[512];size_t n=0;
(void)ctx;char json[4096],safe[512],endpoint[80];size_t n=0;
snprintf(endpoint,sizeof(endpoint),strchr(target.host,':')?"[%s]:%u":"%s:%u",target.host,target.port);
for(size_t i=0;last_error[i] && n+2<sizeof(safe);i++){char c=last_error[i];if(c=='"'||c=='\\')safe[n++]='\\';if((unsigned char)c>=32)safe[n++]=c;}safe[n]=0;
int used=snprintf(json,sizeof(json),"{\"returnValue\":true,\"version\":\"0.7.2\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"127.0.0.1:19444\",\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe);
int used=snprintf(json,sizeof(json),"{\"returnValue\":true,\"version\":\"0.8.0\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s,\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,endpoint,target.host,target.port,target.receiver,target.token[0]?"true":"false",target_piccap_local(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe);
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s[%u,%u,%u]",i?",":"",rgb[i][0],rgb[i][1],rgb[i][2]);
used+=snprintf(json+used,sizeof(json)-used,"],\"ledCount\":%u,\"spatialOutput\":%s,\"blackPercent\":[",led_count,led_count?"true":"false");
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s%u",i?",":"",black_percent[i]);
@@ -235,11 +256,11 @@ static int tick(void *unused){
}
snprintf(line+written,capacity-written,"]}\n");
int failed=send_line(line);free(line);
if(failed){set_error("Hyperion connection lost");stop_capture();return 1;}
if(failed){set_error("Receiver connection lost");stop_capture();return 1;}
char answer[4096];size_t used=0;bool complete=false;
while(used+1<sizeof(answer)){ssize_t len=recv(output,answer+used,sizeof(answer)-used-1,0);if(len<=0)break;used+=(size_t)len;answer[used]=0;if(strchr(answer,'\n')){complete=true;break;}}
if(!complete){set_error("Hyperion did not acknowledge color data");stop_capture();return 1;}
if(!strstr(answer,"\"success\":true")&&!strstr(answer,"\"success\": true")){set_error("Hyperion rejected the color command");stop_capture();return 1;}
if(!complete){set_error("Receiver did not acknowledge color data");stop_capture();return 1;}
if(!strstr(answer,"\"success\":true")&&!strstr(answer,"\"success\": true")){set_error("Receiver rejected the color command");stop_capture();return 1;}
color_valid=true;frames++;return 1;
}
static void *symbol(void *lib,const char *name){void *s=dlsym(lib,name);if(!s){fprintf(stderr,"Missing symbol %s\n",name);exit(2);}return s;}
@@ -251,7 +272,7 @@ int main(void){
vt_init=symbol(vt,"vtCapture_initHistogram");vt_preset=symbol(vt,"vtCapture_setPresetHistogram");vt_get=symbol(vt,"vtCapture_getHistogram");vt_finish=symbol(vt,"vtCapture_finalizeHistogram");
call_once=symbol(ls,"LSCallOneReply");
cancel_call=symbol(ls,"LSCallCancel");
if(playback_json_init(&playback_json))handover_load();
if(playback_json_init(&playback_json)){handover_load();target_load(&playback_json,TARGET_PREF,&target);}
reply=symbol(ls,"LSMessageReply");payload=symbol(ls,"LSMessageGetPayload");error_init=symbol(ls,"LSErrorInit");error_free=symbol(ls,"LSErrorFree");
bool (*reg)(const char*,void**,LSError*)=symbol(ls,"LSRegister");
bool (*category)(void*,const char*,Method*,void*,void*,LSError*)=symbol(ls,"LSRegisterCategory");
@@ -262,7 +283,7 @@ int main(void){
unsigned (*timer)(unsigned,int(*)(void*),void*)=symbol(glib,"g_timeout_add");
void (*loop_unref)(void*)=symbol(glib,"g_main_loop_unref");
void *handle=NULL,*loop=loop_new(NULL,0);LSError e;error_init(&e);
Method methods[]={{"start",start,0},{"stop",stop,0},{"status",status,0},{"autostart",autostart,0},{"handover",handover,0},{NULL,NULL,0}};
Method methods[]={{"start",start,0},{"stop",stop,0},{"status",status,0},{"autostart",autostart,0},{"handover",handover,0},{"settings",settings,0},{NULL,NULL,0}};
if(!reg(SERVICE,&handle,&e)||!category(handle,"/",methods,NULL,NULL,&e)||!attach(handle,loop,&e)){fprintf(stderr,"Luna registration failed: %s\n",e.message?e.message:"unknown");error_free(&e);return 3;}
service_handle=handle;
if(!boot_enabled())retry_pause(&retries);
+76
View File
@@ -0,0 +1,76 @@
#ifndef HYPERDRM_TARGET_H
#define HYPERDRM_TARGET_H
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <poll.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/socket.h>
#include <sys/time.h>
#include <unistd.h>
#include "playback.h"
#define TARGET_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/target.json"
typedef struct {char host[46],receiver[16],token[257];unsigned port;} TargetConfig;
static TargetConfig target={.host="127.0.0.1",.receiver="Hyperion",.port=19444};
static inline bool target_piccap_local(const TargetConfig *t){return t->port==19444 && (!strcmp(t->host,"127.0.0.1") || !strcmp(t->host,"::1"));}
static bool target_valid(const TargetConfig *t){
unsigned char address[16];
if(inet_pton(AF_INET,t->host,address)!=1 && inet_pton(AF_INET6,t->host,address)!=1)return false;
if(!t->port || t->port>65535 || (strcmp(t->receiver,"Hyperion") && strcmp(t->receiver,"HyperHDR")))return false;
for(const unsigned char *p=(const unsigned char*)t->token;*p;p++)
if(!((*p>='a'&&*p<='z')||(*p>='A'&&*p<='Z')||(*p>='0'&&*p<='9')||*p=='-'||*p=='_'||*p=='.'))return false;
return true;
}
static bool target_parse(PlaybackJson *j,const char *text,TargetConfig *out){
void *root=j->parse(text);if(!root)return false;
TargetConfig next=*out;bool valid=j->type(root)==4;
const char *names[]={"host","receiver","token"};char *fields[]={next.host,next.receiver,next.token};
size_t sizes[]={sizeof(next.host),sizeof(next.receiver),sizeof(next.token)};
for(unsigned i=0;valid && i<3;i++){
void *v=playback_field(j,root,names[i]);
if(!v){if(i<2)valid=false;continue;}
if(j->type(v)!=6){valid=false;break;}
const char *s=j->string(v);if(strlen(s)>=sizes[i]){valid=false;break;}
strcpy(fields[i],s);
}
void *port=playback_field(j,root,"port");
if(!port || j->type(port)!=3)valid=false;
else {char *end=NULL;errno=0;long n=strtol(j->string(port),&end,10);if(errno || !end || *end || n<1 || n>65535)valid=false;else next.port=(unsigned)n;}
void *clear=playback_field(j,root,"clearToken");
if(clear){if(j->type(clear)!=1)valid=false;else if(j->boolean(clear))next.token[0]=0;}
valid=valid && target_valid(&next);j->put(root);if(valid)*out=next;return valid;
}
static bool target_save(const char *path,const TargetConfig *t){
if(!target_valid(t))return false;
char tmp[512],data[512];if(snprintf(tmp,sizeof(tmp),"%s.tmp",path)>=(int)sizeof(tmp))return false;
int n=snprintf(data,sizeof(data),"{\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"token\":\"%s\"}\n",t->host,t->port,t->receiver,t->token);
int fd=open(tmp,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);if(fd<0)return false;
bool ok=write(fd,data,n)==n && fsync(fd)==0;if(close(fd)!=0)ok=false;
if(ok)ok=rename(tmp,path)==0;
if(!ok)unlink(tmp);
return ok;
}
static bool target_load(PlaybackJson *j,const char *path,TargetConfig *t){
int fd=open(path,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return false;
char data[1024];ssize_t n=read(fd,data,sizeof(data)-1);close(fd);if(n<=0 || n==(ssize_t)sizeof(data)-1)return false;
data[n]=0;return target_parse(j,data,t);
}
static int target_connect(const TargetConfig *t){
if(!target_valid(t))return -1;
struct sockaddr_storage storage={0};socklen_t length;int family;
struct sockaddr_in *v4=(void*)&storage;struct sockaddr_in6 *v6=(void*)&storage;
if(inet_pton(AF_INET,t->host,&v4->sin_addr)==1){family=AF_INET;v4->sin_family=family;v4->sin_port=htons(t->port);length=sizeof(*v4);}
else {family=AF_INET6;v6->sin6_family=family;v6->sin6_port=htons(t->port);length=sizeof(*v6);}
int fd=socket(family,SOCK_STREAM,0);if(fd<0)return -1;
int flags=fcntl(fd,F_GETFL,0);if(flags<0 || fcntl(fd,F_SETFL,flags|O_NONBLOCK)<0){close(fd);return -1;}
if(connect(fd,(void*)&storage,length)!=0){
if(errno!=EINPROGRESS){close(fd);return -1;}
struct pollfd p={.fd=fd,.events=POLLOUT};int error=0;socklen_t size=sizeof(error);
if(poll(&p,1,1000)<=0 || getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){close(fd);return -1;}
}
if(fcntl(fd,F_SETFL,flags)<0){close(fd);return -1;}
struct timeval timeout={1,0};setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));
return fd;
}
#endif
+2
View File
@@ -17,6 +17,8 @@ static const char *payload(void *m){return m;}
static void error_init(LSError *e){(void)e;}
static void error_free(LSError *e){(void)e;}
#define PICCAP_BINARY "/bin/sh"
static int target;
static bool target_piccap_local(const int *t){(void)t;return true;}
#include "handover.h"
static bool mock_call(void *h,const char *uri,const char *data,Callback cb,void *ctx,unsigned long *token,LSError *e){
(void)h;(void)data;(void)cb;(void)ctx;(void)e;*token=++called;snprintf(last_uri,sizeof(last_uri),"%s",uri);return true;
+23
View File
@@ -0,0 +1,23 @@
#define _DEFAULT_SOURCE
#include <assert.h>
#include <sys/stat.h>
#include <string.h>
#include "target.h"
int main(void){
PlaybackJson j={0};assert(playback_json_init(&j));TargetConfig t=target;
assert(target_valid(&t));assert(target_piccap_local(&t));
assert(target_parse(&j,"{\"host\":\"10.1.0.91\",\"port\":19445,\"receiver\":\"HyperHDR\",\"token\":\"test-token\"}",&t));
assert(t.port==19445 && !strcmp(t.receiver,"HyperHDR") && !strcmp(t.token,"test-token"));assert(!target_piccap_local(&t));
assert(target_parse(&j,"{\"host\":\"::1\",\"port\":65535,\"receiver\":\"Hyperion\"}",&t));assert(!strcmp(t.token,"test-token"));
const char *bad[]={"{}","[]","{\"host\":\"localhost\",\"port\":19444,\"receiver\":\"HyperHDR\"}","{\"host\":\"127.0.0.1\",\"port\":0,\"receiver\":\"HyperHDR\"}","{\"host\":\"127.0.0.1\",\"port\":65536,\"receiver\":\"HyperHDR\"}","{\"host\":\"127.0.0.1\",\"port\":19444.5,\"receiver\":\"HyperHDR\"}","{\"host\":\"127.0.0.1\",\"port\":\"19444\",\"receiver\":\"HyperHDR\"}","{\"host\":\"127.0.0.1\",\"port\":19444,\"receiver\":\"Other\"}","{\"host\":\"127.0.0.1\",\"port\":19444,\"receiver\":\"HyperHDR\",\"token\":\"bad\\\"token\"}"};
for(unsigned i=0;i<sizeof(bad)/sizeof(*bad);i++){TargetConfig before=t;assert(!target_parse(&j,bad[i],&t));assert(!memcmp(&t,&before,sizeof(t)));}
const char *file="/tmp/hyperdrm-target-test.json";assert(access(file,F_OK)!=0);assert(access("/tmp/hyperdrm-target-test.json.tmp",F_OK)!=0);
assert(target_save(file,&t));struct stat st;assert(stat(file,&st)==0 && (st.st_mode&0777)==0600);
TargetConfig restored=target;assert(target_load(&j,file,&restored));assert(!strcmp(restored.host,"::1") && restored.port==65535 && !strcmp(restored.token,"test-token"));
assert(target_parse(&j,"{\"host\":\"127.0.0.1\",\"port\":19444,\"receiver\":\"HyperHDR\",\"clearToken\":true}",&t));assert(!t.token[0]);assert(target_piccap_local(&t));unlink(file);
int server=socket(AF_INET,SOCK_STREAM,0);assert(server>=0);struct sockaddr_in a={.sin_family=AF_INET,.sin_addr={htonl(INADDR_LOOPBACK)}};
assert(bind(server,(void*)&a,sizeof(a))==0 && listen(server,1)==0);socklen_t length=sizeof(a);assert(getsockname(server,(void*)&a,&length)==0);t.port=ntohs(a.sin_port);
int client=target_connect(&t);assert(client>=0);int accepted=accept(server,NULL,NULL);assert(accepted>=0);close(accepted);close(client);close(server);
assert(target_connect(&t)<0);
puts("PASS: target validation, token retention/removal, private persistence, IPv6 parsing, bounded TCP connect and refusal");return 0;
}
+1 -1
View File
@@ -1 +1 @@
{"id": "org.webosbrew.histogramhyperion", "version": "0.7.2", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
{"id": "org.webosbrew.histogramhyperion", "version": "0.8.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
{"id": "org.webosbrew.histogramhyperion.service", "description": "HyperDRM ambient light bridge to local Hyperion", "engine": "native", "executable": "histogram-service", "services": [{"name": "org.webosbrew.histogramhyperion.service", "description": "HyperDRM"}]}
{"id": "org.webosbrew.histogramhyperion.service", "description": "HyperDRM ambient light bridge to Hyperion or HyperHDR", "engine": "native", "executable": "histogram-service", "services": [{"name": "org.webosbrew.histogramhyperion.service", "description": "HyperDRM"}]}
+55
View File
@@ -0,0 +1,55 @@
"""Test-only TCP relay: synthetic token login and fragmented JSON replies.
Forward subsequent commands to a real receiver without changing its settings.
The fixed token is a test fixture, never a real receiver credential.
"""
import argparse
import json
import socket
import socketserver
import time
parser = argparse.ArgumentParser()
parser.add_argument('--bind', required=True)
parser.add_argument('--port', type=int, default=19445)
parser.add_argument('--upstream', required=True)
parser.add_argument('--upstream-port', type=int, default=19444)
args = parser.parse_args()
class Relay(socketserver.StreamRequestHandler):
def handle(self):
if self.client_address[0] != args.upstream:
return
try:
with socket.create_connection((args.upstream, args.upstream_port), timeout=3) as remote:
incoming = remote.makefile('rb')
authorized = False
while line := self.rfile.readline(2 * 1024 * 1024):
command = json.loads(line)
if command.get('command') == 'authorize':
authorized = command.get('token') == 'fixture-token'
reply = json.dumps({'command': 'authorize-login', 'success': authorized}).encode() + b'\n'
elif not authorized:
reply = b'{"success":false,"error":"No Authorization"}\n'
else:
remote.sendall(line)
reply = incoming.readline(2 * 1024 * 1024)
if not reply:
break
if command.get('command') != 'color':
print(json.dumps({'command': command.get('command'), 'bytes': len(reply)}), flush=True)
self.request.sendall(reply[:10])
time.sleep(0.005)
self.request.sendall(reply[10:])
except (OSError, ValueError):
pass
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
with Server((args.bind, args.port), Relay) as server:
print('Receiver relay ready', flush=True)
server.serve_forever()
+4
View File
@@ -27,5 +27,9 @@ key(40);assert.equal(doc.activeElement.id,'autostart');key(38);assert.equal(doc.
key(13);windowHandlers.blur();key(13);assert.equal(buttons[2].clicks,4);
buttons[4].disabled=false;buttons[3].focus();key(39);assert.equal(doc.activeElement.id,'handover');
key(39);assert.equal(doc.activeElement.id,'start');key(37);assert.equal(doc.activeElement.id,'handover');
buttons[2].tagName='INPUT';buttons[2].type='text';buttons[2].focus();
assert(!key(37));assert(!key(39));assert(!key(13));assert.equal(doc.activeElement.id,'refresh');
key(40);assert.equal(doc.activeElement.id,'autostart');key(38);assert.equal(doc.activeElement.id,'refresh');
buttons[2].tagName='BUTTON';
buttons.forEach(button=>button.disabled=true);assert(key(39));
console.log('PASS: arrow navigation, wraparound, disabled controls, OK once per press, pointer handoff, focus recovery, Back preserved');