diff --git a/.gitignore b/.gitignore index 78c6538..32092cc 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,6 @@ test-retry test-playback test-handover + +test-target +tests/receiver-proxy.log diff --git a/HyperDRM.manifest.json b/HyperDRM.manifest.json index b86b875..ecc9812 100644 --- a/HyperDRM.manifest.json +++ b/HyperDRM.manifest.json @@ -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 } diff --git a/HyperDRM_0.7.2_arm.ipk b/HyperDRM_0.8.0_arm.ipk similarity index 89% rename from HyperDRM_0.7.2_arm.ipk rename to HyperDRM_0.8.0_arm.ipk index 2758c25..c92b0d9 100644 Binary files a/HyperDRM_0.7.2_arm.ipk and b/HyperDRM_0.8.0_arm.ipk differ diff --git a/README.md b/README.md index cc4c8a0..567de77 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/app.js b/app/app.js index f1d7852..649e235 100644 --- a/app/app.js +++ b/app/app.js @@ -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); diff --git a/app/appinfo.json b/app/appinfo.json index cbf8a19..14dc44b 100644 --- a/app/appinfo.json +++ b/app/appinfo.json @@ -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"} diff --git a/app/index.html b/app/index.html index 36d2543..a43b398 100644 --- a/app/index.html +++ b/app/index.html @@ -1,9 +1,16 @@ HyperDRM - -

HyperDRM

Ambient lighting for webOS and Hyperion.

Checking service …

+ +

HyperDRM

Ambient lighting for webOS, Hyperion and HyperHDR.

Checking service …

Use the arrow buttons to select a control and OK to activate it.

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.

-

127.0.0.1:19444 · Priority 149 · 10 updates per second

+
Receiver settings + + + + +

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.

+

Auto PicCap requires the default local receiver; custom targets use HyperDRM for all playback. PicCap settings are managed in PicCap.

+

Priority 149 · 10 updates per second

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.

diff --git a/app/remote.js b/app/remote.js index 5a4c93f..1af554f 100644 --- a/app/remote.js +++ b/app/remote.js @@ -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; diff --git a/docs/publishing-review.md b/docs/publishing-review.md index 793d507..dd166bc 100644 --- a/docs/publishing-review.md +++ b/docs/publishing-review.md @@ -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. diff --git a/native/handover.h b/native/handover.h index 23030b4..9fb9304 100644 --- a/native/handover.h +++ b/native/handover.h @@ -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; diff --git a/native/service.c b/native/service.c index 343dd1e..b98f8c8 100644 --- a/native/service.c +++ b/native/service.c @@ -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=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=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 +#include +#include +#include +#include +#include +#include +#include +#include +#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 diff --git a/native/test_handover.c b/native/test_handover.c index 0cb33ea..3fdf377 100644 --- a/native/test_handover.c +++ b/native/test_handover.c @@ -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; diff --git a/native/test_target.c b/native/test_target.c new file mode 100644 index 0000000..8c92e14 --- /dev/null +++ b/native/test_target.c @@ -0,0 +1,23 @@ +#define _DEFAULT_SOURCE +#include +#include +#include +#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=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; +} diff --git a/package/packageinfo.json b/package/packageinfo.json index fca1700..dca5ee4 100644 --- a/package/packageinfo.json +++ b/package/packageinfo.json @@ -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"} diff --git a/service/histogram-service b/service/histogram-service index e147095..5886d5f 100644 Binary files a/service/histogram-service and b/service/histogram-service differ diff --git a/service/services.json b/service/services.json index 35faacb..ce39511 100644 --- a/service/services.json +++ b/service/services.json @@ -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"}]} \ No newline at end of file +{"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"}]} \ No newline at end of file diff --git a/tests/receiver-proxy.py b/tests/receiver-proxy.py new file mode 100644 index 0000000..0d8e66d --- /dev/null +++ b/tests/receiver-proxy.py @@ -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() diff --git a/tests/remote-navigation.test.js b/tests/remote-navigation.test.js index 5dcc278..164ef9a 100644 --- a/tests/remote-navigation.test.js +++ b/tests/remote-navigation.test.js @@ -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');