2 Commits
41 changed files with 1370 additions and 181 deletions

No files matched your search

+7
View File
@@ -35,3 +35,10 @@ tests/receiver-proxy.log
test-spatial-image test-spatial-image
test-diagnostics test-diagnostics
test-calibration
test-layout_refresh
test-spatial_image
test-continuity
test-receiver_stream
test-source_bridge
+4 -4
View File
@@ -1,15 +1,15 @@
{ {
"id": "org.webosbrew.histogramhyperion", "id": "org.webosbrew.histogramhyperion",
"version": "0.9.0", "version": "0.11.0",
"type": "web", "type": "web",
"title": "HyperDRM", "title": "HyperDRM",
"appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR",
"iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png", "iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png",
"sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM",
"rootRequired": true, "rootRequired": true,
"ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.9.0_arm.ipk", "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.11.0_arm.ipk",
"ipkHash": { "ipkHash": {
"sha256": "f9cb0b4035a0b4f527da814ecb0ea1e0c184fc92154f60899b6425c84e0d2d9b" "sha256": "4e2be7de5f6e06cca4a389d17b18c770aa47ef2d862aac651c16ed02f231d711"
}, },
"ipkSize": 1177866 "ipkSize": 1203394
} }
Binary file not shown.
+69 -4
View File
@@ -7,8 +7,10 @@ Ambient Light for webOS, Hyperion and HyperHDR. An own webOS app and native ARM
## Features ## Features
- Spatial colors from 16 LG histogram regions with interpolated transitions along the screen perimeter. - Spatial colors from 16 LG histogram regions with interpolated transitions along the screen perimeter.
- LED count, ordering and screen rectangles read from Hyperion at each service start. No fixed 200-LED layout; layouts with 50 and 500 LEDs were tested. - LED count, ordering and screen rectangles read from the receiver at startup and refreshed every five seconds without capture restart. No fixed 200-LED layout; layouts with 50 and 500 LEDs were tested.
- Automatic detection of top/bottom black bars on the calibrated LG G4. - Automatic detection of top/bottom black bars on the calibrated LG G4.
- Bounded last-color hold on empty/failed histogram reads, real black detection and 500 ms source transitions using a raw receiver LED subscription.
- Guided full-screen video calibration for additional TV models, with review before saving and a four-color validation video.
- Original saturation and brightness (both gains 1.0). - Original saturation and brightness (both gains 1.0).
- Start, stop, status and persistent boot autostart switch in the TV app. - Start, stop, status and persistent boot autostart switch in the TV app.
- Configurable IPv4/IPv6 receiver and JSON TCP port, optional token authentication, 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.
@@ -19,13 +21,13 @@ Tested on a rooted LG G4 with webOS 24, Homebrew Channel and Hyperion 2.0.16 and
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 does not show the TV picture. HyperHDR may show a generated histogram color plane instead. 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 does not show the TV picture. HyperHDR may show a generated histogram color plane instead.
The 16 histogram samples are independent measurements; interpolation produces smoother transitions without adding independently captured detail. Sample positions are approximate G4 calibration values. Top/bottom bar detection is supported; side bars, arbitrary bar thickness and HDR color calibration remain limited. The 16 histogram samples are independent measurements; interpolation produces smoother transitions without adding independently captured detail. Sample positions come from the selected model profile; the built-in profile contains approximate G4 calibration values. Auto uses uniform average colors on uncalibrated models. Top/bottom bar detection is supported; side bars, arbitrary bar thickness and HDR color calibration remain limited.
Only the own service is modified. Optional Auto PicCap can configure its receiver endpoint and start and stop the installed PicCap service through its existing API. No Hue, LG library, driver or firmware binaries are included. For histogram initialization, the service temporarily creates LG's developer marker and uses a guardian process to remove its own marker afterward; an existing marker is preserved. Only the own service is modified. Optional Auto PicCap can configure its receiver endpoint and start and stop the installed PicCap service through its existing API. No Hue, LG library, driver or firmware binaries are included. For histogram initialization, the service temporarily creates LG's developer marker and uses a guardian process to remove its own marker afterward; an existing marker is preserved.
## Installation ## Installation
The current package is [HyperDRM_0.9.0_arm.ipk](HyperDRM_0.9.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow. The current package is [HyperDRM_0.11.0_arm.ipk](HyperDRM_0.11.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow.
Elevate the installed own service through Homebrew Channel: Elevate the installed own service through Homebrew Channel:
@@ -90,7 +92,7 @@ Remote navigation regression tests: `node tests/remote-navigation.test.js`.
Version 0.7.0 adds **Auto PicCap**, disabled by default. The switch is available when PicCap is installed. Keep PicCap's own autostart disabled. From 0.9.0, HyperDRM manages PicCap's receiver address, FlatBuffer port and Unix socket setting when automatic handover is enabled; other capture preferences and the autostart preference are preserved. Version 0.7.0 adds **Auto PicCap**, disabled by default. The switch is available when PicCap is installed. Keep PicCap's own autostart disabled. From 0.9.0, HyperDRM manages PicCap's receiver address, FlatBuffer port and Unix socket setting when automatic handover is enabled; other capture preferences and the autostart preference are preserved.
When the session is started, the controller polls LG's active media resources. Two consecutive foreground media-decoder samples without a Secure Video Path resource allow PicCap to start. HyperDRM releases its histogram capture and clears priority 149 first, allowing PicCap's configured input priority to take over. Any `SVP_CPB` resource selects protected playback; missing, stale, malformed, idle or HDMI state selects the conservative HyperDRM fallback. PicCap must confirm that it has stopped before histogram capture resumes. Detection is specific to the tested G4 resource model, not a universal DRM-state API. When the session is started, the controller polls LG's active media resources. From 0.10.0, three consecutive foreground media-decoder samples over at least two seconds without a Secure Video Path resource allow PicCap to start. HyperDRM releases its histogram capture and clears priority 149 first, allowing PicCap's configured input priority to take over. Any `SVP_CPB` resource selects protected playback immediately. Missing, stale, malformed, idle or HDMI state selects the conservative HyperDRM fallback; an already active PicCap session gets at most 1.5 seconds of unknown-state grace for short playback pauses. Unknown states never start PicCap. PicCap must confirm that it has stopped before histogram capture resumes. Detection is specific to the tested G4 resource model, not a universal DRM-state API.
The switch is saved in the own service container and applies after reboot when Autostart is enabled. Stop pauses both managed capture paths until Start. Turning Auto PicCap off stops a PicCap session started by this controller and restores HyperDRM. Avoid starting another capture service independently while automatic handover is active. The switch is saved in the own service container and applies after reboot when Autostart is enabled. Stop pauses both managed capture paths until Start. Turning Auto PicCap off stops a PicCap session started by this controller and restores HyperDRM. Avoid starting another capture service independently while automatic handover is active.
@@ -127,3 +129,66 @@ The diagnostic line reports service version/root status, capture transition, pla
Before PicCap starts, its endpoint is read through getSettings. Matching settings are reused. Otherwise setSettings updates only address, port and unix-socket:false; the official implementation restarts PicCap itself, so the controller checks status rather than issuing another start. Endpoint changes are saved by PicCap and remain after disabling handover. Changing the receiver stops the managed PicCap session before configuring the new endpoint. Settings errors/timeouts stop PicCap before histogram fallback, with a cooldown to avoid rapid restarts. Before PicCap starts, its endpoint is read through getSettings. Matching settings are reused. Otherwise setSettings updates only address, port and unix-socket:false; the official implementation restarts PicCap itself, so the controller checks status rather than issuing another start. Endpoint changes are saved by PicCap and remain after disabling handover. Changing the receiver stops the managed PicCap session before configuring the new endpoint. Settings errors/timeouts stop PicCap before histogram fallback, with a cooldown to avoid rapid restarts.
Feature tests are native/test_diagnostics.c, native/test_target.c, native/test_handover.c and tests/receiver-ui.test.js. The native tests cover fragmented replies, rejected authentication, invalid layout, refused connection, timeout, custom PicCap endpoint, configuration failure and exclusive capture ownership. Test-only JSON and FlatBuffer relays allow a real receiver to be reached through another IP and ports without changing receiver configuration. Feature tests are native/test_diagnostics.c, native/test_target.c, native/test_handover.c and tests/receiver-ui.test.js. The native tests cover fragmented replies, rejected authentication, invalid layout, refused connection, timeout, custom PicCap endpoint, configuration failure and exclusive capture ownership. Test-only JSON and FlatBuffer relays allow a real receiver to be reached through another IP and ports without changing receiver configuration.
## Capture resilience and live layout (0.10.0)
If managed PicCap reports disconnected for five seconds, HyperDRM requests its stop, waits for confirmed stopped status and resumes histogram output. Reconnection inside that window clears the loss timer. A 30-second cooldown prevents immediate PicCap restart after a connection failure; normal capture transitions have a three-second reentry delay. Manual Stop, disabling handover, changing the receiver and secure playback take precedence over unknown-state grace.
While a capture session runs, a separate nonblocking authenticated JSON connection checks the LED layout every five seconds. Changed LED count, positions or order replace the validated mapping between frames. Invalid or timed-out responses retain the last good mapping and report a layout error; capture continues. Profile changes cancel obsolete probes. Receiver JSON streaming and the user connection test have independent sockets. HyperHDR continues to apply its own current LED rectangles to the generated color plane.
## TV model profiles (0.10.0)
Select Auto, LG G4 (measured), Average (uniform), or Custom model in TV calibration and select Save profile. The app obtains the model identifier through LG's system-property service. Auto selects an imported profile matching that exact identifier, then the built-in G4 series profile, otherwise uniform average colors. Unknown models are not assigned an invented spatial calibration. A manual G4 selection is available when model detection is unavailable.
Custom imports accept measured model JSON with 2-16 sorted clockwise perimeter points and histogram region indices 0-15. Profiles persist privately in the own service container and apply to the current session without restarting capture. See [profile format and calibration guidance](profiles/README.md). Only G4 hardware has been validated; importing a profile does not guarantee histogram support on another TV. Color calibration remains in Hyperion/HyperHDR.
## Color continuity and guided calibration
A populated histogram containing black pixels is a valid dark scene and is sent
immediately. Empty channel populations and failed reads are missing data, not
black. The last good colors are held for at most 1.5 seconds while capture remains
available; a longer failure triggers bounded capture recovery. Receiver failure
cannot be hidden by color holding. Real black does not wait for a recovery fade.
Source handovers retain the previous output briefly instead of clearing it before
PicCap connects. An independent nonblocking raw LED subscription provides the
previous PicCap colors for the return to HyperDRM, and a fresh PicCap target for
the forward transition. The transition lasts 500 ms, with a three-second overall
handover bound. Conversion from LED samples to histogram regions is approximate;
receiver color correction and configured smoothing remain in the receiver. If
streaming is unavailable, ordinary bounded source selection still works.
In **TV calibration**, select **Calibration** then **Start Auto Calibration**. The bundled H.264 video
shows a black reference and 32 white patches around all four edges. Keep the app
visible and the picture full-screen without zoom or bars. The service temporarily
disables PicCap handover and bar detection for this measurement, then restores
normal operation. Cancellation, Back, playback errors and a 110-second service
lease protect against leaving calibration active after the app closes.
Fresh measurements identify responsive edge regions. Global-only, absent or
ambiguous responses are rejected without overwriting a saved profile. Successful
measurements fill the Custom profile editor; choose **Save profile** to apply.
Select **Validate profile** afterwards: the top-left quadrant is red, top-right
green, bottom-right blue and bottom-left yellow. Check LED positions on every
edge. The measured profile is specific to the exact reported model; other models
still require their own measurement. This does not add HDR color calibration or
more independently captured regions.
An explicit app launch with `{"action":"calibrate"}` starts the same visible
wizard. The calibration video can be reproduced with
`python build_calibration_video.py --ffmpeg /path/to/ffmpeg`.
The TV UI separates Overview, Receiver and Calibration. Auto Calibration is also
linked from Overview. Fields use focusable selection frames; highlighting or
hovering a frame never focuses its input. OK or a click starts editing and opens
the TV keyboard. Keyboard dismissal or Back returns to the selection frame.
Only visible controls participate in arrow navigation. Custom profile JSON is
available through Edit profile JSON after selecting a custom profile.
If an update leaves an old process running without root, Overview shows Enable
Root Access. Changing autostart also handles a root-required reply automatically:
the app asks Homebrew Channel to elevate only its own service, restarts that
service, verifies UID 0, and retries the switch. Root and boot-hook failures have
separate messages. The explicit launch action `{"action":"repairRoot"}` invokes
the same visible repair flow. Homebrew Channel must be installed and permit the
request; denied elevation is reported without changing autostart.
+58 -6
View File
@@ -1,15 +1,37 @@
'use strict'; 'use strict';
var pending=[]; var pending=[];
var targetDirty=false,receiver='Hyperion'; var targetDirty=false,receiver='Hyperion';
function call(method,done,data){ var profileDirty=false,profile='auto',advancedProfile=false;
var profileNames={auto:'Auto',g4:'LG G4 (measured)',average:'Average (uniform)',custom:'Custom model'};
function showProfile(){
document.getElementById('profile').textContent='Profile: '+profileNames[profile];
var visible=profile==='custom' && advancedProfile;
document.getElementById('customEditor').style.display=visible?'block':'none';
document.getElementById('customProfile').disabled=!visible;
document.getElementById('editProfile').disabled=profile!=='custom';
document.getElementById('editProfile').textContent=visible?'Hide profile JSON':'Edit profile JSON';
}
function call(method,done,data){callUri('luna://org.webosbrew.histogramhyperion.service/'+method,done,data);}
function callUri(uri,done,data){
if(typeof PalmServiceBridge==='undefined'){done({returnValue:false,errorText:'This app requires an LG TV with the native service installed.'});return;} 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; var bridge=new PalmServiceBridge(),timer,finished=false;
function finish(value){if(finished)return;finished=true;clearTimeout(timer);var i=pending.indexOf(bridge);if(i>=0)pending.splice(i,1);done(value);} function finish(value){if(finished)return;finished=true;clearTimeout(timer);var i=pending.indexOf(bridge);if(i>=0)pending.splice(i,1);done(value);}
bridge.onservicecallback=function(raw){try{finish(JSON.parse(raw));}catch(e){finish({returnValue:false,errorText:'Invalid service response'});}}; bridge.onservicecallback=function(raw){try{finish(JSON.parse(raw));}catch(e){finish({returnValue:false,errorText:'Invalid service response'});}};
pending.push(bridge);timer=setTimeout(function(){bridge.cancel();finish({returnValue:false,errorText:'The service is not responding. Check the installation and service log.'});},10000); pending.push(bridge);timer=setTimeout(function(){bridge.cancel();finish({returnValue:false,errorText:'The service is not responding. Check the installation and service log.'});},uri.indexOf('hbchannel')>=0?30000:10000);
bridge.call('luna://org.webosbrew.histogramhyperion.service/'+method,JSON.stringify(data||{})); bridge.call(uri,JSON.stringify(data||{}));
} }
function display(result){ function display(result){
if(typeof result.uid==='number'){
document.getElementById('grantRoot').style.display=result.uid===0?'none':'inline-block';
document.getElementById('rootHint').style.display=result.uid===0?'none':'block';
}
if(result.calibrationProfile && !profileDirty){
profile=result.calibrationProfile;showProfile();
document.getElementById('customProfile').value=JSON.stringify({model:result.customModel||result.tvModel||'TV-MODEL',points:result.customPoints&&result.customPoints.length?result.customPoints:[]},null,2);
}
if(result.effectiveCalibration)document.getElementById('profileStatus').textContent='TV: '+(result.tvModel||'Unknown')+' · Active profile: '+profileNames[result.effectiveCalibration]+(result.effectiveCalibration==='average'?' · Spatial calibration needed for this model':'');
if(result.host && !targetDirty){ if(result.host && !targetDirty){
document.getElementById('host').value=result.host;document.getElementById('port').value=result.port;document.getElementById('piccapPort').value=result.piccapPort||19400;receiver=result.receiver; document.getElementById('host').value=result.host;document.getElementById('port').value=result.port;document.getElementById('piccapPort').value=result.piccapPort||19400;receiver=result.receiver;
document.getElementById('receiver').textContent='Receiver: '+receiver; document.getElementById('receiver').textContent='Receiver: '+receiver;
@@ -19,7 +41,7 @@ function display(result){
var t=result.connectionTest; var t=result.connectionTest;
if(t)document.getElementById('testResult').textContent='Connection test: '+t.state+(t.state==='Passed'?' · '+t.ledCount+' LEDs · '+t.elapsedMs+' ms':'')+(t.error?' · '+t.error:''); if(t)document.getElementById('testResult').textContent='Connection test: '+t.state+(t.state==='Passed'?' · '+t.ledCount+' LEDs · '+t.elapsedMs+' ms':'')+(t.error?' · '+t.error:'');
document.getElementById('testConnection').disabled=!!t && ['Connecting','Sending request','Checking authentication','Reading LED layout'].indexOf(t.state)>=0; document.getElementById('testConnection').disabled=!!t && ['Connecting','Sending request','Checking authentication','Reading LED layout'].indexOf(t.state)>=0;
if(result.version)document.getElementById('diagnostics').textContent='Version '+result.version+' · '+(result.uid===0?'Root service':'Service needs root elevation')+' · '+result.captureState+' · Playback: '+result.playbackProtection+' · Capture recoveries: '+result.captureRecoveries+' (last: '+result.lastRecoveryMs+' ms)'+(result.lastOutputAgeMs>=0?' · Last histogram output: '+result.lastOutputAgeMs+' ms ago':'')+(result.retryScheduled?' · Retry in '+result.retryInMs+' ms · Attempts: '+result.retryAttempts:'')+(result.piccapError?' · '+result.piccapError:''); if(result.version)document.getElementById('diagnostics').textContent='Version '+result.version+' · '+(result.uid===0?'Root service':'Service needs root elevation')+' · '+result.captureState+' · Playback: '+result.playbackProtection+' · Capture recoveries: '+result.captureRecoveries+' (last: '+result.lastRecoveryMs+' ms)'+(result.lastOutputAgeMs>=0?' · Last histogram output: '+result.lastOutputAgeMs+' ms ago':'')+(result.retryScheduled?' · Retry in '+result.retryInMs+' ms · Attempts: '+result.retryAttempts:'')+(result.piccapError?' · '+result.piccapError:'')+(result.piccapCooldownMs?' · PicCap retry in '+Math.ceil(result.piccapCooldownMs/1000)+' s':'')+' · Layout updates: '+(result.layoutUpdates||0)+(result.layoutError?' · Layout: '+result.layoutError:'');
var handover=document.getElementById('handover');handover.disabled=!result.piccapInstalled; var handover=document.getElementById('handover');handover.disabled=!result.piccapInstalled;
handover.setAttribute('aria-checked',String(!!result.piccapHandover)); handover.setAttribute('aria-checked',String(!!result.piccapHandover));
handover.textContent=result.piccapInstalled?'Auto PicCap: '+(result.piccapHandover?'On':'Off'):'Auto PicCap: Not installed'; handover.textContent=result.piccapInstalled?'Auto PicCap: '+(result.piccapHandover?'On':'Off'):'Auto PicCap: Not installed';
@@ -32,10 +54,13 @@ function display(result){
} }
if(result.piccapHandover)document.getElementById('status').textContent+=' · HyperDRM · '+result.playbackProtection; if(result.piccapHandover)document.getElementById('status').textContent+=' · HyperDRM · '+result.playbackProtection;
if(result.piccapHandover && !result.piccapTargetCompatible)document.getElementById('status').textContent+=' · Auto PicCap unavailable for remote IPv6 target'; if(result.piccapHandover && !result.piccapTargetCompatible)document.getElementById('status').textContent+=' · Auto PicCap unavailable for remote IPv6 target';
if(result.colorValid && result.effectiveCalibration==='average')document.getElementById('status').textContent+=' · Average color output';
if(result.colorValid)document.getElementById('status').textContent+=result.spatialOutput?' · '+result.ledCount+' LEDs from 16 color regions'+(result.receiver==='HyperHDR'?' · Spatial image output':''):' · Output RGB: '+result.outputColor.join(', '); if(result.colorValid)document.getElementById('status').textContent+=result.spatialOutput?' · '+result.ledCount+' LEDs from 16 color regions'+(result.receiver==='HyperHDR'?' · Spatial image output':''):' · Output RGB: '+result.outputColor.join(', ');
else if(result.returnValue!==false)document.getElementById('status').textContent+=' · No current color output'; 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.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.spatialInterpolation)document.getElementById('status').textContent+=' · Smooth color transitions';
if(result.holdingColors)document.getElementById('status').textContent+=' · Holding last valid colors (missing data)';
if(result.sourceFade)document.getElementById('status').textContent+=' · Blending capture sources';
if(result.retryScheduled)document.getElementById('status').textContent+=' · Retrying receiver or capture startup'; if(result.retryScheduled)document.getElementById('status').textContent+=' · Retrying receiver or capture startup';
if(result.retryPaused)document.getElementById('status').textContent+=' · Automatic retry paused until Start'; if(result.retryPaused)document.getElementById('status').textContent+=' · Automatic retry paused until Start';
var box=document.getElementById('regions');box.textContent=''; var box=document.getElementById('regions');box.textContent='';
@@ -47,7 +72,30 @@ document.getElementById('refresh').onclick=function(){call('status',display);};
call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000); call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000);
document.getElementById('start').focus(); 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});}; var repairingRoot=false,rootWaiters=[];
function repairRoot(done){
rootWaiters.push(done);if(repairingRoot)return;repairingRoot=true;document.getElementById('grantRoot').disabled=true;
document.getElementById('error').textContent='Granting root access to HyperDRM...';
function finish(error){repairingRoot=false;document.getElementById('grantRoot').disabled=false;if(error)document.getElementById('error').textContent=error;var waiters=rootWaiters;rootWaiters=[];waiters.forEach(function(callback){callback(!error);});}
callUri('luna://org.webosbrew.hbchannel.service/elevateService',function(r){
if(r.returnValue===false){finish('Homebrew Channel could not grant root access: '+(r.errorText||'Check Homebrew Channel installation.'));return;}
call('restartService',function(r){
if(r.returnValue===false){finish(r.errorText||'Could not restart the HyperDRM service.');return;}
var attempts=0;
function check(){call('status',function(s){if(s.uid===0){display(s);finish();}else if(++attempts<12)setTimeout(check,1000);else finish('The service is still missing root access. Elevate HyperDRM in Homebrew Channel and reopen the app.');});}
setTimeout(check,1500);
});
},{id:'org.webosbrew.histogramhyperion.service'});
}
document.getElementById('grantRoot').onclick=function(){repairRoot(function(ok){if(ok)call('status',display);});};
document.getElementById('autostart').onclick=function(){
var button=this,enabled=button.getAttribute('aria-checked')!=='true';button.disabled=true;
function save(){call('autostart',function(r){
if(r.rootRequired){repairRoot(function(ok){if(ok)save();else button.disabled=false;});return;}
button.disabled=false;if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);
},{enabled:enabled});}
save();
};
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'});}; 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'});};
['host','port','piccapPort','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});}); ['host','port','piccapPort','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});});
@@ -62,4 +110,8 @@ document.getElementById('saveTarget').onclick=function(){
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); 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);
}; };
document.getElementById('testConnection').onclick=function(){if(targetDirty){document.getElementById('error').textContent='Save receiver settings before testing the connection.';return;}this.disabled=true;call('testConnection',function(r){if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;call('status',display);});}; document.getElementById('testConnection').onclick=function(){if(targetDirty){document.getElementById('error').textContent='Save receiver settings before testing the connection.';return;}this.disabled=true;call('testConnection',function(r){if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;call('status',display);});};
setupRemoteNavigation(['start','stop','refresh','autostart','handover','receiver','host','port','piccapPort','token','clearToken','saveTarget','testConnection'].map(function(id){return document.getElementById(id);}),document,window); document.getElementById('profile').onclick=function(){var keys=['auto','g4','average','custom'];profile=keys[(keys.indexOf(profile)+1)%keys.length];profileDirty=true;showProfile();};
document.getElementById('customProfile').addEventListener('input',function(){profileDirty=true;});
document.getElementById('saveProfile').onclick=function(){var data={profile:profile},button=this;if(profile==='custom'){try{var parsed=JSON.parse(document.getElementById('customProfile').value);data.model=parsed.model;data.points=parsed.points;}catch(e){document.getElementById('error').textContent='Custom profile must be valid JSON.';return;}}button.disabled=true;call('calibration',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}profileDirty=false;call('status',display);},data);};
document.getElementById('editProfile').onclick=function(){advancedProfile=!advancedProfile;showProfile();if(advancedProfile)document.getElementById('customProfileField').focus();};
setupRemoteNavigation(['overviewTab','receiverTab','calibrationTab','grantRoot','start','stop','refresh','autostart','handover','calibrationShortcut','receiver','hostField','portField','piccapPortField','tokenField','clearToken','saveTarget','testConnection','calibrate','validateCalibration','profile','saveProfile','editProfile','customProfileField','cancelCalibration'].map(function(id){return document.getElementById(id);}),document,window);
+1 -1
View File
@@ -1 +1 @@
{"id": "org.webosbrew.histogramhyperion", "version": "0.9.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"} {"id": "org.webosbrew.histogramhyperion", "version": "0.11.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"}
+35
View File
@@ -0,0 +1,35 @@
/* Pure measurement processing; the video uses 32 clockwise edge patches. */
function calibrationFit(baseline,patches,model){
if(!model || baseline.length!==16 || patches.length!==32)throw new Error('Incomplete calibration measurements.');
var candidates=[],edges=[0,0,0,0];
for(var region=0;region<16;region++){
var responses=patches.map(function(p){if(!p || p.length!==16)throw new Error('Missing video measurements.');return Math.max(0,p[region]-baseline[region]);});
var peak=Math.max.apply(Math,responses),best=responses.indexOf(peak);
if(peak<8)continue;
// Reject regions responding equally around the screen (global histograms).
var strong=responses.filter(function(v){return v>=peak*.65;}).length;
if(strong>10)continue;
var distant=responses.some(function(v,i){var d=Math.min(Math.abs(i-best),32-Math.abs(i-best));return v>=peak*.65 && d>6;});
if(distant)continue;
var sum=0,weighted=0;
for(var offset=-3;offset<=3;offset++){
var index=(best+offset+32)%32,weight=Math.max(0,responses[index]-peak*.35);
weighted+=offset*weight;sum+=weight;
}
var position=((best+.5+weighted/sum)/8+4)%4;
candidates.push({position:position,region:region,score:peak/strong});
}
// Nested LG regions can measure the same edge position. Keep the most
// localized response rather than making duplicate interpolation anchors.
candidates.sort(function(a,b){return b.score-a.score || a.region-b.region;});
var points=[];
candidates.forEach(function(p){if(points.some(function(q){var d=Math.abs(p.position-q.position);return Math.min(d,4-d)<.06;}))return;points.push({position:p.position,region:p.region});edges[Math.floor(p.position)]++;});
points.sort(function(a,b){return a.position-b.position;});
if(points.length<4 || edges.some(function(n){return !n;}))throw new Error('No reliable spatial response on every edge. Keep the video full-screen; this TV may not expose local histogram regions. Your saved profile is unchanged.');
return {model:model,points:points};
}
function calibrationLuma(regions){return regions.map(function(c){return (c[0]+c[1]+c[2])/3;});}
function calibrationAverage(samples){
if(samples.length<3)throw new Error('Not enough fresh histogram samples. Keep the app visible and repeat calibration.');
return samples[0].map(function(_,r){return samples.reduce(function(sum,s){return sum+s[r];},0)/samples.length;});
}
Binary file not shown.
+58 -18
View File
@@ -1,18 +1,58 @@
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>HyperDRM</title> <!doctype html>
<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,#piccapPort{width:140px}#clearToken{width:28px;height:28px}</style> <html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>HyperDRM</title>
<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> <style>
<div role="group" aria-label="Service controls"><button id="start">Start</button><button id="stop">Stop</button><button id="refresh">Refresh status</button> body{background:#101827;color:#f1f5ff;font:24px sans-serif;margin:36px 54px;max-width:1800px}
<button id="autostart" role="switch" aria-checked="false">Autostart: Off</button> h1{font-size:40px;display:flex;align-items:center;gap:18px;margin:0 0 16px}h1 img{border-radius:16px}
<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> button{font:inherit;padding:14px 24px;margin:8px 12px 8px 0;background:#223654;color:white;border:2px solid #739be0;border-radius:10px}
<fieldset><legend>Receiver settings</legend><button id="receiver" type="button">Receiver: Hyperion</button> button:disabled{opacity:.45}button:focus,.field:focus{outline:4px solid #66ddff;outline-offset:3px}
<label>IP address <input id="host" type="text" maxlength="45" value="127.0.0.1" spellcheck="false"></label> nav{border-bottom:1px solid #456185;padding-bottom:10px;margin-bottom:18px}nav button[aria-pressed="true"]{background:#126b85;border-color:#66ddff}
<label>JSON port <input id="port" type="number" min="1" max="65535" value="19444"></label> .primary{background:#126b85;border-color:#66ddff}p{line-height:1.4;margin:12px 0}small{color:#c0cbe0;font-size:21px}
<label>PicCap FlatBuffer port <input id="piccapPort" type="number" min="1" max="65535" value="19400"></label> #error{color:#ffbc99}fieldset{border:1px solid #456185;border-radius:12px;padding:18px 24px;margin:16px 0}
<label>API token (optional) <input id="token" type="password" maxlength="256" autocomplete="off"></label> .fields{display:flex;flex-wrap:wrap;gap:18px;margin:18px 0}.field{border:2px solid #739be0;border-radius:10px;padding:12px;background:#1b2b43;width:360px;cursor:pointer}
<label><input id="clearToken" type="checkbox"> Remove saved token</label><button id="saveTarget" type="button">Save receiver</button><button id="testConnection" type="button">Test connection</button><p id="testResult" role="status">Connection test: Not tested</p> .field label{display:flex;flex-direction:column;gap:10px;font-size:22px}.field input,.field textarea{font:inherit;color:white;background:transparent;border:0;outline:0;width:100%;padding:0;box-sizing:border-box}
<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> .field:focus-within{border-color:#66ddff}.field input:read-write,.field textarea:read-write{background:#223654;outline:2px solid #66ddff}
<p id="piccapTargetHint"><small>Auto PicCap uses the same receiver IP with its FlatBuffer port, usually 19400. Enable the FlatBuffer server on the receiver. When switching to PicCap, its saved address, port and Unix socket setting are updated; other PicCap preferences are preserved. PicCap supports IPv4 targets; remote IPv6 uses HyperDRM. The API token applies to JSON only.</small></p></fieldset> .narrow{width:225px}.wide{width:95%;max-width:1200px}#clearToken{width:24px;height:24px}
<p><small id="endpoint">Priority 149 &middot; 10 updates per second</small></p> #regions{display:flex;flex-wrap:wrap;gap:8px;margin:20px 0}.swatch{width:50px;height:50px;border:1px solid #abc;border-radius:6px}
<p><small id="diagnostics"></small></p> #diagnostics{display:block;margin-top:14px;max-width:1500px}#cancelCalibration{display:none}
<div id="regions" aria-label="Histogram colors"></div><p><small>Adjust saturation, brightness and smoothing in your receiver. The receiver layout determines the number and positions of LEDs. Colors come from 16 LG histogram regions with interpolated edge colors. HyperHDR receives a generated color plane, which its image mapping converts to LED colors. Restart the service after changing the LED layout.</small></p> #cancelCalibration:not(:disabled){display:block;position:fixed;right:24px;top:24px;z-index:101;font-size:20px;opacity:.65}
<script src="remote.js"></script><script src="app.js"></script></html> #calibrationVideo{display:none;position:fixed;inset:0;width:100vw;height:100vh;object-fit:fill;background:black;z-index:100}
.steps{display:flex;gap:24px;flex-wrap:wrap}.steps p{flex:1;min-width:260px;color:#c0cbe0}.steps strong{display:block;color:#fff;margin-bottom:6px}
</style>
<h1><img src="logo.png" alt="" width="76" height="76">HyperDRM</h1>
<nav aria-label="App sections"><button id="overviewTab" aria-pressed="true">Overview</button><button id="receiverTab" aria-pressed="false">Receiver</button><button id="calibrationTab" aria-pressed="false">Calibration</button></nav>
<p id="status" role="status">Checking service &hellip;</p><p id="error" role="alert"></p>
<section id="overviewPanel" aria-label="Overview">
<button id="grantRoot" class="primary" style="display:none">Enable Root Access</button><p id="rootHint" style="display:none"><small>Homebrew Channel grants root to the HyperDRM service. The service then restarts with its updated permissions.</small></p>
<div role="group" aria-label="Service controls"><button id="start" class="primary">Start</button><button id="stop">Stop</button><button id="refresh">Refresh status</button></div>
<fieldset><legend>Automatic operation</legend><button id="autostart" role="switch" aria-checked="false">Autostart: Off</button><button id="handover" role="switch" aria-checked="false" disabled>Auto PicCap: Off</button>
<p><small>Autostart retries until the receiver is available. Auto PicCap uses PicCap for unprotected playback and HyperDRM for protected playback. Keep PicCap's own autostart off.</small></p></fieldset>
<button id="calibrationShortcut">Auto Calibration</button><small>Measure a spatial profile for your TV.</small>
<p><small id="endpoint">Priority 149 &middot; 10 updates per second</small></p><div id="regions" aria-label="Histogram colors"></div>
<p><small>Adjust brightness, saturation and normal smoothing in Hyperion or HyperHDR.</small></p><small id="diagnostics"></small>
</section>
<section id="receiverPanel" aria-label="Receiver settings" style="display:none">
<fieldset><legend>Receiver connection</legend><button id="receiver">Receiver: Hyperion</button>
<p><small>Select a field with the arrow buttons. Press OK or click to open the TV keyboard.</small></p>
<div class="fields">
<div id="hostField" class="field" tabindex="0" role="button" aria-label="IP address, press OK to edit"><label>IP address<input id="host" tabindex="-1" readonly type="text" maxlength="45" value="127.0.0.1" spellcheck="false"></label></div>
<div id="portField" class="field narrow" tabindex="0" role="button" aria-label="JSON port, press OK to edit"><label>JSON port<input id="port" tabindex="-1" readonly type="number" min="1" max="65535" value="19444"></label></div>
<div id="piccapPortField" class="field narrow" tabindex="0" role="button" aria-label="FlatBuffer port, press OK to edit"><label>PicCap FlatBuffer port<input id="piccapPort" tabindex="-1" readonly type="number" min="1" max="65535" value="19400"></label></div>
<div id="tokenField" class="field" tabindex="0" role="button" aria-label="API token, press OK to edit"><label>API token (optional)<input id="token" tabindex="-1" readonly type="password" maxlength="256" autocomplete="off"></label></div>
</div>
<label><input id="clearToken" type="checkbox"> Remove saved token</label>
<div><button id="saveTarget" class="primary">Save receiver</button><button id="testConnection">Test connection</button></div><p id="testResult" role="status">Connection test: Not tested</p>
<p><small>The JSON TCP port is usually 19444. Leave the token blank to keep the saved token. Saving updates the current session.</small></p>
<p id="piccapTargetHint"><small>Auto PicCap uses the same IP and its FlatBuffer port, usually 19400. Enable the receiver's FlatBuffer server. Remote IPv6 uses HyperDRM.</small></p></fieldset>
</section>
<section id="calibrationPanel" aria-label="TV calibration" style="display:none">
<p id="profileStatus">Detecting TV model...</p>
<fieldset><legend>Auto Calibration</legend>
<div class="steps"><p><strong>1. Measure</strong>Play the built-in edge test. Keep the picture full-screen without bars.</p><p><strong>2. Save</strong>Review the result and save the measured profile.</p><p><strong>3. Check</strong>Validate all four colored screen corners against the LEDs.</p></div>
<button id="calibrate" class="primary">Start Auto Calibration</button><button id="validateCalibration">Validate profile</button><p id="wizardStatus" role="status">Measurement takes about 70 seconds. Your saved profile is preserved until you select Save profile.</p>
</fieldset>
<fieldset><legend>Spatial profile</legend><button id="profile">Profile: Auto</button><button id="saveProfile" class="primary">Save profile</button><button id="editProfile" disabled>Edit profile JSON</button>
<p><small>Auto uses your measured model profile or the built-in LG G4 profile. Other uncalibrated TVs use average colors.</small></p>
<div id="customEditor" style="display:none"><div id="customProfileField" class="field wide" tabindex="0" role="button" aria-label="Custom profile JSON, press OK to edit"><label>Custom model profile JSON<textarea id="customProfile" tabindex="-1" readonly rows="4" maxlength="2048" disabled spellcheck="false"></textarea></label></div></div>
</fieldset></section>
<button id="cancelCalibration" disabled>Cancel calibration</button><video id="calibrationVideo" src="calibration.mp4" muted preload="auto" playsinline></video>
<script src="remote.js"></script><script src="calibration.js"></script><script src="navigation.js"></script><script src="app.js"></script><script src="wizard.js"></script></html>
+21
View File
@@ -0,0 +1,21 @@
function showPanel(name,focus){
['overview','receiver','calibration'].forEach(function(key){document.getElementById(key+'Panel').style.display=key===name?'block':'none';document.getElementById(key+'Tab').setAttribute('aria-pressed',String(key===name));});
if(focus!==false)document.getElementById({overview:'start',receiver:'receiver',calibration:'calibrate'}[name]).focus();
}
['overview','receiver','calibration'].forEach(function(name){document.getElementById(name+'Tab').onclick=function(){showPanel(name);};});
document.getElementById('calibrationShortcut').onclick=function(){showPanel('calibration');};
function setupFormEditors(ids,doc,win){
var pairs=ids.map(function(id){return {input:doc.getElementById(id),field:doc.getElementById(id+'Field')};});
pairs.forEach(function(pair){
var input=pair.input,field=pair.field;input.readOnly=true;
field.onclick=function(){if(input.disabled || !input.readOnly)return;input.readOnly=false;input.focus();};
input.addEventListener('blur',function(){input.readOnly=true;});
input.addEventListener('mousedown',function(event){if(input.readOnly)event.preventDefault();});
});
function finishEditing(){
pairs.forEach(function(pair){if(!pair.input.readOnly){pair.input.readOnly=true;pair.input.blur();pair.field.focus();}});
}
doc.addEventListener('keyboardStateChange',function(event){var detail=event.detail||{};if(detail.visibility===false || detail.isShowing===false)finishEditing();});
doc.addEventListener('keydown',function(event){if((event.keyCode===461 || event.key==='Escape') && pairs.some(function(pair){return !pair.input.readOnly;})){event.preventDefault();finishEditing();}});
}
setupFormEditors(['host','port','piccapPort','token','customProfile'],document,window);
+3 -3
View File
@@ -11,10 +11,10 @@ function setupRemoteNavigation(buttons, doc, win) {
event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0; event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0;
var enter=event.key==='Enter'||code===13; var enter=event.key==='Enter'||code===13;
if(!direction&&!enter)return; if(!direction&&!enter)return;
var editing=doc.activeElement && doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox'; var editing=doc.activeElement && !doc.activeElement.readOnly && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA');
if(editing && (enter||code===37||code===39||event.key==='ArrowLeft'||event.key==='ArrowRight'))return; if(editing)return;
event.preventDefault(); event.preventDefault();
var enabled=buttons.filter(function(button){return !button.disabled;}); var enabled=buttons.filter(function(button){return !button.disabled && (typeof button.getClientRects!=='function' || button.getClientRects().length) && (!win.remoteNavigationScope || win.remoteNavigationScope.indexOf(button.id)>=0);});
if(!enabled.length)return; if(!enabled.length)return;
var focused=doc.activeElement,index=enabled.indexOf(focused); var focused=doc.activeElement,index=enabled.indexOf(focused);
if(direction){ if(direction){
+87
View File
@@ -0,0 +1,87 @@
'use strict';
var wizard=null;
var video=document.getElementById('calibrationVideo');
function wizardFinish(error){
if(!wizard)return;
clearInterval(wizard.timer);wizard=null;window.remoteNavigationScope=null;video.pause();video.style.display='none';
if(typeof video.removeAttribute==='function'){video.removeAttribute('src');video.load();}
document.getElementById('cancelCalibration').disabled=true;
document.getElementById('calibrate').disabled=false;document.getElementById('validateCalibration').disabled=false;
call('calibrationSession',function(r){if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:false});
call('calibrationResult',function(){},{message:error||document.getElementById('wizardStatus').textContent});
if(error)document.getElementById('wizardStatus').textContent=error;
document.getElementById('calibrate').focus();
}
function wizardSample(){
if(!wizard || wizard.busy)return;
if(Date.now()-wizard.started>100000){wizardFinish('Calibration timed out. Saved profile unchanged.');return;}
if(wizard.validation)return;
var t=video.currentTime;
if(t>=68){
try{
var measured=calibrationFit(calibrationAverage(wizard.baseline),wizard.patches.map(calibrationAverage),wizard.model);
profile='custom';profileDirty=true;showProfile();document.getElementById('customProfile').value=JSON.stringify(measured,null,2);
document.getElementById('wizardStatus').textContent='Measured '+measured.points.length+' edge regions. Select Save profile, then Validate profile to check all four colored edges. The previous saved profile is still active.';
wizard.busy=true;
call('calibrationDraft',function(r){if(wizard)wizardFinish(r.returnValue===false?r.errorText:undefined);},{profile:'custom',model:measured.model,points:measured.points});
}catch(e){wizardFinish(e.message);}
return;
}
var step=t<4?-1:Math.floor((t-4)/2),local=t<4?t:(t-4)%2;
if(step<0 && (t<.8 || t>3.2) || step>=0 && (local<.65 || local>1.75))return;
wizard.busy=true;
call('status',function(s){
if(!wizard)return;
wizard.busy=false;
if(s.returnValue===false || !s.calibrating){wizardFinish(s.errorText||'Calibration session ended. Saved profile unchanged.');return;}
// Ignore duplicates, held output and replies crossing a stimulus boundary.
var current=video.currentTime,nowStep=current<4?-1:Math.floor((current-4)/2);
if(nowStep!==step || s.sampleSequence===wizard.sequence || s.sampleAgeMs<0 || s.sampleAgeMs>350 || !s.colorValid || !Array.isArray(s.regions) || s.regions.length!==16)return;
wizard.sequence=s.sampleSequence;
(step<0?wizard.baseline:wizard.patches[step]).push(calibrationLuma(s.regions));
document.getElementById('wizardStatus').textContent=step<0?'Measuring black reference...':'Measuring edge patch '+(step+1)+' of 32...';
});
}
function wizardStart(validation){
if(wizard)return;
if(typeof showPanel==='function')showPanel('calibration',false);
if(targetDirty || profileDirty){document.getElementById('wizardStatus').textContent='Save receiver and profile edits before starting.';return;}
document.getElementById('calibrate').disabled=true;document.getElementById('validateCalibration').disabled=true;
document.getElementById('cancelCalibration').disabled=false;
window.remoteNavigationScope=['cancelCalibration'];
wizard={validation:validation,started:Date.now(),busy:false,sequence:-1,model:'',baseline:[],patches:Array.from({length:32},function(){return [];})};
document.getElementById('wizardStatus').textContent='Starting full-screen capture. Keep the app visible; do not change zoom or aspect ratio.';
call('calibrationSession',function(r){
if(!wizard)return;if(r.returnValue===false){wizardFinish(r.errorText);return;}
// Start the video plane before waiting for histogram data. Without another
// running video, waiting first would prevent calibration from ever starting.
video.src='calibration.mp4';if(typeof video.load==='function')video.load();
video.style.display='block';var warmup=video.play();
if(warmup && warmup.catch)warmup.catch(function(){wizardFinish('The TV could not play the calibration video. Saved profile unchanged.');});
call('start',function(){waitCapture();});
},{enabled:true});
function waitCapture(){
if(!wizard)return;
call('status',function(s){
if(!wizard)return;
if(s.running && s.activeSource!=='PicCap' && s.tvModel && s.colorValid){
wizard.model=s.tvModel;video.currentTime=validation?68:0;video.style.display='block';
document.getElementById('cancelCalibration').focus();
var playing=video.play();if(playing && playing.catch)playing.catch(function(){wizardFinish('The TV could not play the calibration video. Saved profile unchanged.');});
wizard.timer=setInterval(wizardSample,250);
}else if(Date.now()-wizard.started<15000)setTimeout(waitCapture,500);
else wizardFinish('No fresh full-screen histogram capture or TV model. Saved profile unchanged.');
});
}
}
document.getElementById('calibrate').onclick=function(){wizardStart(false);};
document.getElementById('validateCalibration').onclick=function(){wizardStart(true);};
document.getElementById('cancelCalibration').onclick=function(){wizardFinish('Calibration cancelled. Saved profile unchanged.');};
video.addEventListener('ended',function(){if(wizard && wizard.validation)wizardFinish('Validation finished. Top-left red, top-right green, bottom-right blue, bottom-left yellow. Check matching LED positions.');else wizardSample();});
video.addEventListener('error',function(){wizardFinish('Calibration video playback failed. Saved profile unchanged.');});
document.addEventListener('keydown',function(e){if(wizard && (e.keyCode===461 || e.key==='Escape')){e.preventDefault();wizardFinish('Calibration cancelled. Saved profile unchanged.');}});
window.addEventListener('pagehide',function(){wizardFinish();});
// An explicit launch action also supports starting the TV's guided test remotely.
function wizardLaunch(raw){try{var params=typeof raw==='string'?JSON.parse(raw):raw;if(params && params.action==='calibrate')wizardStart(false);else if(params && params.action==='repairRoot'){showPanel('overview');repairRoot(function(ok){if(ok)call('status',display);});}}catch(e){document.getElementById('wizardStatus').textContent='Invalid launch parameters.';}}
if(typeof PalmSystem!=='undefined' && PalmSystem.launchParams)setTimeout(function(){wizardLaunch(PalmSystem.launchParams);},1000);
window.addEventListener('webOSRelaunch',function(){if(typeof PalmSystem!=='undefined')wizardLaunch(PalmSystem.launchParams);});
+30
View File
@@ -0,0 +1,30 @@
"""Build the deterministic native video-plane calibration stimulus (H.264)."""
import argparse, subprocess
from pathlib import Path
parser=argparse.ArgumentParser()
parser.add_argument('--ffmpeg',default='ffmpeg')
args=parser.parse_args()
root=Path(__file__).resolve().parent
width,height=1920,1080
filters=[]
for step in range(32):
edge,index=divmod(step,8)
center=(index+.5)/8
if edge==0: x,y,w,h=int(center*width-192),0,384,162
elif edge==1: x,y,w,h=1632,int(center*height-108),288,216
elif edge==2: x,y,w,h=int((1-center)*width-192),918,384,162
else: x,y,w,h=0,int((1-center)*height-108),288,216
left,top=max(0,x),max(0,y)
w,h=min(width,x+w)-left,min(height,y+h)-top
start=4+step*2
filters.append(f"drawbox=x={left}:y={top}:w={w}:h={h}:color=white:t=fill:enable='gte(t,{start})*lt(t,{start+2})'")
# Four color quadrants for visual validation after measurement.
for x,y,color in [(0,0,'red'),(960,0,'lime'),(960,540,'blue'),(0,540,'yellow')]:
filters.append(f"drawbox=x={x}:y={y}:w=960:h=540:color={color}:t=fill:enable='gte(t,68)'")
subprocess.run([args.ffmpeg,'-hide_banner','-loglevel','error','-y','-f','lavfi','-i',
'color=black:s=1920x1080:r=30:d=78','-vf',','.join(filters),
'-c:v','libx264','-preset','fast','-crf','16','-pix_fmt','yuv420p',
'-profile:v','main','-level','4.0','-g','30','-an','-movflags','+faststart',
str(root/'app/calibration.mp4')],check=True)
print(root/'app/calibration.mp4')
+47
View File
@@ -89,3 +89,50 @@ ARM tests on the G4 passed connection diagnostics, fragmented replies, invalid l
Switching the user playback back to DRM stopped PicCap automatically and resumed histogram output through the remote JSON relay. Seven actual HyperHDR LED-stream samples contained 89-91 distinct colors for 120 LEDs, with no all-black sample. Both test endpoints were restored to local defaults and the synthetic token was removed. Switching the user playback back to DRM stopped PicCap automatically and resumed histogram output through the remote JSON relay. Seven actual HyperHDR LED-stream samples contained 89-91 distinct colors for 120 LEDs, with no all-black sample. Both test endpoints were restored to local defaults and the synthetic token was removed.
The remote FlatBuffer relay received over 411 MB from PicCap during the test session. The final port validation also rejects explicit JSON null while retaining the default for older preferences without the new field. The remote FlatBuffer relay received over 411 MB from PicCap during the test session. The final port validation also rejects explicit JSON null while retaining the default for older preferences without the new field.
## Capture resilience, live layout and model profiles (0.10.0)
Managed PicCap requires three clear media samples over two seconds before starting. Active PicCap has a bounded 1.5-second grace for unknown state; secure playback, manual Stop, handover disable and endpoint changes take precedence. Five seconds of reported receiver disconnection trigger a confirmed PicCap stop and histogram fallback, followed by a 30-second cooldown. A successful reconnection clears the loss timer. ARM controller tests passed stable gating, pause grace expiry, immediate secure transition, reconnect/loss/cooldown, exclusive capture, custom endpoints and request failures.
A separate nonblocking authenticated receiver probe refreshes the LED mapping every five seconds. Invalid responses retain the good mapping. Tests passed 50-to-500 LED replacement, changed positions at the same count, unchanged mappings, obsolete profile-result cancellation and failure retention. The live relay test read 50 LEDs at frame 2, changed to 500 at frame 53, applied changed 500-LED positions at frame 101, retained output after an empty layout at frame 149, and restored the actual 120-LED layout at frame 195. Capture/output continued across these checks without resetting the frame counter. Receiver configuration and WLED settings were not modified.
The app automatically detected OLED65G42LW.DEUQLJP and selected the measured G4 profile. A live switch to Average produced one unique output color across four HyperHDR LED-stream samples; switching back to G4 produced 80-97 unique colors. Auto and the original receiver/handover preferences were restored afterward. Custom profiles validate 2-16 sorted rim positions, bounded region indices and model identifiers, persist privately, and are selected automatically only for the exact imported model identifier. No measured spatial profile for other hardware is claimed. Native tests passed profile parsing/persistence, model selection and average fallback. The synthetic-image test verifies geometry cache replacement after a custom-profile change.
The receiver UI and remote-navigation tests passed, including custom JSON input and profile controls. Diagnosis export was deliberately excluded from this release. Cold boot and other TV models remain unverified.
## Color continuity, calibration wizard and TV navigation (0.11.0)
Empty histogram populations and failed reads are distinguished from populated
black frames. A bounded 1.5-second hold expires into capture recovery; valid dark
frames bypass the 500 ms recovery blend without altering their RGB values. ARM
tests verify hold timing, black populations, recovery mixing and near-black codec
rounding. An independent nonblocking raw receiver LED subscription accepts old
result and current data envelopes, authenticates when required, handles fragmented
messages, validates array values/counts and rebuilds mappings on layout/profile
changes. Source bridge tests cover both directions, fresh-sample gating, midpoint
and completion, genuine black, stale samples, timeout, connection setup and Stop.
The bundled reproducible H.264 calibration video measures 32 edge positions.
PicCap handover and bar detection are suppressed only for the bounded calibration
session. The wizard starts the video plane before waiting for capture, requires
fresh samples, rejects global/multimodal responses and selects the most localized
anchor when nested regions overlap. The live G4 run produced ten sorted anchors
covering all four edges and preserved the saved Auto/G4 profile. One source
revocation recovered in 252 ms. Other TV hardware remains unverified; candidates
require user review/save and four-corner validation. Failed measurements and
cancellation preserve the saved profile.
Overview, Receiver and Calibration are separate sections. Start Auto Calibration
is directly visible in Calibration and linked from Overview. Focusable field
frames keep native inputs unfocused during highlighting; OK/click explicitly
starts keyboard editing. The documented keyboardStateChange event and Back
restore frame navigation. Tests cover section selection, hidden/disabled controls,
input focus only after activation, keyboard dismissal, wizard scope and rejection,
proposal versus saved state, JSON validation and root-repair retries/denials.
The root repair targets only org.webosbrew.histogramhyperion.service via Homebrew
Channel and restarts only this service. The TV's current root process and boot
symlink were verified. Actual autostart disable/enable requests succeeded, with
Autostart restored to On. No receiver color correction, WLED configuration or
PicCap autostart setting was changed. A local browser preview was unavailable
because the browser policy rejected file URLs; no alternate browser path was used.
+84
View File
@@ -0,0 +1,84 @@
#ifndef HYPERDRM_CALIBRATION_H
#define HYPERDRM_CALIBRATION_H
#include <math.h>
#include "playback.h"
#define CALIBRATION_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/calibration.json"
typedef struct {double position;unsigned char region;} RimPoint;
typedef enum {CAL_AUTO,CAL_G4,CAL_AVERAGE,CAL_CUSTOM} CalibrationKind;
typedef struct {CalibrationKind kind;char model[64];unsigned count;RimPoint points[16];} Calibration;
static Calibration calibration;
static char tv_model[64];
static unsigned calibration_generation=1;
static inline unsigned calibration_revision(void){return calibration_generation;}
static const RimPoint g4_rim[]={
{0.2078431373,7},{0.3176470588,3},{0.5490196078,11},{0.7529411765,12},
{0.8392156863,13},{1.4941176471,10},{2.1843137255,9},{2.5490196078,1},
{2.8666666667,5},{3.4,15}
};
static inline const char *calibration_key(CalibrationKind kind){
const char *keys[]={"auto","g4","average","custom"};return keys[kind];
}
static inline bool calibration_model_valid(const char *model){
if(!model[0] || strlen(model)>63)return false;
for(const unsigned char *p=(const unsigned char*)model;*p;p++)
if(!((*p>='A'&&*p<='Z')||(*p>='a'&&*p<='z')||(*p>='0'&&*p<='9')||*p=='.'||*p=='-'||*p=='_'||*p==' '))return false;
return true;
}
/* LG retail model identifiers: OLED + two-digit panel size + G4 series. */
static inline bool calibration_is_g4(const char *model){
return strlen(model)>=8 && !strncmp(model,"OLED",4) && model[4]>='0' && model[4]<='9' && model[5]>='0' && model[5]<='9' && model[6]=='G' && model[7]=='4';
}
static inline CalibrationKind calibration_effective(void){
if(calibration.kind!=CAL_AUTO)return calibration.kind;
if(calibration.count>=2 && tv_model[0] && !strcmp(calibration.model,tv_model))return CAL_CUSTOM;
return calibration_is_g4(tv_model)?CAL_G4:CAL_AVERAGE;
}
static inline bool calibration_parse(PlaybackJson *j,const char *text,Calibration *result){
void *root=j->parse(text);if(!root)return false;
Calibration next=*result;bool valid=false;
for(unsigned k=0;k<4;k++)if(playback_is(j,root,"profile",calibration_key(k))){next.kind=k;valid=true;break;}
void *points=NULL;
if(j->type(root)==4 && j->get(root,"points",&points)){
void *model=playback_field(j,root,"model");
valid=valid && points && j->type(points)==5 && model && j->type(model)==6 && calibration_model_valid(j->string(model));
size_t count=valid?j->length(points):0;valid=valid && count>=2 && count<=16;
if(valid){strcpy(next.model,j->string(model));next.count=count;memset(next.points,0,sizeof(next.points));}
for(size_t i=0;valid && i<count;i++){
void *point=j->at(points,i),*position=playback_field(j,point,"position"),*region=playback_field(j,point,"region");
valid=position && (j->type(position)==2 || j->type(position)==3) && region && j->type(region)==3;
if(valid){char *end;double x=strtod(j->string(position),&end);valid=end && !*end && isfinite(x) && x>=0 && x<4;
long r=strtol(j->string(region),&end,10);valid=valid && end && !*end && r>=0 && r<16 && (!i || x>next.points[i-1].position);
if(valid){next.points[i].position=x;next.points[i].region=r;}
}
}
}
if(next.kind==CAL_CUSTOM && next.count<2)valid=false;
j->put(root);if(valid)*result=next;return valid;
}
static inline bool calibration_save(const char *path,const Calibration *value){
char temp[512],data[2048];int used=snprintf(data,sizeof(data),"{\"profile\":\"%s\"",calibration_key(value->kind));
if(value->count){
used+=snprintf(data+used,sizeof(data)-used,",\"model\":\"%s\",\"points\":[",value->model);
for(unsigned i=0;i<value->count;i++)used+=snprintf(data+used,sizeof(data)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",value->points[i].position,value->points[i].region);
used+=snprintf(data+used,sizeof(data)-used,"]");
}
used+=snprintf(data+used,sizeof(data)-used,"}\n");
if(snprintf(temp,sizeof(temp),"%s.tmp",path)>=(int)sizeof(temp))return false;
int fd=open(temp,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);if(fd<0)return false;
bool ok=write(fd,data,used)==used && fsync(fd)==0;if(close(fd)!=0)ok=false;
if(ok)ok=rename(temp,path)==0;
if(!ok)unlink(temp);
return ok;
}
static inline void calibration_load(PlaybackJson *j,const char *path){
int fd=open(path,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return;
char data[2048];ssize_t n=read(fd,data,sizeof(data)-1);close(fd);
if(n<=0 || n==(ssize_t)sizeof(data)-1)return;
data[n]=0;calibration_parse(j,data,&calibration);
}
static inline void calibration_colors(const unsigned char input[16][3],unsigned char output[16][3]){
memcpy(output,input,48);if(calibration_effective()!=CAL_AVERAGE)return;
unsigned sum[3]={0};for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)sum[c]+=input[i][c];
for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)output[i][c]=sum[c]/16;
}
#endif
+35
View File
@@ -0,0 +1,35 @@
#ifndef HYPERDRM_CONTINUITY_H
#define HYPERDRM_CONTINUITY_H
/* Zero population is missing data, while populated bin zero is real black. */
static inline bool histogram_populated(const Histogram hist[16]){
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++){
uint64_t total=0;for(unsigned b=0;b<256;b++)total+=hist[r].bins[c][b];
if(!total)return false;
}
return true;
}
typedef struct {unsigned char last[16][3],from[16][3];uint64_t valid_at,fade_at,missing_at;bool available,fading,holding;} Continuity;
static inline void continuity_seed(Continuity *s,const unsigned char colors[16][3],uint64_t now){
memcpy(s->last,colors,sizeof(s->last));memcpy(s->from,colors,sizeof(s->from));s->valid_at=now;s->available=true;s->fading=true;s->fade_at=0;s->missing_at=0;s->holding=false;
}
static inline void continuity_mix(const unsigned char from[16][3],const unsigned char to[16][3],unsigned elapsed,unsigned char out[16][3]){
if(elapsed>500)elapsed=500;
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)out[r][c]=(from[r][c]*(500-elapsed)+to[r][c]*elapsed+250)/500;
}
static inline bool continuity_frame(Continuity *s,bool valid,const unsigned char input[16][3],uint64_t now,unsigned char out[16][3]){
if(!valid){
if(!s->missing_at)s->missing_at=now;
s->holding=s->available && now-s->missing_at<1500;
if(s->holding)memcpy(out,s->last,sizeof(s->last));
return s->holding;
}
if(s->holding){memcpy(s->from,s->last,sizeof(s->from));s->fading=true;s->fade_at=0;}
s->holding=false;s->missing_at=0;
/* Near-black codec rounding is valid darkness, not a lost measurement. */
bool black=true;for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)if(input[r][c]>3)black=false;
if(black)s->fading=false;
if(s->fading){if(!s->fade_at)s->fade_at=now;continuity_mix(s->from,input,(unsigned)(now-s->fade_at),out);if(now-s->fade_at>=500)s->fading=false;}
else memcpy(out,input,sizeof(s->last));
memcpy(s->last,out,sizeof(s->last));s->available=true;s->valid_at=now;return true;
}
#endif
+55 -48
View File
@@ -3,69 +3,76 @@
/* An independent nonblocking connection. Tests never send colors or acquire /* An independent nonblocking connection. Tests never send colors or acquire
* capture. Work per timer iteration is bounded to keep output responsive. */ * capture. Work per timer iteration is bounded to keep output responsive. */
typedef enum {TEST_IDLE,TEST_CONNECT,TEST_SEND,TEST_LOGIN,TEST_LAYOUT,TEST_DONE,TEST_FAILED} TestStage; typedef enum {TEST_IDLE,TEST_CONNECT,TEST_SEND,TEST_LOGIN,TEST_LAYOUT,TEST_DONE,TEST_FAILED} TestStage;
static struct { typedef struct {
int fd;TestStage stage,after_send;TargetConfig target; int fd;TestStage stage,after_send;TargetConfig target;
uint64_t started,deadline,elapsed;unsigned leds; uint64_t started,deadline,elapsed;unsigned leds;
char request[384],error[128];size_t sent,used;char *response; char request[384],error[128];size_t sent,used;char *response;
} connection_test={.fd=-1}; LedBlend *mapping;
static const char *test_stage_name(void){ } ReceiverProbe;
static ReceiverProbe connection_test={.fd=-1};
static const char *probe_stage_name(const ReceiverProbe *probe){
static const char *names[]={"Not tested","Connecting","Sending request","Checking authentication","Reading LED layout","Passed","Failed"}; static const char *names[]={"Not tested","Connecting","Sending request","Checking authentication","Reading LED layout","Passed","Failed"};
return names[connection_test.stage]; return names[probe->stage];
} }
static bool test_busy(void){return connection_test.stage>=TEST_CONNECT && connection_test.stage<=TEST_LAYOUT;} static bool probe_busy(const ReceiverProbe *probe){return probe->stage>=TEST_CONNECT && probe->stage<=TEST_LAYOUT;}
static void test_close(void){if(connection_test.fd>=0)close(connection_test.fd);connection_test.fd=-1;free(connection_test.response);connection_test.response=NULL;memset(connection_test.request,0,sizeof(connection_test.request));memset(connection_test.target.token,0,sizeof(connection_test.target.token));} static void probe_close(ReceiverProbe *probe){if(probe->fd>=0)close(probe->fd);probe->fd=-1;free(probe->mapping);probe->mapping=NULL;free(probe->response);probe->response=NULL;memset(probe->request,0,sizeof(probe->request));memset(probe->target.token,0,sizeof(probe->target.token));}
static void test_finish(const char *error,uint64_t now){ static void probe_finish(ReceiverProbe *probe,const char *error,uint64_t now){
test_close();connection_test.elapsed=now-connection_test.started; probe_close(probe);probe->elapsed=now-probe->started;
connection_test.stage=error?TEST_FAILED:TEST_DONE; probe->stage=error?TEST_FAILED:TEST_DONE;
snprintf(connection_test.error,sizeof(connection_test.error),"%s",error?error:""); snprintf(probe->error,sizeof(probe->error),"%s",error?error:"");
} }
static void test_request(bool login,uint64_t now){ static void probe_request(ReceiverProbe *probe,bool login,uint64_t now){
if(login)snprintf(connection_test.request,sizeof(connection_test.request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",connection_test.target.token); if(login)snprintf(probe->request,sizeof(probe->request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",probe->target.token);
else snprintf(connection_test.request,sizeof(connection_test.request),"{\"command\":\"serverinfo\"}\n"); else snprintf(probe->request,sizeof(probe->request),"{\"command\":\"serverinfo\"}\n");
connection_test.sent=connection_test.used=0;connection_test.after_send=login?TEST_LOGIN:TEST_LAYOUT; probe->sent=probe->used=0;probe->after_send=login?TEST_LOGIN:TEST_LAYOUT;
connection_test.stage=TEST_SEND;connection_test.deadline=now+3000; probe->stage=TEST_SEND;probe->deadline=now+3000;
} }
static bool test_begin(const TargetConfig *target,uint64_t now){ static bool probe_begin(ReceiverProbe *probe,const TargetConfig *target,uint64_t now){
if(test_busy())return false; if(probe_busy(probe))return false;
test_close();memset(&connection_test,0,sizeof(connection_test));connection_test.fd=-1; probe_close(probe);memset(probe,0,sizeof(*probe));probe->fd=-1;
connection_test.target=*target;connection_test.started=now; probe->target=*target;probe->started=now;
connection_test.response=malloc(2*1024*1024+1); probe->response=malloc(2*1024*1024+1);
if(!connection_test.response){test_finish("Cannot allocate connection test buffer",now);return true;} if(!probe->response){probe_finish(probe,"Cannot allocate connection test buffer",now);return true;}
connection_test.fd=target_open(target); probe->fd=target_open(target);
if(connection_test.fd<0){test_finish("Cannot open receiver connection",now);return true;} if(probe->fd<0){probe_finish(probe,"Cannot open receiver connection",now);return true;}
connection_test.stage=TEST_CONNECT;connection_test.deadline=now+3000;return true; probe->stage=TEST_CONNECT;probe->deadline=now+3000;return true;
} }
static void test_tick(PlaybackJson *json,uint64_t now){ static void probe_tick(ReceiverProbe *probe,PlaybackJson *json,uint64_t now){
if(!test_busy())return; if(!probe_busy(probe))return;
if(now>=connection_test.deadline){test_finish("Receiver timed out",now);return;} if(now>=probe->deadline){probe_finish(probe,"Receiver timed out",now);return;}
int fd=connection_test.fd; int fd=probe->fd;
if(connection_test.stage==TEST_CONNECT){ if(probe->stage==TEST_CONNECT){
struct pollfd p={fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error); struct pollfd p={fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error);
if(poll(&p,1,0)<=0)return; if(poll(&p,1,0)<=0)return;
if(getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){test_finish("Receiver connection refused or unreachable",now);return;} if(getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){probe_finish(probe,"Receiver connection refused or unreachable",now);return;}
test_request(connection_test.target.token[0]!=0,now);return; probe_request(probe,probe->target.token[0]!=0,now);return;
} }
if(connection_test.stage==TEST_SEND){ if(probe->stage==TEST_SEND){
size_t length=strlen(connection_test.request); size_t length=strlen(probe->request);
ssize_t n=send(fd,connection_test.request+connection_test.sent,length-connection_test.sent,MSG_NOSIGNAL); ssize_t n=send(fd,probe->request+probe->sent,length-probe->sent,MSG_NOSIGNAL);
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return;
if(n<=0){test_finish("Receiver closed the test connection",now);return;} if(n<=0){probe_finish(probe,"Receiver closed the test connection",now);return;}
connection_test.sent+=(size_t)n; probe->sent+=(size_t)n;
if(connection_test.sent==length){connection_test.stage=connection_test.after_send;connection_test.deadline=now+3000;memset(connection_test.request,0,sizeof(connection_test.request));} if(probe->sent==length){probe->stage=probe->after_send;probe->deadline=now+3000;memset(probe->request,0,sizeof(probe->request));}
return; return;
} }
size_t available=2*1024*1024-connection_test.used;if(available>65536)available=65536; size_t available=2*1024*1024-probe->used;if(available>65536)available=65536;
if(!available){test_finish("Receiver response exceeds 2 MiB",now);return;} if(!available){probe_finish(probe,"Receiver response exceeds 2 MiB",now);return;}
ssize_t n=recv(fd,connection_test.response+connection_test.used,available,0); ssize_t n=recv(fd,probe->response+probe->used,available,0);
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return;
if(n<=0){test_finish("Receiver closed the test connection",now);return;} if(n<=0){probe_finish(probe,"Receiver closed the test connection",now);return;}
connection_test.used+=(size_t)n;connection_test.response[connection_test.used]=0; probe->used+=(size_t)n;probe->response[probe->used]=0;
char *end=strchr(connection_test.response,'\n');if(!end)return;*end=0; char *end=strchr(probe->response,'\n');if(!end)return;*end=0;
void *root=json->parse(connection_test.response);bool success=playback_true(json,root,"success");if(root)json->put(root); void *root=json->parse(probe->response);bool success=playback_true(json,root,"success");if(root)json->put(root);
if(!success){test_finish(connection_test.stage==TEST_LOGIN?"Receiver authentication failed":"Receiver rejected serverinfo; check authorization",now);return;} if(!success){probe_finish(probe,probe->stage==TEST_LOGIN?"Receiver authentication failed":"Receiver rejected serverinfo; check authorization",now);return;}
if(connection_test.stage==TEST_LOGIN){test_request(false,now);return;} if(probe->stage==TEST_LOGIN){probe_request(probe,false,now);return;}
LedBlend *mapping=NULL;unsigned count=0; LedBlend *mapping=NULL;unsigned count=0;
if(!layout_from_json(connection_test.response,&mapping,&count)){test_finish("Receiver returned an invalid or empty LED layout",now);return;} if(!layout_from_json(probe->response,&mapping,&count)){probe_finish(probe,"Receiver returned an invalid or empty LED layout",now);return;}
free(mapping);connection_test.leds=count;test_finish(NULL,now); probe->leds=count;probe_finish(probe,NULL,now);probe->mapping=mapping;
} }
static inline const char *test_stage_name(void){return probe_stage_name(&connection_test);}
static inline bool test_busy(void){return probe_busy(&connection_test);}
static inline void test_close(void){probe_close(&connection_test);}
static inline bool test_begin(const TargetConfig *target,uint64_t now){return probe_begin(&connection_test,target,now);}
static inline void test_tick(PlaybackJson *json,uint64_t now){probe_tick(&connection_test,json,now);}
#endif #endif
+35 -11
View File
@@ -1,6 +1,9 @@
#ifndef HYPERDRM_HANDOVER_H #ifndef HYPERDRM_HANDOVER_H
#define HYPERDRM_HANDOVER_H #define HYPERDRM_HANDOVER_H
#include "playback.h" #include "playback.h"
#ifndef CALIBRATION_ACTIVE
#define CALIBRATION_ACTIVE false
#endif
#ifndef PICCAP_BINARY #ifndef PICCAP_BINARY
#define PICCAP_BINARY "/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos" #define PICCAP_BINARY "/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos"
#endif #endif
@@ -16,7 +19,12 @@ static bool (*call_once)(void*,const char*,const char*,Callback,void*,unsigned l
static bool (*cancel_call)(void*,unsigned long,LSError*); static bool (*cancel_call)(void*,unsigned long,LSError*);
static unsigned long piccap_token,media_token; static unsigned long piccap_token,media_token;
static uint64_t piccap_deadline,media_deadline; static uint64_t piccap_deadline,media_deadline;
static unsigned clear_samples; static unsigned clear_samples,piccap_fallbacks;
static uint64_t clear_since,unknown_since,piccap_disconnected_since;
#define CLEAR_STABLE_MS 2000
#define UNKNOWN_GRACE_MS 1500
#define PICCAP_LOSS_MS 5000
#define PICCAP_FAILURE_COOLDOWN_MS 30000
static TargetConfig piccap_target; static TargetConfig piccap_target;
static char piccap_error[128]; static char piccap_error[128];
static bool piccap_request(int command); static bool piccap_request(int command);
@@ -38,7 +46,11 @@ static void handover_load(void){
static bool media_reply(void *h,void *m,void *ctx){ static bool media_reply(void *h,void *m,void *ctx){
(void)h;(void)ctx;media_busy=false;media_token=0; (void)h;(void)ctx;media_busy=false;media_token=0;
protection=playback_classify(&playback_json,payload(m));protection_at=milliseconds(); protection=playback_classify(&playback_json,payload(m));protection_at=milliseconds();
if(protection==PLAYBACK_CLEAR){if(clear_samples<2)clear_samples++;}else clear_samples=0; if(protection==PLAYBACK_CLEAR){
if(!clear_samples)clear_since=protection_at;
if(clear_samples<3)clear_samples++;
unknown_since=0;
}else {clear_samples=0;clear_since=0;if(protection==PLAYBACK_UNKNOWN && !unknown_since)unknown_since=protection_at;else if(protection==PLAYBACK_PROTECTED)unknown_since=0;}
return true; return true;
} }
/* Commands: 0 status, 1 start, 2 stop, 3 getSettings, 4 setSettings. Confirm stopped status before VT reuse. */ /* Commands: 0 status, 1 start, 2 stop, 3 getSettings, 4 setSettings. Confirm stopped status before VT reuse. */
@@ -50,10 +62,15 @@ static bool piccap_reply(void *h,void *m,void *ctx){
if(ok && running && playback_json.type(running)==1){ if(ok && running && playback_json.type(running)==1){
bool active=playback_json.boolean(running); bool active=playback_json.boolean(running);
piccap_connected=playback_true(&playback_json,root,"connected"); piccap_connected=playback_true(&playback_json,root,"connected");
if(active){if(capture_source==SOURCE_PICCAP_STARTING)capture_source=SOURCE_PICCAP;} if(active){
if(capture_source==SOURCE_PICCAP_STARTING)capture_source=SOURCE_PICCAP;
if(piccap_connected)piccap_disconnected_since=0;
else if(!piccap_disconnected_since)piccap_disconnected_since=milliseconds();
}
else if(capture_source!=SOURCE_PICCAP_STARTING || milliseconds()-piccap_action_at>=2000){ else if(capture_source!=SOURCE_PICCAP_STARTING || milliseconds()-piccap_action_at>=2000){
if(capture_source!=SOURCE_PICCAP_STOPPING)handover_cooldown=milliseconds()+10000; if(capture_source!=SOURCE_PICCAP_STOPPING)handover_cooldown=milliseconds()+10000;
piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false; piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;piccap_disconnected_since=0;
if(handover_cooldown<milliseconds()+3000)handover_cooldown=milliseconds()+3000;
if(!retries.paused)retries.due=milliseconds(); if(!retries.paused)retries.due=milliseconds();
} }
}else if(!piccap_installed()){piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;} }else if(!piccap_installed()){piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;}
@@ -84,7 +101,7 @@ static bool piccap_request(int command){
/* Return true while PicCap owns capture or a stop confirmation is pending. */ /* Return true while PicCap owns capture or a stop confirmation is pending. */
static bool handover_tick(void){ static bool handover_tick(void){
uint64_t now=milliseconds(); uint64_t now=milliseconds();
if(media_busy && now>=media_deadline){LSError e;error_init(&e);cancel_call(service_handle,media_token,&e);error_free(&e);media_busy=false;media_token=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;} if(media_busy && now>=media_deadline){LSError e;error_init(&e);cancel_call(service_handle,media_token,&e);error_free(&e);media_busy=false;media_token=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;}
if(piccap_busy && now>=piccap_deadline){snprintf(piccap_error,sizeof(piccap_error),"PicCap request timed out");LSError e;error_init(&e);cancel_call(service_handle,piccap_token,&e);error_free(&e);piccap_busy=false;piccap_token=0;capture_source=SOURCE_PICCAP_STOPPING;next_piccap_probe=0;} if(piccap_busy && now>=piccap_deadline){snprintf(piccap_error,sizeof(piccap_error),"PicCap request timed out");LSError e;error_init(&e);cancel_call(service_handle,piccap_token,&e);error_free(&e);piccap_busy=false;piccap_token=0;capture_source=SOURCE_PICCAP_STOPPING;next_piccap_probe=0;}
if(handover_enabled && piccap_installed() && !retries.paused && playback_json.lib && !media_busy && now>=next_media_probe){ if(handover_enabled && piccap_installed() && !retries.paused && playback_json.lib && !media_busy && now>=next_media_probe){
LSError e;error_init(&e); LSError e;error_init(&e);
@@ -92,13 +109,20 @@ static bool handover_tick(void){
media_deadline=now+3000; media_deadline=now+3000;
error_free(&e);next_media_probe=now+1000; error_free(&e);next_media_probe=now+1000;
} }
bool clear=handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked && if(protection==PLAYBACK_CLEAR && now-protection_at>=3000){protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;}
protection==PLAYBACK_CLEAR && clear_samples>=2 && now-protection_at<3000 && now>=handover_cooldown; bool session=!CALIBRATION_ACTIVE && handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked;
if(capture_source==SOURCE_HISTOGRAM && clear){ bool clear=session && protection==PLAYBACK_CLEAR && now-protection_at<3000;
piccap_target=target;piccap_error[0]=0;stop_capture();retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING; bool stable=clear && clear_samples>=3 && now-clear_since>=CLEAR_STABLE_MS && now>=handover_cooldown;
if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+10000;} if(capture_source==SOURCE_HISTOGRAM && stable){
piccap_target=target;piccap_error[0]=0;piccap_disconnected_since=now;release_capture(false);retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING;
if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS;}
} }
if(piccap_owned && (!clear || strcmp(piccap_target.host,target.host) || piccap_target.piccap_port!=target.piccap_port) && capture_source!=SOURCE_PICCAP_STOPPING){capture_source=SOURCE_PICCAP_STOPPING;next_piccap_probe=0;} bool grace=protection==PLAYBACK_UNKNOWN && unknown_since && now-unknown_since<UNKNOWN_GRACE_MS && capture_source==SOURCE_PICCAP;
bool lost=piccap_owned && capture_source==SOURCE_PICCAP && piccap_disconnected_since && now-piccap_disconnected_since>=PICCAP_LOSS_MS;
if(lost){
snprintf(piccap_error,sizeof(piccap_error),"PicCap receiver disconnected; histogram fallback");piccap_fallbacks++;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS;
}
if(piccap_owned && (!session || (!clear && !grace) || lost || strcmp(piccap_target.host,target.host) || piccap_target.piccap_port!=target.piccap_port) && capture_source!=SOURCE_PICCAP_STOPPING){capture_source=SOURCE_PICCAP_STOPPING;next_piccap_probe=0;}
if(piccap_owned && !piccap_busy && now>=next_piccap_probe){ if(piccap_owned && !piccap_busy && now>=next_piccap_probe){
if(capture_source==SOURCE_PICCAP_STOPPING)piccap_request(2);else piccap_request(0); if(capture_source==SOURCE_PICCAP_STOPPING)piccap_request(2);else piccap_request(0);
next_piccap_probe=now+1000; next_piccap_probe=now+1000;
+6 -7
View File
@@ -6,19 +6,18 @@
#include <dlfcn.h> #include <dlfcn.h>
#include <math.h> #include <math.h>
typedef struct {unsigned char a,b;unsigned weight;} LedBlend; typedef struct {unsigned char a,b;unsigned weight;} LedBlend;
/* Measured G4 rim samples, independent of LED count and wiring order. */ #include <fcntl.h>
static const struct {double position;unsigned char region;} rim[]={ #include <unistd.h>
{0.2078431373,7},{0.3176470588,3},{0.5490196078,11},{0.7529411765,12}, #include <stdio.h>
{0.8392156863,13},{1.4941176471,10},{2.1843137255,9},{2.5490196078,1}, #include "calibration.h"
{2.8666666667,5},{3.4,15}
};
static double screen_perimeter(double x,double y){ static double screen_perimeter(double x,double y){
double distances[]={y,1-x,1-y,x},positions[]={x,1+y,3-x,4-y};unsigned edge=0; double distances[]={y,1-x,1-y,x},positions[]={x,1+y,3-x,4-y};unsigned edge=0;
for(unsigned i=1;i<4;i++)if(distances[i]<distances[edge])edge=i; for(unsigned i=1;i<4;i++)if(distances[i]<distances[edge])edge=i;
return positions[edge]>=4?positions[edge]-4:positions[edge]; return positions[edge]>=4?positions[edge]-4:positions[edge];
} }
static LedBlend layout_blend(double x,double y){ static LedBlend layout_blend(double x,double y){
double t=screen_perimeter(x,y);unsigned n=sizeof(rim)/sizeof(*rim),right=0; const RimPoint *rim=calibration_effective()==CAL_CUSTOM?calibration.points:g4_rim;
double t=screen_perimeter(x,y);unsigned n=calibration_effective()==CAL_CUSTOM?calibration.count:sizeof(g4_rim)/sizeof(*g4_rim),right=0;
while(right<n && rim[right].position<=t)right++; while(right<n && rim[right].position<=t)right++;
right%=n;unsigned left=(right+n-1)%n; right%=n;unsigned left=(right+n-1)%n;
double span=rim[right].position-rim[left].position,distance=t-rim[left].position; double span=rim[right].position-rim[left].position,distance=t-rim[left].position;
+34
View File
@@ -0,0 +1,34 @@
#ifndef HYPERDRM_LAYOUT_REFRESH_H
#define HYPERDRM_LAYOUT_REFRESH_H
#define LAYOUT_REFRESH_MS 5000
static ReceiverProbe layout_probe={.fd=-1};
static uint64_t next_layout_check,last_layout_check;
static unsigned layout_updates,layout_probe_generation;
static char layout_error[128];
static void layout_refresh_reset(uint64_t due){
probe_close(&layout_probe);layout_probe.stage=TEST_IDLE;next_layout_check=due;
}
static bool layout_mapping_changed(const LedBlend *next,unsigned count){
if(count!=led_count || !led_blends)return true;
for(unsigned i=0;i<count;i++)if(next[i].a!=led_blends[i].a || next[i].b!=led_blends[i].b || next[i].weight!=led_blends[i].weight)return true;
return false;
}
static void layout_refresh_tick(PlaybackJson *json,uint64_t now,bool running){
if(!running){layout_refresh_reset(now+LAYOUT_REFRESH_MS);return;}
probe_tick(&layout_probe,json,now);
if(layout_probe.stage==TEST_DONE){
if(layout_probe_generation==calibration_generation){
if(layout_mapping_changed(layout_probe.mapping,layout_probe.leds)){
free(led_blends);led_blends=layout_probe.mapping;layout_probe.mapping=NULL;led_count=layout_probe.leds;layout_updates++;
}
last_layout_check=now;layout_error[0]=0;
}else next_layout_check=now;
probe_close(&layout_probe);layout_probe.stage=TEST_IDLE;
}else if(layout_probe.stage==TEST_FAILED){
snprintf(layout_error,sizeof(layout_error),"%s",layout_probe.error);layout_probe.stage=TEST_IDLE;
}
if(!probe_busy(&layout_probe) && now>=next_layout_check){
layout_probe_generation=calibration_generation;probe_begin(&layout_probe,&target,now);next_layout_check=now+LAYOUT_REFRESH_MS;
}
}
#endif
+71
View File
@@ -0,0 +1,71 @@
#ifndef HYPERDRM_RECEIVER_STREAM_H
#define HYPERDRM_RECEIVER_STREAM_H
#ifndef RECEIVER_LAYOUT_REVISION
#define RECEIVER_LAYOUT_REVISION 0
#endif
/* Independent raw LED subscription: bounded, nonblocking, before receiver
* color correction. Old result and newer data envelopes are both accepted. */
typedef struct {int fd,stage;uint64_t deadline,retry_at,updated;unsigned sequence,count,revision,layout_revision;LedBlend *mapping;char *data,*request;size_t used,sent,length;unsigned char colors[16][3];bool valid;} ReceiverStream;
static ReceiverStream stream={.fd=-1};
static void stream_close(uint64_t now){if(stream.fd>=0)close(stream.fd);free(stream.mapping);free(stream.data);free(stream.request);stream=(ReceiverStream){.fd=-1,.retry_at=now+5000};}
static bool stream_queue(const char *line){
if(stream.fd<0 || stream.request)return false;
stream.request=strdup(line);if(!stream.request)return false;stream.length=strlen(line);stream.sent=0;return true;
}
static void stream_start(uint64_t now){
stream.fd=target_open(&target);if(stream.fd<0){stream.retry_at=now+5000;return;}
stream.data=malloc(262145);if(!stream.data){stream_close(now);return;}stream.stage=1;stream.deadline=now+3000;stream.revision=calibration_revision();stream.layout_revision=RECEIVER_LAYOUT_REVISION;
}
static bool stream_colors(PlaybackJson *j,void *root,uint64_t now){
void *body=playback_field(j,root,"result");if(!body)body=playback_field(j,root,"data");
void *leds=playback_field(j,body,"leds");
if(!leds || j->type(leds)!=5)return false;
if(j->length(leds)!=(size_t)stream.count*3){stream.revision=0;return false;}
uint64_t sums[16][3]={{0}},weights[16]={0};
for(unsigned i=0;i<stream.count;i++){
unsigned values[3];for(unsigned c=0;c<3;c++){void *v=j->at(leds,i*3+c);if(!v || j->type(v)!=3)return false;char *end;long n=strtol(j->string(v),&end,10);if(*end || n<0 || n>255)return false;values[c]=(unsigned)n;}
LedBlend b=stream.mapping[i];unsigned w[2]={65535-b.weight,b.weight},r[2]={b.a,b.b};
for(unsigned k=0;k<2;k++){weights[r[k]]+=w[k];for(unsigned c=0;c<3;c++)sums[r[k]][c]+=(uint64_t)values[c]*w[k];}
}
unsigned char mean[3]={0};uint64_t total=0,all[3]={0};
for(unsigned r=0;r<16;r++){total+=weights[r];for(unsigned c=0;c<3;c++)all[c]+=sums[r][c];}
if(!total)return false;
for(unsigned c=0;c<3;c++)mean[c]=(unsigned char)(all[c]/total);
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)stream.colors[r][c]=weights[r]?(unsigned char)(sums[r][c]/weights[r]):mean[c];
stream.valid=true;stream.updated=now;stream.sequence++;return true;
}
static void stream_tick(PlaybackJson *j,uint64_t now,bool enabled){
if(!enabled){if(stream.fd>=0)stream_close(now);return;}
if(stream.fd>=0 && (stream.revision!=calibration_revision() || stream.layout_revision!=RECEIVER_LAYOUT_REVISION)){stream_close(now);stream.retry_at=now;}
if(stream.fd<0){if(now>=stream.retry_at)stream_start(now);return;}
if(stream.stage<4 && now>=stream.deadline){stream_close(now);return;}
if(stream.stage==1){
struct pollfd p={stream.fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error);if(poll(&p,1,0)<=0)return;
if(getsockopt(stream.fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){stream_close(now);return;}
char line[384];if(target.token[0]){snprintf(line,sizeof(line),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",target.token);stream.stage=2;}
else {snprintf(line,sizeof(line),"{\"command\":\"serverinfo\"}\n");stream.stage=3;}
if(!stream_queue(line)){stream_close(now);return;}
}
if(stream.request){
ssize_t n=send(stream.fd,stream.request+stream.sent,stream.length-stream.sent,MSG_NOSIGNAL);
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return;
if(n<=0){stream_close(now);return;}stream.sent+=(size_t)n;
if(stream.sent==stream.length){free(stream.request);stream.request=NULL;}
}
if(stream.used==262144){stream_close(now);return;}
size_t available=262144-stream.used;if(available>65536)available=65536;
ssize_t n=recv(stream.fd,stream.data+stream.used,available,0);
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return;
if(n<=0){stream_close(now);return;}stream.used+=(size_t)n;stream.data[stream.used]=0;
for(unsigned messages=0;messages<8;messages++){
char *end=strchr(stream.data,'\n');if(!end)break;*end=0;void *root=j->parse(stream.data);
if(stream.stage==2){if(!playback_true(j,root,"success")){if(root)j->put(root);stream_close(now);return;}stream.stage=3;stream_queue("{\"command\":\"serverinfo\"}\n");stream.deadline=now+3000;}
else if(stream.stage==3){
LedBlend *mapping=NULL;unsigned count=0;if(!layout_from_json(stream.data,&mapping,&count)){if(root)j->put(root);stream_close(now);return;}
stream.mapping=mapping;stream.count=count;stream.stage=4;stream_queue("{\"command\":\"ledcolors\",\"subcommand\":\"ledstream-start\",\"interval\":100}\n");
}else if(playback_is(j,root,"command","ledcolors-ledstream-update"))stream_colors(j,root,now);
if(root)j->put(root);
size_t consumed=(size_t)(end-stream.data)+1;memmove(stream.data,stream.data+consumed,stream.used-consumed);stream.used-=consumed;stream.data[stream.used]=0;
}
}
#endif
+126 -31
View File
@@ -27,6 +27,7 @@
#include "target.h" #include "target.h"
#include "spatial_image.h" #include "spatial_image.h"
#include "diagnostics.h" #include "diagnostics.h"
#include "continuity.h"
#define SERVICE "org.webosbrew.histogramhyperion.service" #define SERVICE "org.webosbrew.histogramhyperion.service"
#define PRIORITY 149 #define PRIORITY 149
typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError; typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError;
@@ -60,6 +61,10 @@ static unsigned letterbox_mode,settling;
static int pending_letterbox=-1; static int pending_letterbox=-1;
static uint64_t candidate_since,next_border_probe; static uint64_t candidate_since,next_border_probe;
static bool color_valid; static bool color_valid;
static Continuity continuity;
static uint64_t sample_at,calibration_until;
static unsigned sample_sequence;
#define CALIBRATION_ACTIVE (calibration_until>milliseconds())
static unsigned led_count; static unsigned led_count;
static LedBlend *led_blends; static LedBlend *led_blends;
static bool interpolation_enabled; static bool interpolation_enabled;
@@ -108,13 +113,20 @@ static int send_line(const char *line){
while(left){ssize_t n=send(output,p,left,MSG_NOSIGNAL);if(n<=0)return -1;p+=n;left-=n;} while(left){ssize_t n=send(output,p,left,MSG_NOSIGNAL);if(n<=0)return -1;p+=n;left-=n;}
return 0; return 0;
} }
static bool receiver_ack(void){
char answer[4096];size_t used=0;bool complete=false;
while(used+1<sizeof(answer)){ssize_t n=recv(output,answer+used,sizeof(answer)-used-1,0);if(n<=0)break;used+=(size_t)n;answer[used]=0;if(strchr(answer,'\n')){complete=true;break;}}
PlaybackJson json={0};if(!complete || !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 bool read_layout(void){ static bool read_layout(void){
const size_t capacity=2*1024*1024;char *data=malloc(capacity+1);if(!data)return false; const size_t capacity=2*1024*1024;char *data=malloc(capacity+1);if(!data)return false;
bool complete=false;size_t used=0; bool complete=false;size_t used=0;
if(send_line("{\"command\":\"serverinfo\"}\n")==0){ if(send_line("{\"command\":\"serverinfo\"}\n")==0){
while(used<capacity){ssize_t n=recv(output,data+used,capacity-used,0);if(n<=0)break;used+=(size_t)n;data[used]=0;char *end=strchr(data,'\n');if(end){*end=0;complete=true;break;}} while(used<capacity){ssize_t n=recv(output,data+used,capacity-used,0);if(n<=0)break;used+=(size_t)n;data[used]=0;char *end=strchr(data,'\n');if(end){*end=0;complete=true;break;}}
} }
bool ok=complete && layout_from_json(data,&led_blends,&led_count);free(data);interpolation_enabled=ok;return ok; LedBlend *mapping=NULL;unsigned count=0;bool ok=complete && layout_from_json(data,&mapping,&count);
if(ok){free(led_blends);led_blends=mapping;led_count=count;}free(data);interpolation_enabled=ok && calibration_effective()!=CAL_AVERAGE;return ok;
} }
static bool target_login(void){ static bool target_login(void){
if(!target.token[0])return true; if(!target.token[0])return true;
@@ -128,7 +140,9 @@ static bool target_login(void){
PlaybackJson json={0};if(!playback_json_init(&json))return false; 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; void *root=json.parse(answer);bool ok=playback_true(&json,root,"success");if(root)json.put(root);dlclose(json.lib);return ok;
} }
static void layout_refresh_reset(uint64_t due);
static void release_capture(bool clear_output){ static void release_capture(bool clear_output){
layout_refresh_reset(milliseconds()+5000);
free(led_blends);led_blends=NULL;led_count=0;interpolation_enabled=false; 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)); color_valid=false;memset(rgb,0,sizeof(rgb));memset(sent_rgb,0,sizeof(sent_rgb));
if(output>=0){if(clear_output && frames){char line[80];snprintf(line,sizeof(line),"{\"command\":\"clear\",\"priority\":%d}\n",PRIORITY);send_line(line);}close(output);output=-1;} if(output>=0){if(clear_output && frames){char line[80];snprintf(line,sizeof(line),"{\"command\":\"clear\",\"priority\":%d}\n",PRIORITY);send_line(line);}close(output);output=-1;}
@@ -137,6 +151,60 @@ static void release_capture(bool clear_output){
static void stop_capture(void){release_capture(true);} static void stop_capture(void){release_capture(true);}
static bool respond(void *h,void *m,const char *s){if(!h||!m)return true;LSError e;error_init(&e);bool ok=reply(h,m,s,&e);error_free(&e);return ok;} static bool respond(void *h,void *m,const char *s){if(!h||!m)return true;LSError e;error_init(&e);bool ok=reply(h,m,s,&e);error_free(&e);return ok;}
#include "handover.h" #include "handover.h"
#include "layout_refresh.h"
#define RECEIVER_LAYOUT_REVISION layout_updates
#include "receiver_stream.h"
#include "source_bridge.h"
static bool calibration_session(void *h,void *m,void *ctx){
(void)ctx;const char *p=payload(m);
if(!strcmp(p,"{\"enabled\":true}")){
calibration_until=milliseconds()+110000;
if(capture && vt_preset(capture,0)!=0){calibration_until=0;return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot select full-screen calibration capture\"}");}
capture_preset=letterbox_mode=0;settling=2;pending_letterbox=-1;
}else if(!strcmp(p,"{\"enabled\":false}"))calibration_until=0;
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}");
return respond(h,m,"{\"returnValue\":true}");
}
static Calibration calibration_draft;
static char calibration_message[256];
static bool report_calibration(void *h,void *m,void *ctx){
if(!playback_json.lib)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"JSON-C is unavailable\"}");
(void)ctx;void *root=playback_json.parse(payload(m)),*message=playback_field(&playback_json,root,"message");
bool valid=message && playback_json.type(message)==6 && strlen(playback_json.string(message))<sizeof(calibration_message);
if(valid)snprintf(calibration_message,sizeof(calibration_message),"%s",playback_json.string(message));
if(root)playback_json.put(root);
return respond(h,m,valid?"{\"returnValue\":true}":"{\"returnValue\":false,\"errorText\":\"Invalid calibration result message\"}");
}
static bool draft_calibration(void *h,void *m,void *ctx){
(void)ctx;Calibration next={0};
if(!playback_json.lib || !calibration_parse(&playback_json,payload(m),&next) || next.kind!=CAL_CUSTOM || next.count<4 || strcmp(next.model,tv_model))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Invalid measured model profile\"}");
calibration_draft=next;return respond(h,m,"{\"returnValue\":true}");
}
static bool model_busy;
static unsigned long model_token;
static uint64_t model_deadline,next_model_probe;
static bool model_reply(void *h,void *m,void *ctx){
(void)h;(void)ctx;model_busy=false;model_token=0;
void *root=playback_json.parse(payload(m)),*model=playback_field(&playback_json,root,"modelName");
if(playback_true(&playback_json,root,"returnValue") && model && playback_json.type(model)==6 && calibration_model_valid(playback_json.string(model))){
snprintf(tv_model,sizeof(tv_model),"%s",playback_json.string(model));calibration_generation++;layout_refresh_reset(milliseconds());
}
if(root)playback_json.put(root);
return true;
}
static void model_tick(uint64_t now){
if(model_busy && now>=model_deadline){LSError e;error_init(&e);cancel_call(service_handle,model_token,&e);error_free(&e);model_busy=false;model_token=0;}
if(!tv_model[0] && !model_busy && now>=next_model_probe){
LSError e;error_init(&e);model_busy=call_once(service_handle,"luna://com.webos.service.tv.systemproperty/getSystemInfo","{\"keys\":[\"modelName\"]}",model_reply,NULL,&model_token,&e);error_free(&e);model_deadline=now+3000;next_model_probe=now+60000;
}
}
static bool set_calibration(void *h,void *m,void *ctx){
(void)ctx;Calibration next=calibration;
if(!playback_json.lib || !calibration_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Select a valid profile; custom profiles need a model and 2-16 sorted rim points (position 0-4, region 0-15)\"}");
if(!calibration_save(CALIBRATION_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot save calibration profile\"}");
calibration=next;calibration_generation++;layout_refresh_reset(milliseconds());
return respond(h,m,"{\"returnValue\":true}");
}
static bool start_failure(void *h,void *m,const char *message){ static bool start_failure(void *h,void *m,const char *message){
retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds()); retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds());
char json[512];snprintf(json,sizeof(json),"{\"returnValue\":false,\"errorText\":\"%s\",\"retryScheduled\":%s,\"retryBlocked\":%s}",message,retries.due?"true":"false",retries.blocked?"true":"false"); char json[512];snprintf(json,sizeof(json),"{\"returnValue\":false,\"errorText\":\"%s\",\"retryScheduled\":%s,\"retryBlocked\":%s}",message,retries.due?"true":"false",retries.blocked?"true":"false");
@@ -163,17 +231,31 @@ static bool start(void *h,void *m,void *ctx){
if(recovery_started){last_recovery_ms=milliseconds()-recovery_started;recovery_started=0;} if(recovery_started){last_recovery_ms=milliseconds()-recovery_started;recovery_started=0;}
return respond(h,m,"{\"returnValue\":true,\"running\":true}"); return respond(h,m,"{\"returnValue\":true,\"running\":true}");
} }
static bool stop(void *h,void *m,void *ctx){(void)ctx;retry_pause(&retries);stop_capture();return respond(h,m,"{\"returnValue\":true,\"running\":false}");} static bool stop(void *h,void *m,void *ctx){
(void)ctx;calibration_until=0;retry_pause(&retries);
bool bridging=bridge_phase!=0;stream_close(milliseconds());bridge_phase=0;
memset(&continuity,0,sizeof(continuity));stop_capture();
if(bridging){
output=target_connect(&target);
if(output>=0){if(target_login())send_line("{\"command\":\"clear\",\"priority\":149}\n");close(output);output=-1;}
}
return respond(h,m,"{\"returnValue\":true,\"running\":false}");
}
static bool autostart(void *h,void *m,void *ctx){ static bool autostart(void *h,void *m,void *ctx){
(void)ctx;const char *p=m?payload(m):NULL;bool enabled; (void)ctx;const char *p=m?payload(m):NULL;bool enabled;
if(p && strcmp(p,"{\"enabled\":true}")==0)enabled=true; if(p && strcmp(p,"{\"enabled\":true}")==0)enabled=true;
else if(p && strcmp(p,"{\"enabled\":false}")==0)enabled=false; else if(p && strcmp(p,"{\"enabled\":false}")==0)enabled=false;
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}"); else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}");
if(geteuid()!=0 || !set_boot(enabled))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Autostart could not be saved. Check own service root elevation and boot hook.\"}"); if(geteuid()!=0)return respond(h,m,"{\"returnValue\":false,\"rootRequired\":true,\"errorText\":\"Root access is required to change autostart. Select Enable Root Access in Overview.\"}");
if(!set_boot(enabled))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot update the HyperDRM boot hook. Check the installed boot script and whether another file occupies the hook path.\"}");
if(enabled)retry_reset(&retries); if(enabled)retry_reset(&retries);
retry_update(&retries,enabled,capture!=NULL,milliseconds()); retry_update(&retries,enabled,capture!=NULL,milliseconds());
return respond(h,m,enabled?"{\"returnValue\":true,\"autostart\":true}":"{\"returnValue\":true,\"autostart\":false}"); return respond(h,m,enabled?"{\"returnValue\":true,\"autostart\":true}":"{\"returnValue\":true,\"autostart\":false}");
} }
static bool restart_service(void *h,void *m,void *ctx){
(void)ctx;if(strcmp(payload(m),"{}"))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Restart expects an empty object\"}");
bool ok=respond(h,m,"{\"returnValue\":true,\"restarting\":true}");quitting=1;return ok;
}
static bool handover(void *h,void *m,void *ctx){ static bool handover(void *h,void *m,void *ctx){
(void)ctx;const char *p=payload(m);bool enabled; (void)ctx;const char *p=payload(m);bool enabled;
if(strcmp(p,"{\"enabled\":true}")==0)enabled=true; if(strcmp(p,"{\"enabled\":true}")==0)enabled=true;
@@ -182,7 +264,7 @@ static bool handover(void *h,void *m,void *ctx){
if(!playback_json.lib || geteuid()!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Root service and JSON-C are required\"}"); if(!playback_json.lib || geteuid()!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Root service and JSON-C are required\"}");
if(enabled && !piccap_installed())return respond(h,m,"{\"returnValue\":false,\"errorText\":\"PicCap is not installed\"}"); if(enabled && !piccap_installed())return respond(h,m,"{\"returnValue\":false,\"errorText\":\"PicCap is not installed\"}");
if(!handover_save(enabled))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save PicCap handover preference\"}"); if(!handover_save(enabled))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save PicCap handover preference\"}");
handover_enabled=enabled;protection=PLAYBACK_UNKNOWN;clear_samples=0;next_media_probe=0; handover_enabled=enabled;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=0;next_media_probe=0;
return respond(h,m,"{\"returnValue\":true}"); return respond(h,m,"{\"returnValue\":true}");
} }
static bool settings(void *h,void *m,void *ctx){ static bool settings(void *h,void *m,void *ctx){
@@ -190,7 +272,7 @@ static bool settings(void *h,void *m,void *ctx){
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(!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\"}"); 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; bool active=capture || piccap_owned || retries.due;
stop_capture();test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0; stop_capture();stream_close(milliseconds());bridge_phase=0;memset(&continuity,0,sizeof(continuity));test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=0;
if(active && !retries.paused && !retries.blocked)retries.due=milliseconds(); if(active && !retries.paused && !retries.blocked)retries.due=milliseconds();
return respond(h,m,"{\"returnValue\":true}"); return respond(h,m,"{\"returnValue\":true}");
} }
@@ -201,12 +283,21 @@ static bool test_connection(void *h,void *m,void *ctx){
return respond(h,m,"{\"returnValue\":true,\"testing\":true}"); return respond(h,m,"{\"returnValue\":true,\"testing\":true}");
} }
static bool status(void *h,void *m,void *ctx){ static bool status(void *h,void *m,void *ctx){
(void)ctx;char json[4096],safe[512],endpoint[80];size_t n=0; (void)ctx;char json[8192],safe[512],endpoint[80];size_t n=0;
snprintf(endpoint,sizeof(endpoint),strchr(target.host,':')?"[%s]:%u":"%s:%u",target.host,target.port); 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; 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;
uint64_t now=milliseconds(); uint64_t now=milliseconds();
int used=snprintf(json,sizeof(json),"{\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\",\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",",target.piccap_port,test_stage_name(),connection_test.leds,(unsigned long long)connection_test.elapsed,connection_test.error,last_output_at?(long long)(now-last_output_at):-1,(unsigned long long)(retries.due>now?retries.due-now:0),capture_source==SOURCE_PICCAP_STARTING?"Starting PicCap":capture_source==SOURCE_PICCAP_STOPPING?"Stopping PicCap":piccap_owned?"PicCap":capture?"Histogram":"Stopped",piccap_error); char report[512];size_t k=0;for(size_t i=0;calibration_message[i] && k+2<sizeof(report);i++){char c=calibration_message[i];if(c=='"'||c=='\\')report[k++]='\\';if((unsigned char)c>=32)report[k++]=c;}report[k]=0;
used+=snprintf(json+used,sizeof(json)-used,"\"version\":\"0.9.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_compatible(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe); int used=snprintf(json,sizeof(json),"{\"calibrationProfile\":\"%s\",\"effectiveCalibration\":\"%s\",\"tvModel\":\"%s\",\"customModel\":\"%s\",\"customPoints\":[",calibration_key(calibration.kind),calibration_key(calibration_effective()),tv_model,calibration.model);
for(unsigned i=0;i<calibration.count;i++)used+=snprintf(json+used,sizeof(json)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",calibration.points[i].position,calibration.points[i].region);
used+=snprintf(json+used,sizeof(json)-used,"],\"layoutUpdates\":%u,\"lastLayoutCheckAgeMs\":%lld,\"layoutError\":\"%s\",\"piccapFallbacks\":%u,\"piccapCooldownMs\":%llu,",layout_updates,last_layout_check?(long long)(now-last_layout_check):-1,layout_error,piccap_fallbacks,(unsigned long long)(handover_cooldown>now?handover_cooldown-now:0));
used+=snprintf(json+used,sizeof(json)-used,"\"calibrationMessage\":\"%s\",",report);
used+=snprintf(json+used,sizeof(json)-used,"\"calibrationDraft\":{\"model\":\"%s\",\"points\":[",calibration_draft.model);
for(unsigned i=0;i<calibration_draft.count;i++)used+=snprintf(json+used,sizeof(json)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",calibration_draft.points[i].position,calibration_draft.points[i].region);
used+=snprintf(json+used,sizeof(json)-used,"]},\"receiverStreamConnected\":%s,",stream.stage==4?"true":"false");
used+=snprintf(json+used,sizeof(json)-used,"\"sampleSequence\":%u,\"sampleAgeMs\":%lld,\"holdingColors\":%s,\"sourceFade\":%s,\"calibrating\":%s,",sample_sequence,sample_at?(long long)(now-sample_at):-1,continuity.holding?"true":"false",(continuity.fading || bridge_phase==3)?"true":"false",CALIBRATION_ACTIVE?"true":"false");
used+=snprintf(json+used,sizeof(json)-used,"\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\",\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",",target.piccap_port,test_stage_name(),connection_test.leds,(unsigned long long)connection_test.elapsed,connection_test.error,last_output_at?(long long)(now-last_output_at):-1,(unsigned long long)(retries.due>now?retries.due-now:0),capture_source==SOURCE_PICCAP_STARTING?"Starting PicCap":capture_source==SOURCE_PICCAP_STOPPING?"Stopping PicCap":piccap_owned?"PicCap":capture?"Histogram":"Stopped",piccap_error);
used+=snprintf(json+used,sizeof(json)-used,"\"version\":\"0.11.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_compatible(&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]); 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"); 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]); for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s%u",i?",":"",black_percent[i]);
@@ -214,8 +305,11 @@ static bool status(void *h,void *m,void *ctx){
} }
static int tick(void *unused){ static int tick(void *unused){
(void)unused; (void)unused;
test_tick(&playback_json,milliseconds()); uint64_t now=milliseconds();
if(handover_tick())return 1; model_tick(now);test_tick(&playback_json,now);layout_refresh_tick(&playback_json,now,capture || capture_source==SOURCE_PICCAP);
stream_tick(&playback_json,now,handover_enabled && !retries.paused && !retries.blocked);
bool owned=handover_tick();bridge_tick(now);
if(owned)return 1;
if(!capture){ if(!capture){
retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds()); retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds());
if(retry_take(&retries,milliseconds())){ if(retry_take(&retries,milliseconds())){
@@ -226,12 +320,22 @@ static int tick(void *unused){
} }
if(revoked){set_error("LG revoked capture; recovering after source transition");capture_recoveries++;recovery_started=milliseconds();release_capture(false);retry_source_transition(&retries,milliseconds());return 1;} if(revoked){set_error("LG revoked capture; recovering after source transition");capture_recoveries++;recovery_started=milliseconds();release_capture(false);retry_source_transition(&retries,milliseconds());return 1;}
if(settling){settling--;return 1;} if(settling){settling--;return 1;}
if(letterbox_mode && capture_preset==1 && milliseconds()>=next_border_probe){ if(!CALIBRATION_ACTIVE && letterbox_mode && capture_preset==1 && milliseconds()>=next_border_probe){
if(vt_preset(capture,0)!=0){set_error("Border probe preset failed");stop_capture();return 1;} if(vt_preset(capture,0)!=0){set_error("Border probe preset failed");stop_capture();return 1;}
capture_preset=0;settling=2;return 1; capture_preset=0;settling=2;return 1;
} }
Histogram hist[16]={0};int rc=vt_get(capture,hist); Histogram hist[16]={0};int rc=vt_get(capture,hist);
if(rc){set_error("LG histogram read failed; streaming stopped");stop_capture();return 1;} bool populated=!rc && histogram_populated(hist);
if(!populated){
unsigned char held[16][3];
color_valid=false;
if(!continuity_frame(&continuity,false,rgb,now,held)){set_error("Histogram data unavailable beyond hold window; recovering capture");release_capture(false);retry_source_transition(&retries,now);return 1;}
char *line=region_command(held,led_blends,led_count,(unsigned)(1500-(now-continuity.missing_at)));
if(!line || send_line(line)){free(line);set_error("Receiver connection lost while holding colors");stop_capture();return 1;}free(line);
if(!receiver_ack()){set_error("Receiver did not acknowledge held colors");stop_capture();return 1;}
last_output_at=now;return 1;
}
sample_at=now;sample_sequence++;
unsigned aggregate[3]={0}; unsigned aggregate[3]={0};
for(int i=0;i<16;i++){ for(int i=0;i<16;i++){
black_percent[i]=100; black_percent[i]=100;
@@ -245,7 +349,7 @@ static int tick(void *unused){
for(int i=0;i<16;i++)for(int c=0;c<3;c++){ for(int i=0;i<16;i++)for(int c=0;c<3;c++){
rgb[i][c]=histogram_mean(hist[i].bins[c]);aggregate[c]+=rgb[i][c]; rgb[i][c]=histogram_mean(hist[i].bins[c]);aggregate[c]+=rgb[i][c];
} }
if(capture_preset==0){ if(!CALIBRATION_ACTIVE && capture_preset==0){
int candidate=letterbox_candidate(black_percent,rgb);uint64_t now=milliseconds(); int candidate=letterbox_candidate(black_percent,rgb);uint64_t now=milliseconds();
if(candidate<0)pending_letterbox=-1; if(candidate<0)pending_letterbox=-1;
else if(candidate!=pending_letterbox){pending_letterbox=candidate;candidate_since=now;} else if(candidate!=pending_letterbox){pending_letterbox=candidate;candidate_since=now;}
@@ -257,24 +361,15 @@ static int tick(void *unused){
} }
for(int c=0;c<3;c++)sent_rgb[c]=(unsigned char)(aggregate[c]/16); for(int c=0;c<3;c++)sent_rgb[c]=(unsigned char)(aggregate[c]/16);
unsigned char boosted_mean[3];boost_color(sent_rgb,boosted_mean);memcpy(sent_rgb,boosted_mean,3); unsigned char boosted_mean[3];boost_color(sent_rgb,boosted_mean);memcpy(sent_rgb,boosted_mean,3);
bool image_output=strcmp(target.receiver,"HyperHDR")==0; unsigned char mapped_rgb[16][3],stable_rgb[16][3];calibration_colors(rgb,mapped_rgb);
size_t capacity=256+(size_t)led_count*12;char *line=image_output?spatial_image_command(rgb,PRIORITY):malloc(capacity); continuity_frame(&continuity,true,mapped_rgb,now,stable_rgb);
interpolation_enabled=led_count && calibration_effective()!=CAL_AVERAGE;
char *line=region_command(stable_rgb,led_blends,led_count,1500);
if(!line){set_error("Color buffer allocation failed");stop_capture();return 1;} if(!line){set_error("Color buffer allocation failed");stop_capture();return 1;}
if(!image_output){
size_t written=(size_t)snprintf(line,capacity,"{\"command\":\"color\",\"priority\":%d,\"duration\":1500,\"origin\":\"HyperDRM\",\"color\":[",PRIORITY);
for(unsigned i=0;i<led_count;i++){
unsigned char interpolated[3],boosted[3];LedBlend blend=led_blends[i];
blend_color(rgb[blend.a],rgb[blend.b],blend.weight,interpolated);boost_color(interpolated,boosted);
written+=(size_t)snprintf(line+written,capacity-written,"%s%u,%u,%u",i?",":"",boosted[0],boosted[1],boosted[2]);
}
snprintf(line+written,capacity-written,"]}\n");
}
int failed=send_line(line);free(line); int failed=send_line(line);free(line);
if(failed){set_error("Receiver 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; if(!receiver_ack()){set_error("Receiver did not acknowledge color data");stop_capture();return 1;}
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;}} last_error[0]=0;
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;last_output_at=milliseconds();frames++;return 1; color_valid=true;last_output_at=milliseconds();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;} 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;}
@@ -286,7 +381,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"); 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"); call_once=symbol(ls,"LSCallOneReply");
cancel_call=symbol(ls,"LSCallCancel"); cancel_call=symbol(ls,"LSCallCancel");
if(playback_json_init(&playback_json)){handover_load();target_load(&playback_json,TARGET_PREF,&target);} if(playback_json_init(&playback_json)){handover_load();target_load(&playback_json,TARGET_PREF,&target);calibration_load(&playback_json,CALIBRATION_PREF);}
reply=symbol(ls,"LSMessageReply");payload=symbol(ls,"LSMessageGetPayload");error_init=symbol(ls,"LSErrorInit");error_free=symbol(ls,"LSErrorFree"); 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 (*reg)(const char*,void**,LSError*)=symbol(ls,"LSRegister");
bool (*category)(void*,const char*,Method*,void*,void*,LSError*)=symbol(ls,"LSRegisterCategory"); bool (*category)(void*,const char*,Method*,void*,void*,LSError*)=symbol(ls,"LSRegisterCategory");
@@ -297,13 +392,13 @@ int main(void){
unsigned (*timer)(unsigned,int(*)(void*),void*)=symbol(glib,"g_timeout_add"); unsigned (*timer)(unsigned,int(*)(void*),void*)=symbol(glib,"g_timeout_add");
void (*loop_unref)(void*)=symbol(glib,"g_main_loop_unref"); void (*loop_unref)(void*)=symbol(glib,"g_main_loop_unref");
void *handle=NULL,*loop=loop_new(NULL,0);LSError e;error_init(&e); 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},{"settings",settings,0},{"testConnection",test_connection,0},{NULL,NULL,0}}; Method methods[]={{"start",start,0},{"stop",stop,0},{"status",status,0},{"autostart",autostart,0},{"restartService",restart_service,0},{"handover",handover,0},{"settings",settings,0},{"testConnection",test_connection,0},{"calibration",set_calibration,0},{"calibrationSession",calibration_session,0},{"calibrationDraft",draft_calibration,0},{"calibrationResult",report_calibration,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;} 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; service_handle=handle;
if(!boot_enabled())retry_pause(&retries); if(!boot_enabled())retry_pause(&retries);
timer(100,tick,NULL); timer(100,tick,NULL);
while(!quitting){iterate(NULL,0);usleep(10000);} while(!quitting){iterate(NULL,0);usleep(10000);}
test_close();stop_capture(); stream_close(milliseconds());test_close();stop_capture();
if(piccap_owned){ if(piccap_owned){
handover_enabled=false;retry_pause(&retries); handover_enabled=false;retry_pause(&retries);
uint64_t deadline=milliseconds()+4000; uint64_t deadline=milliseconds()+4000;
+47
View File
@@ -0,0 +1,47 @@
#ifndef HYPERDRM_SOURCE_BRIDGE_H
#define HYPERDRM_SOURCE_BRIDGE_H
static unsigned bridge_phase,bridge_sequence;
static uint64_t bridge_started,bridge_clear_at,bridge_fade_at,bridge_last_sent;
static unsigned char bridge_from[16][3],bridge_to[16][3];
static CaptureSource bridge_source;
static char *region_command(const unsigned char colors[16][3],const LedBlend *mapping,unsigned count,unsigned duration){
if(!count || !mapping)return NULL;
if(!strcmp(target.receiver,"HyperHDR"))return spatial_image_command_duration(colors,PRIORITY,duration);
size_t capacity=256+(size_t)count*12;char *line=malloc(capacity);if(!line)return NULL;
size_t n=(size_t)snprintf(line,capacity,"{\"command\":\"color\",\"priority\":%d,\"duration\":%u,\"origin\":\"HyperDRM\",\"color\":[",PRIORITY,duration);
for(unsigned i=0;i<count;i++){unsigned char value[3];LedBlend b=mapping[i];blend_color(colors[b.a],colors[b.b],b.weight,value);n+=(size_t)snprintf(line+n,capacity-n,"%s%u,%u,%u",i?",":"",value[0],value[1],value[2]);}
snprintf(line+n,capacity-n,"]}\n");return line;
}
static bool bridge_clear(void){return stream.stage==4 && stream_queue("{\"command\":\"clear\",\"priority\":149}\n");}
static bool bridge_send(const unsigned char colors[16][3]){
if(stream.stage!=4)return false;
char *line=region_command(colors,stream.mapping,stream.count,1500);if(!line)return false;
bool ok=stream_queue(line);free(line);return ok;
}
static void bridge_tick(uint64_t now){
if(retries.paused || retries.blocked){if(bridge_phase)bridge_clear();bridge_phase=0;bridge_source=capture_source;return;}
if(capture_source!=bridge_source){
if(capture_source==SOURCE_PICCAP_STARTING){
bridge_phase=continuity.available && now-continuity.valid_at<2000?1:0;bridge_started=now;memcpy(bridge_from,continuity.last,sizeof(bridge_from));
}else if(capture_source==SOURCE_PICCAP_STOPPING || (bridge_source==SOURCE_PICCAP && capture_source==SOURCE_HISTOGRAM)){
bridge_phase=0;
if(stream.valid && now-stream.updated<1000){continuity_seed(&continuity,stream.colors,now);bridge_send(stream.colors);}
}
bridge_source=capture_source;
}
if(bridge_phase && now-bridge_started>=3000){if(bridge_clear())bridge_phase=0;return;}
if(bridge_phase==1 && capture_source==SOURCE_PICCAP && piccap_connected && stream.stage==4){
if(bridge_clear()){bridge_sequence=stream.sequence;bridge_clear_at=now;bridge_phase=2;}
}else if(bridge_phase==1 && now-bridge_last_sent>=100 && stream.stage==4){
if(bridge_send(bridge_from))bridge_last_sent=now;
}else if(bridge_phase==2 && stream.valid && stream.sequence>bridge_sequence && stream.updated>=bridge_clear_at+150){
memcpy(bridge_to,stream.colors,sizeof(bridge_to));bridge_fade_at=now;bridge_phase=3;
bool black=true;for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)if(bridge_to[r][c]>3)black=false;
if(black)bridge_phase=0;
}
if(bridge_phase==3 && now-bridge_last_sent>=100){
if(now-bridge_fade_at>=500){if(bridge_clear())bridge_phase=0;}
else {unsigned char colors[16][3];continuity_mix(bridge_from,bridge_to,(unsigned)(now-bridge_fade_at),colors);if(bridge_send(colors))bridge_last_sent=now;}
}
}
#endif
+6 -5
View File
@@ -9,16 +9,16 @@ enum {SPATIAL_WIDTH=160,SPATIAL_HEIGHT=90,SPATIAL_PIXELS=SPATIAL_WIDTH*SPATIAL_H
/* Synthetic color plane from histogram samples, never captured video pixels. /* Synthetic color plane from histogram samples, never captured video pixels.
* HyperHDR 22 treats JSON color as a solid color; image input retains space. * HyperHDR 22 treats JSON color as a solid color; image input retains space.
* The receiver samples this plane using its configured LED rectangles. */ * The receiver samples this plane using its configured LED rectangles. */
static char *spatial_image_command(const unsigned char regions[16][3],unsigned priority){ static char *spatial_image_command_duration(const unsigned char regions[16][3],unsigned priority,unsigned duration){
static LedBlend blends[SPATIAL_PIXELS];static bool initialized; static LedBlend blends[SPATIAL_PIXELS];static unsigned generation;
if(!initialized){ if(generation!=calibration_generation){
for(unsigned y=0;y<SPATIAL_HEIGHT;y++)for(unsigned x=0;x<SPATIAL_WIDTH;x++) for(unsigned y=0;y<SPATIAL_HEIGHT;y++)for(unsigned x=0;x<SPATIAL_WIDTH;x++)
blends[y*SPATIAL_WIDTH+x]=layout_blend((x+0.5)/SPATIAL_WIDTH,(y+0.5)/SPATIAL_HEIGHT); blends[y*SPATIAL_WIDTH+x]=layout_blend((x+0.5)/SPATIAL_WIDTH,(y+0.5)/SPATIAL_HEIGHT);
initialized=true; generation=calibration_generation;
} }
const char alphabet[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; const char alphabet[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
size_t capacity=SPATIAL_PIXELS*4+256;char *line=malloc(capacity);if(!line)return NULL; size_t capacity=SPATIAL_PIXELS*4+256;char *line=malloc(capacity);if(!line)return NULL;
size_t used=snprintf(line,capacity,"{\"command\":\"image\",\"priority\":%u,\"duration\":1500,\"origin\":\"HyperDRM\",\"imagewidth\":%u,\"imageheight\":%u,\"format\":\"rgb\",\"name\":\"Histogram colors\",\"imagedata\":\"",priority,SPATIAL_WIDTH,SPATIAL_HEIGHT); size_t used=snprintf(line,capacity,"{\"command\":\"image\",\"priority\":%u,\"duration\":%u,\"origin\":\"HyperDRM\",\"imagewidth\":%u,\"imageheight\":%u,\"format\":\"rgb\",\"name\":\"Histogram colors\",\"imagedata\":\"",priority,duration,SPATIAL_WIDTH,SPATIAL_HEIGHT);
for(unsigned i=0;i<SPATIAL_PIXELS;i++){ for(unsigned i=0;i<SPATIAL_PIXELS;i++){
unsigned char rgb[3],color[3];LedBlend b=blends[i];blend_color(regions[b.a],regions[b.b],b.weight,rgb);boost_color(rgb,color); unsigned char rgb[3],color[3];LedBlend b=blends[i];blend_color(regions[b.a],regions[b.b],b.weight,rgb);boost_color(rgb,color);
line[used++]=alphabet[color[0]>>2];line[used++]=alphabet[((color[0]&3)<<4)|(color[1]>>4)]; line[used++]=alphabet[color[0]>>2];line[used++]=alphabet[((color[0]&3)<<4)|(color[1]>>4)];
@@ -26,4 +26,5 @@ static char *spatial_image_command(const unsigned char regions[16][3],unsigned p
} }
line[used++]='"';line[used++]='}';line[used++]='\n';line[used]=0;return line; line[used++]='"';line[used++]='}';line[used++]='\n';line[used]=0;return line;
} }
static inline char *spatial_image_command(const unsigned char regions[16][3],unsigned priority){return spatial_image_command_duration(regions,priority,1500);}
#endif #endif
+26
View File
@@ -0,0 +1,26 @@
#define _DEFAULT_SOURCE
#include <assert.h>
#include <string.h>
#include "layout.h"
int main(void){
(void)layout_from_json;
PlaybackJson json={0};assert(playback_json_init(&json));
assert(calibration_effective()==CAL_AVERAGE);
strcpy(tv_model,"OLED65G42LW.DEUQLJP");assert(calibration_effective()==CAL_G4);
assert(!calibration_is_g4("OLED65G52LW") && !calibration_is_g4("LG G4"));
Calibration next=calibration;
const char *custom="{\"profile\":\"custom\",\"model\":\"OLED65C42LA\",\"points\":[{\"position\":0,\"region\":2},{\"position\":2,\"region\":10}]}";
assert(calibration_parse(&json,custom,&next));calibration=next;calibration_generation++;
LedBlend b=layout_blend(.5,0);assert(b.a==2 && b.b==10 && b.weight>=16383 && b.weight<=16385);
const char *bad[]={"{}","{\"profile\":\"custom\",\"points\":null}","{\"profile\":\"other\"}","{\"profile\":\"custom\",\"model\":\"bad\\\"name\",\"points\":[]}","{\"profile\":\"custom\",\"model\":\"TV\",\"points\":[{\"position\":2,\"region\":1},{\"position\":1,\"region\":2}]}","{\"profile\":\"custom\",\"model\":\"TV\",\"points\":[{\"position\":0,\"region\":16},{\"position\":4,\"region\":2}]}"};
for(unsigned i=0;i<sizeof(bad)/sizeof(*bad);i++){Calibration before=next;assert(!calibration_parse(&json,bad[i],&next));assert(!memcmp(&before,&next,sizeof(next)));}
const char *path="/tmp/hyperdrm-calibration-test.json";assert(access(path,F_OK)!=0);assert(calibration_save(path,&next));
calibration=(Calibration){0};calibration_load(&json,path);assert(calibration.count==2 && !strcmp(calibration.model,"OLED65C42LA"));unlink(path);
assert(calibration_parse(&json,"{\"profile\":\"auto\"}",&calibration));assert(calibration_effective()==CAL_G4);
strcpy(tv_model,"OLED65C42LA");assert(calibration_effective()==CAL_CUSTOM);
strcpy(tv_model,"UNKNOWN-TV");assert(calibration_effective()==CAL_AVERAGE);
unsigned char input[16][3],output[16][3];for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)input[i][c]=i*10+c;
calibration_colors(input,output);for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)assert(output[i][c]==75+c);
calibration.kind=CAL_G4;calibration_colors(input,output);assert(!memcmp(input,output,48));
puts("PASS: G4 detection, custom model profile, rim mapping, invalid profile rejection, persistence and honest average fallback");return 0;
}
+27
View File
@@ -0,0 +1,27 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <string.h>
#include <stdio.h>
#include "histogram.h"
#include "continuity.h"
int main(void){
Histogram hist[16]={0};assert(!histogram_populated(hist));
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)hist[r].bins[c][0]=100;
assert(histogram_populated(hist));hist[4].bins[2][0]=0;assert(!histogram_populated(hist));
Continuity s={0};unsigned char red[16][3]={{0}},blue[16][3]={{0}},black[16][3]={{0}},out[16][3];
for(unsigned r=0;r<16;r++){red[r][0]=200;blue[r][2]=200;}
assert(!continuity_frame(&s,false,red,1000,out));
assert(continuity_frame(&s,true,red,1100,out));assert(!memcmp(out,red,sizeof(out)));
assert(continuity_frame(&s,false,black,1200,out));assert(!memcmp(out,red,sizeof(out)));
assert(continuity_frame(&s,false,black,2699,out));assert(!continuity_frame(&s,false,black,2700,out));
assert(continuity_frame(&s,true,blue,2800,out));
continuity_seed(&s,red,3000);continuity_frame(&s,true,blue,3100,out);assert(out[0][0]==200 && out[0][2]==0);
continuity_frame(&s,true,blue,3350,out);assert(out[0][0]==100 && out[0][2]==100);
continuity_frame(&s,true,blue,3600,out);assert(out[0][0]==0 && out[0][2]==200 && !s.fading);
continuity_seed(&s,red,4000);continuity_frame(&s,true,black,4100,out);assert(!memcmp(out,black,sizeof(out)) && !s.fading);
continuity_frame(&s,false,red,4200,out);assert(!memcmp(out,black,sizeof(out)));
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++)black[r][c]=2;
continuity_seed(&s,red,4300);continuity_frame(&s,true,black,4400,out);assert(out[0][0]==2 && out[0][1]==2 && !s.fading);
puts("PASS: empty data versus populated black, bounded hold, recovery fade, immediate real black");return 0;
}
+52 -38
View File
@@ -12,7 +12,7 @@ static uint64_t clock_ms=10000;
static unsigned stopped,called,canceled; static unsigned stopped,called,canceled;
static char last_uri[128],last_data[256]; static char last_uri[128],last_data[256];
static uint64_t milliseconds(void){return clock_ms;} static uint64_t milliseconds(void){return clock_ms;}
static void stop_capture(void){stopped++;} static void release_capture(bool clear){(void)clear;stopped++;}
static const char *payload(void *m){return m;} static const char *payload(void *m){return m;}
static void error_init(LSError *e){(void)e;} static void error_init(LSError *e){(void)e;}
static void error_free(LSError *e){(void)e;} static void error_free(LSError *e){(void)e;}
@@ -20,50 +20,64 @@ static void error_free(LSError *e){(void)e;}
#include "target.h" #include "target.h"
#include "handover.h" #include "handover.h"
static bool mock_call(void *h,const char *uri,const char *data,Callback cb,void *ctx,unsigned long *token,LSError *e){ static bool mock_call(void *h,const char *uri,const char *data,Callback cb,void *ctx,unsigned long *token,LSError *e){
(void)h;snprintf(last_data,sizeof(last_data),"%s",data);(void)cb;(void)ctx;(void)e;*token=++called;snprintf(last_uri,sizeof(last_uri),"%s",uri);return true; (void)h;(void)cb;(void)ctx;(void)e;*token=++called;
snprintf(last_uri,sizeof(last_uri),"%s",uri);snprintf(last_data,sizeof(last_data),"%s",data);return true;
} }
static bool mock_cancel(void *h,unsigned long token,LSError *e){(void)h;(void)token;(void)e;canceled++;return true;} static bool mock_cancel(void *h,unsigned long token,LSError *e){(void)h;(void)token;(void)e;canceled++;return true;}
static void configured(void){ static void media(PlaybackProtection state){
piccap_reply(NULL,"{\"returnValue\":true,\"address\":\"127.0.0.1\",\"port\":19400,\"unix-socket\":false}",(void*)(intptr_t)3); const char *json=state==PLAYBACK_CLEAR?"[{\"type\":\"media\",\"is_foreground\":true,\"resource\":[{\"resource\":\"VDEC\"}]}]":state==PLAYBACK_PROTECTED?"[{\"resource\":[{\"resource\":\"SVP_CPB\"}]}]":"[]";
assert(strstr(last_uri,"/start")); media_reply(NULL,(void*)json,NULL);
} }
static void answer(int cmd,bool running){ static void answer(int command,bool running,bool connected){
char json[128];snprintf(json,sizeof(json),"{\"returnValue\":true,\"isRunning\":%s,\"connected\":true}",running?"true":"false"); char json[128];snprintf(json,sizeof(json),"{\"returnValue\":true,\"isRunning\":%s,\"connected\":%s}",running?"true":"false",connected?"true":"false");
piccap_reply(NULL,json,(void*)(intptr_t)cmd); piccap_reply(NULL,json,(void*)(intptr_t)command);
}
static void confirm_stop(void){answer(2,true,false);assert(piccap_owned);answer(0,false,false);assert(!piccap_owned && capture_source==SOURCE_HISTOGRAM);}
static void stable_clear(void){
media(PLAYBACK_UNKNOWN);media(PLAYBACK_CLEAR);assert(!handover_tick());clock_ms+=1000;
media(PLAYBACK_CLEAR);assert(!handover_tick());clock_ms+=1000;
media(PLAYBACK_CLEAR);assert(handover_tick());assert(strstr(last_uri,"/getSettings"));
}
static void connected_start(void){
piccap_reply(NULL,"{\"returnValue\":true,\"address\":\"127.0.0.1\",\"port\":19400,\"unix-socket\":false}",(void*)(intptr_t)3);
assert(strstr(last_uri,"/start"));answer(1,false,false);clock_ms+=600;
media(PLAYBACK_CLEAR);handover_tick();answer(0,true,true);assert(capture_source==SOURCE_PICCAP);
} }
int main(void){ int main(void){
(void)target_save;(void)target_load;(void)target_connect; (void)target_save;(void)target_load;(void)target_connect;(void)handover_save;(void)handover_load;
(void)handover_save;(void)handover_load;
(void)retry_failure;(void)retry_take;(void)retry_update; (void)retry_failure;(void)retry_take;(void)retry_update;
assert(playback_json_init(&playback_json));call_once=mock_call;cancel_call=mock_cancel; assert(playback_json_init(&playback_json));call_once=mock_call;cancel_call=mock_cancel;
handover_enabled=true;protection=PLAYBACK_PROTECTED;protection_at=clock_ms;next_media_probe=UINT64_MAX; handover_enabled=true;next_media_probe=UINT64_MAX;
assert(!handover_tick());assert(called==0 && stopped==0); media(PLAYBACK_PROTECTED);assert(!handover_tick() && called==0);
protection=PLAYBACK_CLEAR;clear_samples=1;assert(!handover_tick());assert(called==0); stable_clear();assert(stopped==1);connected_start();
clear_samples=2;assert(handover_tick());assert(stopped==1 && strstr(last_uri,"/getSettings")); media(PLAYBACK_UNKNOWN);assert(handover_tick() && capture_source==SOURCE_PICCAP);
configured();answer(1,false);clock_ms+=600;assert(handover_tick());assert(strstr(last_uri,"/status"));answer(0,true); clock_ms+=1000;assert(handover_tick() && capture_source==SOURCE_PICCAP);answer(0,true,true);
assert(capture_source==SOURCE_PICCAP && piccap_connected); media(PLAYBACK_CLEAR);assert(handover_tick() && capture_source==SOURCE_PICCAP);
protection=PLAYBACK_PROTECTED;assert(handover_tick());assert(strstr(last_uri,"/stop")); media(PLAYBACK_UNKNOWN);clock_ms+=100;media(PLAYBACK_PROTECTED);
answer(2,true);assert(strstr(last_uri,"/status"));assert(piccap_owned);answer(0,false); assert(handover_tick() && capture_source==SOURCE_PICCAP_STOPPING && strstr(last_uri,"/stop"));confirm_stop();
assert(!piccap_owned && capture_source==SOURCE_HISTOGRAM && retries.due==clock_ms); assert(retries.due==clock_ms);clock_ms+=3001;handover_cooldown=0;stable_clear();connected_start();
protection=PLAYBACK_CLEAR;protection_at=clock_ms;clear_samples=2;assert(handover_tick());configured();answer(1,true); media(PLAYBACK_UNKNOWN);clock_ms+=1600;handover_tick();assert(capture_source==SOURCE_PICCAP_STOPPING);confirm_stop();
retry_pause(&retries);assert(handover_tick());assert(strstr(last_uri,"/stop"));answer(2,false);answer(0,false); clock_ms+=3001;handover_cooldown=0;stable_clear();connected_start();
assert(!piccap_owned && retries.due==0);assert(!handover_tick()); answer(0,true,false);clock_ms+=1000;media(PLAYBACK_CLEAR);handover_tick();answer(0,true,true);
retry_reset(&retries);assert(handover_tick());configured();answer(1,true);handover_enabled=false; assert(!piccap_disconnected_since && piccap_fallbacks==0);
assert(handover_tick());answer(2,false);answer(0,false);assert(!piccap_owned); answer(0,true,false);clock_ms+=2500;media(PLAYBACK_CLEAR);handover_tick();answer(0,true,false);
handover_enabled=true;protection_at=clock_ms;assert(handover_tick()); clock_ms+=2501;media(PLAYBACK_CLEAR);handover_tick();
clock_ms+=3100;assert(handover_tick());assert(canceled==1 && capture_source==SOURCE_PICCAP_STOPPING); assert(capture_source==SOURCE_PICCAP_STOPPING && piccap_fallbacks==1 && strstr(piccap_error,"disconnected"));confirm_stop();
assert(strstr(last_uri,"/stop"));answer(2,false);answer(0,false); assert(retries.due==clock_ms);clock_ms+=2000;media(PLAYBACK_CLEAR);assert(!handover_tick());
assert(!piccap_owned);assert(!handover_tick()); clock_ms=handover_cooldown+1;media(PLAYBACK_CLEAR);assert(handover_tick());connected_start();
retries.paused=false;handover_cooldown=0;protection=PLAYBACK_CLEAR;protection_at=clock_ms;clear_samples=2; retry_pause(&retries);handover_tick();assert(capture_source==SOURCE_PICCAP_STOPPING);confirm_stop();assert(!retries.due);
retry_reset(&retries);clock_ms+=3001;handover_cooldown=0;stable_clear();
strcpy(target.host,"10.1.0.100");target.piccap_port=19446; strcpy(target.host,"10.1.0.100");target.piccap_port=19446;
assert(handover_tick());assert(strstr(last_uri,"/getSettings")); handover_tick();assert(capture_source==SOURCE_PICCAP_STOPPING);answer(3,true,false);handover_tick();confirm_stop();
clock_ms+=3001;handover_cooldown=0;stable_clear();
piccap_reply(NULL,"{\"returnValue\":true,\"address\":\"127.0.0.1\",\"port\":19400,\"unix-socket\":false}",(void*)(intptr_t)3); piccap_reply(NULL,"{\"returnValue\":true,\"address\":\"127.0.0.1\",\"port\":19400,\"unix-socket\":false}",(void*)(intptr_t)3);
assert(strstr(last_uri,"/setSettings"));assert(!strcmp(last_data,"{\"address\":\"10.1.0.100\",\"port\":19446,\"unix-socket\":false}")); assert(strstr(last_uri,"/setSettings") && !strcmp(last_data,"{\"address\":\"10.1.0.100\",\"port\":19446,\"unix-socket\":false}"));
answer(4,true);clock_ms+=600;handover_tick();answer(0,true);assert(capture_source==SOURCE_PICCAP); answer(4,true,false);clock_ms+=600;media(PLAYBACK_CLEAR);handover_tick();answer(0,true,true);
target.piccap_port=19400;assert(handover_tick());assert(capture_source==SOURCE_PICCAP_STOPPING);answer(2,false);answer(0,false); handover_enabled=false;handover_tick();confirm_stop();handover_enabled=true;handover_cooldown=0;stable_clear();
handover_cooldown=0;protection_at=clock_ms;assert(handover_tick()); clock_ms+=3100;handover_tick();assert(canceled==1 && capture_source==SOURCE_PICCAP_STOPPING);confirm_stop();
piccap_reply(NULL,"{\"returnValue\":false}",(void*)(intptr_t)3); handover_cooldown=0;clock_ms+=3001;stable_clear();
assert(capture_source==SOURCE_PICCAP_STOPPING && piccap_error[0]); piccap_reply(NULL,"{\"returnValue\":false}",(void*)(intptr_t)3);assert(capture_source==SOURCE_PICCAP_STOPPING && piccap_error[0]);
handover_tick();answer(2,false);answer(0,false);assert(!piccap_owned); clock_ms+=600;handover_tick();confirm_stop();
puts("PicCap handover tests passed: source ownership, remote endpoint, target change and configuration failure");return 0; strcpy(target.host,"2001:db8::1");handover_cooldown=0;media(PLAYBACK_CLEAR);clock_ms+=2000;media(PLAYBACK_CLEAR);assert(!handover_tick());
puts("PASS: stable clear gate, bounded pause grace, immediate DRM fallback, disconnect/reconnect and cooldown, exclusive capture, custom targets and failures");return 0;
} }
+35
View File
@@ -0,0 +1,35 @@
#define _DEFAULT_SOURCE
#include <assert.h>
#include <stdint.h>
#include <string.h>
#include "target.h"
#include "layout.h"
#include "diagnostics.h"
static unsigned led_count;
static LedBlend *led_blends;
#include "layout_refresh.h"
static void completed(unsigned count,unsigned generation){
layout_probe.mapping=calloc(count,sizeof(LedBlend));assert(layout_probe.mapping);
for(unsigned i=0;i<count;i++)layout_probe.mapping[i]=layout_blend((i+.5)/count,0);
layout_probe.leds=count;layout_probe.stage=TEST_DONE;layout_probe_generation=generation;
}
int main(void){
(void)target_connect;(void)target_load;(void)target_save;
PlaybackJson json={0};assert(playback_json_init(&json));
calibration.kind=CAL_G4;next_layout_check=UINT64_MAX;
completed(50,calibration_generation);layout_refresh_tick(&json,1000,true);
assert(led_count==50 && layout_updates==1 && last_layout_check==1000);
completed(500,calibration_generation);layout_refresh_tick(&json,2000,true);
assert(led_count==500 && layout_updates==2 && last_layout_check==2000);
completed(500,calibration_generation);layout_refresh_tick(&json,3000,true);assert(layout_updates==2);
completed(500,calibration_generation);layout_probe.mapping[42].weight^=512;
layout_refresh_tick(&json,4000,true);assert(layout_updates==3);
LedBlend *previous=led_blends;layout_probe.stage=TEST_FAILED;strcpy(layout_probe.error,"Invalid layout");
layout_refresh_tick(&json,5000,true);assert(led_blends==previous && led_count==500 && layout_updates==3 && layout_error[0]);
completed(50,calibration_generation);calibration_generation++;
layout_refresh_tick(&json,6000,true);assert(led_count==500 && led_blends==previous);assert(probe_busy(&layout_probe));
layout_refresh_tick(&json,6100,false);assert(!probe_busy(&layout_probe) && layout_probe.fd==-1);
assert(led_count==500 && led_blends==previous);
layout_refresh_reset(7000);free(led_blends);test_close();
puts("PASS: automatic 50/500 LED replacement, same-count position changes, unchanged layout, invalid reply retention, obsolete profile result and cancellation");return 0;
}
+39
View File
@@ -0,0 +1,39 @@
#define _DEFAULT_SOURCE
#include <assert.h>
#include <stdint.h>
#include <sys/wait.h>
#include "target.h"
#include "layout.h"
#include "receiver_stream.h"
static void fixture(PlaybackJson *json,bool modern,bool login){
int server=socket(AF_INET,SOCK_STREAM,0);assert(server>=0);
struct sockaddr_in address={.sin_family=AF_INET,.sin_addr={htonl(INADDR_LOOPBACK)}};
assert(!bind(server,(void*)&address,sizeof(address)) && !listen(server,1));socklen_t size=sizeof(address);assert(!getsockname(server,(void*)&address,&size));
target.port=ntohs(address.sin_port);snprintf(target.token,sizeof(target.token),"%s",login?"fixture-token":"");
pid_t child=fork();assert(child>=0);
if(!child){
int fd=accept(server,NULL,NULL);if(fd<0)_exit(1);FILE *input=fdopen(dup(fd),"r");char request[512];
if(login){if(!fgets(request,sizeof(request),input) || !strstr(request,"authorize"))_exit(2);write(fd,"{\"success\":true}\n",17);}
if(!fgets(request,sizeof(request),input) || !strstr(request,"serverinfo"))_exit(3);
const char *layout="{\"success\":true,\"info\":{\"leds\":[{\"hmin\":0,\"hmax\":1,\"vmin\":0,\"vmax\":0.1}]}}\n";write(fd,layout,strlen(layout));
if(!fgets(request,sizeof(request),input) || !strstr(request,"ledstream-start"))_exit(4);
const char *reply=modern?"{\"success\":true,\"command\":\"ledcolors-ledstream-update\",\"data\":{\"leds\":[20,30,40]}}\n":"{\"success\":true,\"command\":\"ledcolors-ledstream-update\",\"result\":{\"leds\":[20,30,40]}}\n";
write(fd,reply,13);usleep(20000);write(fd,reply+13,strlen(reply)-13);usleep(500000);close(fd);fclose(input);close(server);_exit(0);
}
stream_close(0);stream.retry_at=0;
for(unsigned i=0;i<500 && !stream.valid;i++){stream_tick(json,1000+i*5,true);usleep(1000);}
assert(stream.valid && stream.sequence==1 && stream.count==1);
for(unsigned r=0;r<16;r++){assert(stream.colors[r][0]==20 && stream.colors[r][1]==30 && stream.colors[r][2]==40);}
void *bad=json->parse("{\"result\":{\"leds\":[0,0,999]}}");assert(!stream_colors(json,bad,2000));json->put(bad);assert(stream.colors[0][2]==40);
bad=json->parse("{\"data\":{\"leds\":[0,0,0]}}");assert(stream_colors(json,bad,2000));json->put(bad);assert(stream.colors[0][2]==0);
bad=json->parse("{\"data\":{\"leds\":[1,2,3,4,5,6]}}");assert(!stream_colors(json,bad,2000) && !stream.revision);json->put(bad);
stream_tick(json,2001,false);assert(stream.fd<0 && !stream.valid);int status;waitpid(child,&status,0);assert(WIFEXITED(status) && !WEXITSTATUS(status));close(server);
}
int main(void){
PlaybackJson json={0};assert(playback_json_init(&json));fixture(&json,false,false);fixture(&json,true,true);
stream_start(1000);stream_tick(&json,5000,true);assert(stream.fd<0);stream_close(0);
TargetConfig copy=target;assert(target_valid(&copy));assert(target_piccap_compatible(&copy));assert(!target_piccap_local(&copy));assert(target_piccap_host(&copy));
assert(!target_parse(&json,"{}",&copy));assert(!target_save("/no/such/profile",&copy));assert(!target_load(&json,"/no/such/profile",&copy));
int fd=target_connect(&copy);if(fd>=0)close(fd);
dlclose(json.lib);puts("PASS: fragmented raw LED subscription, token login, old/new envelopes, invalid samples, real black, timeout and cleanup");return 0;
}
+43
View File
@@ -0,0 +1,43 @@
#include <assert.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "histogram.h"
#include "continuity.h"
#include "interpolation.h"
#define PRIORITY 149
typedef struct {unsigned char a,b;unsigned weight;} LedBlend;
typedef enum {SOURCE_HISTOGRAM,SOURCE_PICCAP_STARTING,SOURCE_PICCAP,SOURCE_PICCAP_STOPPING} CaptureSource;
static CaptureSource capture_source;
static struct {bool paused,blocked;} retries;
static struct {char receiver[16];} target={"Hyperion"};
static struct {int stage;bool valid;uint64_t updated;unsigned sequence,count;LedBlend *mapping;unsigned char colors[16][3];} stream;
static Continuity continuity;
static bool piccap_connected;
static char *queued;
static bool stream_queue(const char *line){if(queued)return false;queued=strdup(line);return queued!=NULL;}
static char *spatial_image_command_duration(const unsigned char colors[16][3],unsigned priority,unsigned duration){(void)colors;(void)priority;(void)duration;return strdup("image");}
#include "source_bridge.h"
static void consume(void){free(queued);queued=NULL;}
int main(void){
LedBlend mapping={0,0,0};stream.mapping=&mapping;stream.count=1;stream.stage=4;
for(unsigned r=0;r<16;r++){continuity.last[r][0]=200;stream.colors[r][2]=200;}continuity.available=true;continuity.valid_at=1000;
capture_source=SOURCE_PICCAP_STARTING;bridge_tick(1000);assert(bridge_phase==1 && strstr(queued,"200,0,0"));consume();
capture_source=SOURCE_PICCAP;piccap_connected=true;bridge_tick(1200);assert(bridge_phase==2 && strstr(queued,"clear"));consume();
stream.valid=true;stream.sequence=1;stream.updated=1300;bridge_tick(1300);assert(bridge_phase==2);
stream.sequence=2;stream.updated=1400;bridge_tick(1400);assert(bridge_phase==3 && strstr(queued,"200,0,0"));consume();
bridge_tick(1650);assert(strstr(queued,"100,0,100"));consume();
bridge_tick(1900);assert(!bridge_phase && strstr(queued,"clear"));consume();
capture_source=SOURCE_PICCAP_STOPPING;stream.updated=2000;bridge_tick(2000);assert(continuity.fading && continuity.last[0][2]==200 && strstr(queued,"0,0,200"));consume();
capture_source=SOURCE_HISTOGRAM;bridge_tick(2100);assert(!queued);
capture_source=SOURCE_PICCAP_STARTING;continuity.valid_at=3000;bridge_tick(3000);consume();bridge_tick(6000);assert(!bridge_phase && strstr(queued,"clear"));consume();
capture_source=SOURCE_PICCAP_STARTING;bridge_source=SOURCE_HISTOGRAM;continuity.valid_at=7000;bridge_tick(7000);consume();retries.paused=true;bridge_tick(7100);assert(!bridge_phase && strstr(queued,"clear"));consume();
retries.paused=false;bridge_source=SOURCE_HISTOGRAM;continuity.valid_at=8000;capture_source=SOURCE_PICCAP_STARTING;bridge_tick(8000);consume();
capture_source=SOURCE_PICCAP;bridge_tick(8100);consume();stream.sequence++;stream.updated=8300;memset(stream.colors,0,sizeof(stream.colors));bridge_tick(8300);assert(!bridge_phase && !queued);
bridge_source=SOURCE_HISTOGRAM;capture_source=SOURCE_PICCAP_STARTING;continuity.valid_at=1000;bridge_tick(9000);assert(!bridge_phase && !queued);
stream.stage=1;bridge_phase=1;bridge_started=9000;bridge_tick(12000);assert(!queued);retries.paused=true;bridge_tick(12100);assert(!bridge_phase && !queued);
strcpy(target.receiver,"HyperHDR");char *line=region_command(stream.colors,&mapping,1,500);assert(!strcmp(line,"image"));free(line);assert(!region_command(stream.colors,NULL,0,500));
puts("PASS: both source transitions, fresh sample gate, fade midpoint/end, bounded timeout, manual-stop cleanup");return 0;
}
+7
View File
@@ -13,5 +13,12 @@ int main(void){
command=spatial_image_command(regions,149);encoded=strstr(command,"\"imagedata\":\"")+13; command=spatial_image_command(regions,149);encoded=strstr(command,"\"imagedata\":\"")+13;
assert(memcmp(encoded,encoded+(SPATIAL_WIDTH-1)*4,4)); assert(memcmp(encoded,encoded+(SPATIAL_WIDTH-1)*4,4));
assert(memcmp(encoded,encoded+(SPATIAL_PIXELS-SPATIAL_WIDTH)*4,4));free(command); assert(memcmp(encoded,encoded+(SPATIAL_PIXELS-SPATIAL_WIDTH)*4,4));free(command);
calibration=(Calibration){.kind=CAL_CUSTOM,.count=2,.points={{0,2},{2,10}}};calibration_generation++;
command=spatial_image_command(regions,149);encoded=strstr(command,"\"imagedata\":\"")+13;
const char alphabet[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
unsigned char expected[3],actual[3];LedBlend custom=layout_blend(.5/SPATIAL_WIDTH,.5/SPATIAL_HEIGHT);
blend_color(regions[custom.a],regions[custom.b],custom.weight,expected);
unsigned v[4];for(unsigned i=0;i<4;i++)v[i]=(unsigned)(strchr(alphabet,encoded[i])-alphabet);
actual[0]=(v[0]<<2)|(v[1]>>4);actual[1]=(v[1]<<4)|(v[2]>>2);actual[2]=(v[2]<<6)|v[3];assert(!memcmp(expected,actual,3));free(command);
puts("PASS: image command, exact neutral RGB base64, spatial edge differences and cached geometry");return 0; puts("PASS: image command, exact neutral RGB base64, spatial edge differences and cached geometry");return 0;
} }
+1 -1
View File
@@ -1 +1 @@
{"id": "org.webosbrew.histogramhyperion", "version": "0.9.0", "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.11.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"}
+45
View File
@@ -0,0 +1,45 @@
# TV model calibration profiles
The built-in LG G4 profile is measured. Other models do not have verified spatial
profiles yet. Auto uses uniform average colors on an unknown model, unless an
imported custom profile matches its exact reported model identifier.
To import measured data, select Custom in the TV app, paste the profile JSON,
and select Save profile. Auto subsequently uses the custom profile on that exact
model. One custom model profile is stored in the own service container. Importing
another replaces it; switching to Auto, G4 or Average preserves the imported data.
```json
{
"model": "EXACT-MODEL-IDENTIFIER-FROM-APP",
"points": [
{"position": 0.25, "region": 7},
{"position": 2.25, "region": 9}
]
}
```
This two-point example describes the file format, not a calibrated TV model.
Use 2-16 actual measured points sorted by position. Region indices are 0-15.
The clockwise perimeter position starts at the top-left corner: top 0-1, right
1-2, bottom 2-3, left 3-4. The final point interpolates back to the first.
Use the coordinate-ramp calibration video with full-screen playback, observe
the LG histogram region colors, and derive their measured perimeter positions.
Validate all four edges against the receiver LED output before sharing a model
profile. A model match alone does not establish correctness on another panel,
SoC or firmware; document that test context with contributed profiles.
Profiles only control spatial region assignment. Saturation, brightness and
smoothing remain receiver settings. Runtime selection invalidates the generated
HyperHDR geometry and refreshes the Hyperion LED mapping without capture restart.
## Guided measurement
The app's auto calibration uses an actual local video plane, not UI overlays.
It collects a black reference and fresh samples from 32 perimeter patches,
rejects global responses, and derives sorted positions from each region's local
response peak. A candidate must cover all four edges. The resulting JSON is a
proposal until Save profile is selected. Use Validate profile after saving and
check all four color quadrants before contributing the profile. Cancellation and
failed measurement preserve the saved profile.
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const assert=require('assert'),fs=require('fs'),vm=require('vm');
const c={};vm.createContext(c);vm.runInContext(fs.readFileSync('app/calibration.js','utf8'),c);
const baseline=Array(16).fill(10);
const patches=Array.from({length:32},(_,i)=>Array.from({length:16},(_,r)=>{
const center=r*2+.5,distance=Math.min(Math.abs(i-center),32-Math.abs(i-center));
return 10+80*Math.max(0,1-distance/2);
}));
const fit=c.calibrationFit(baseline,patches,'OTHER-TV');assert.equal(fit.points.length,16);
fit.points.forEach((p,i)=>{assert.equal(p.region,i);assert(Math.abs(p.position-(i*2+1)/8)<.05);});
assert.throws(()=>c.calibrationFit(baseline,Array.from({length:32},()=>Array(16).fill(90)),'OTHER-TV'),/reliable spatial/);
assert.throws(()=>c.calibrationFit(baseline,Array.from({length:32},()=>baseline),'OTHER-TV'),/reliable spatial/);
assert.throws(()=>c.calibrationFit(baseline,patches.slice(1),'OTHER-TV'),/Incomplete/);
assert.throws(()=>c.calibrationAverage([baseline,baseline]),/fresh/);
const nested=patches.map(p=>p.map((v,r)=>r===15?p[0]:v));
const reduced=c.calibrationFit(baseline,nested,'NESTED-TV');assert.equal(reduced.points.length,15);assert(!reduced.points.some(p=>p.region===15));
const duplicate=patches.map(p=>Array(16).fill(p[0]));assert.throws(()=>c.calibrationFit(baseline,duplicate,'DUPLICATE-TV'),/reliable spatial/);
assert.deepEqual(Array.from(c.calibrationAverage([baseline,baseline,baseline])),baseline);
console.log('PASS: measured edge positions, global/absent response rejection and fresh sample minimum');
+24
View File
@@ -0,0 +1,24 @@
'use strict';
const assert=require('assert'),fs=require('fs'),vm=require('vm');
const events={},windowEvents={},elements={};let inputFocuses=0;
const doc={activeElement:null,getElementById(id){return elements[id];},addEventListener(k,f){(events[k]||(events[k]=[])).push(f);}};
function element(id,input){return {id,tagName:input?'INPUT':'DIV',type:'text',readOnly:true,disabled:false,style:{},attributes:{},handlers:{},
addEventListener(k,f){this.handlers[k]=f;},setAttribute(k,v){this.attributes[k]=v;},
focus(){if(doc.activeElement && doc.activeElement!==this)doc.activeElement.blur();doc.activeElement=this;if(input)inputFocuses++;},
blur(){if(this.handlers.blur)this.handlers.blur();if(doc.activeElement===this)doc.activeElement=null;}
};}
['host','port','piccapPort','token','customProfile'].forEach(id=>{elements[id]=element(id,true);elements[id+'Field']=element(id+'Field',false);});
['overview','receiver','calibration'].forEach(key=>{elements[key+'Panel']=element(key+'Panel');elements[key+'Tab']=element(key+'Tab');});
['start','receiver','calibrate','calibrationShortcut'].forEach(id=>elements[id]=element(id));
const win={addEventListener(k,f){windowEvents[k]=f;}};
const c={document:doc,window:win};vm.createContext(c);vm.runInContext(fs.readFileSync('app/navigation.js','utf8'),c);
elements.receiverTab.onclick();assert.equal(elements.receiverPanel.style.display,'block');assert.equal(elements.overviewPanel.style.display,'none');assert.equal(doc.activeElement.id,'receiver');
elements.hostField.focus();assert.equal(inputFocuses,0);assert(elements.host.readOnly);
elements.hostField.onclick();assert.equal(inputFocuses,1);assert(!elements.host.readOnly);assert.equal(doc.activeElement.id,'host');
events.keyboardStateChange[0]({detail:{visibility:false}});assert(elements.host.readOnly);assert.equal(doc.activeElement.id,'hostField');
elements.portField.focus();assert.equal(inputFocuses,1);elements.portField.onclick();assert.equal(inputFocuses,2);
let prevented=false;events.keydown[0]({keyCode:461,preventDefault(){prevented=true;}});assert(prevented && elements.port.readOnly && doc.activeElement.id==='portField');
elements.token.disabled=true;elements.tokenField.onclick();assert.equal(inputFocuses,2);
elements.calibrationShortcut.onclick();assert.equal(doc.activeElement.id,'calibrate');assert.equal(elements.calibrationPanel.style.display,'block');assert.equal(elements.receiverPanel.style.display,'none');
elements.customProfileField.onclick();assert.equal(inputFocuses,3);elements.start.focus();assert(elements.customProfile.readOnly);
console.log('PASS: three sections, calibration shortcut, highlight without input focus, explicit editing, keyboard close/Back and disabled input');
+63
View File
@@ -0,0 +1,63 @@
"""Test-only JSON relay with a controlled serverinfo LED layout.
Only the configured TV may connect. Receiver settings remain unchanged.
The control file can select count/shift, an invalid layout or passthrough.
"""
import argparse
import json
from pathlib import Path
import socket
import socketserver
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)
parser.add_argument('--control-file', required=True)
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')
while line := self.rfile.readline(2 * 1024 * 1024):
command = json.loads(line)
remote.sendall(line)
reply = incoming.readline(2 * 1024 * 1024)
if not reply:
return
if command.get('command') == 'serverinfo':
control = json.loads(Path(args.control_file).read_text(encoding='utf-8'))
response = json.loads(reply)
if control.get('invalid'):
response['info']['leds'] = []
elif 'count' in control:
count = control['count']
if not isinstance(count, int) or not 1 <= count <= 16384:
return
shift = control.get('shift', 0)
response['info']['leds'] = [
{'hmin': (i + shift) / count, 'hmax': (i + 1) / count,
'vmin': 0, 'vmax': .05}
for i in range(count)
]
reply = json.dumps(response).encode() + b'\n'
print(json.dumps({'serverinfo': control}), flush=True)
self.request.sendall(reply[:10])
self.request.sendall(reply[10:])
except (OSError, ValueError, KeyError):
pass
class Server(socketserver.ThreadingTCPServer):
daemon_threads = True
with Server((args.bind, args.port), Relay) as server:
print('Layout relay ready', flush=True)
server.serve_forever()
+6 -3
View File
@@ -1,10 +1,10 @@
'use strict'; 'use strict';
const assert=require('assert'),fs=require('fs'),vm=require('vm'); const assert=require('assert'),fs=require('fs'),vm=require('vm');
const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics']; const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics','profile','customEditor','customProfile','saveProfile','profileStatus','calibrate','validateCalibration','cancelCalibration','editProfile','customProfileField','overviewTab','receiverTab','calibrationTab','calibrationShortcut','hostField','portField','piccapPortField','tokenField','grantRoot','rootHint'];
const elements={}; const elements={};
ids.forEach(id=>elements[id]={value:'',textContent:'',disabled:false,checked:false,attributes:{},style:{},addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}}); ids.forEach(id=>elements[id]={value:'',textContent:'',disabled:false,checked:false,attributes:{},style:{},addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}});
const requests=[]; const requests=[];
const status={returnValue:true,version:'0.9.0',host:'127.0.0.1',port:19444,piccapPort:19400,receiver:'HyperHDR',tokenSet:true,piccapInstalled:true,autostart:true,captureState:'Histogram',playbackProtection:'protected',connectionTest:{state:'Passed',ledCount:120,elapsedMs:300,error:''},uid:0}; const status={returnValue:true,version:'0.9.0',host:'127.0.0.1',port:19444,piccapPort:19400,receiver:'HyperHDR',tokenSet:true,piccapInstalled:true,autostart:true,captureState:'Histogram',playbackProtection:'protected',connectionTest:{state:'Passed',ledCount:120,elapsedMs:300,error:''},uid:0,calibrationProfile:'auto',effectiveCalibration:'g4',tvModel:'OLED65G42LW.DEUQLJP',customModel:'',customPoints:[]};
class Bridge { class Bridge {
call(uri,data){requests.push({uri,data:JSON.parse(data)});this.onservicecallback(JSON.stringify(uri.endsWith('/status')?status:{returnValue:true}));} call(uri,data){requests.push({uri,data:JSON.parse(data)});this.onservicecallback(JSON.stringify(uri.endsWith('/status')?status:{returnValue:true}));}
cancel(){} cancel(){}
@@ -14,11 +14,14 @@ const context={PalmServiceBridge:Bridge,document:{getElementById:id=>elements[id
vm.runInNewContext(fs.readFileSync('app/app.js','utf8'),context); vm.runInNewContext(fs.readFileSync('app/app.js','utf8'),context);
assert(elements.testResult.textContent.includes('120 LEDs')); assert(elements.testResult.textContent.includes('120 LEDs'));
assert(elements.diagnostics.textContent.includes('Root service')); assert(elements.diagnostics.textContent.includes('Root service'));
assert(controls.includes(elements.piccapPort)&&controls.includes(elements.testConnection)); assert(controls.includes(elements.piccapPortField)&&controls.includes(elements.testConnection));
context.targetDirty=true;elements.testConnection.onclick();assert(elements.error.textContent.includes('Save receiver')); context.targetDirty=true;elements.testConnection.onclick();assert(elements.error.textContent.includes('Save receiver'));
elements.piccapPort.value='0';elements.saveTarget.onclick();assert(elements.error.textContent.includes('FlatBuffer port')); elements.piccapPort.value='0';elements.saveTarget.onclick();assert(elements.error.textContent.includes('FlatBuffer port'));
elements.piccapPort.value='19446';elements.host.value='10.1.0.100';elements.token.value='';elements.saveTarget.onclick(); elements.piccapPort.value='19446';elements.host.value='10.1.0.100';elements.token.value='';elements.saveTarget.onclick();
const saved=requests.find(r=>r.uri.endsWith('/settings'));assert.strictEqual(saved.data.piccapPort,19446);assert.strictEqual(saved.data.host,'10.1.0.100');assert(!('token' in saved.data)); const saved=requests.find(r=>r.uri.endsWith('/settings'));assert.strictEqual(saved.data.piccapPort,19446);assert.strictEqual(saved.data.host,'10.1.0.100');assert(!('token' in saved.data));
elements.testConnection.onclick();assert(requests.some(r=>r.uri.endsWith('/testConnection'))); elements.testConnection.onclick();assert(requests.some(r=>r.uri.endsWith('/testConnection')));
assert.strictEqual(elements.token.value,'');assert(!elements.diagnostics.textContent.includes('fixture-token')); assert.strictEqual(elements.token.value,'');assert(!elements.diagnostics.textContent.includes('fixture-token'));
elements.profile.onclick();assert(elements.profile.textContent.includes('LG G4'));elements.saveProfile.onclick();assert(requests.some(r=>r.uri.endsWith('/calibration')&&r.data.profile==='g4'));
context.profile='custom';context.showProfile();elements.customProfile.value='invalid';elements.saveProfile.onclick();assert(elements.error.textContent.includes('valid JSON'));
elements.customProfile.value=JSON.stringify({model:'OTHER-TV',points:[{position:0,region:1},{position:2,region:5}]});elements.saveProfile.onclick();assert(requests.some(r=>r.uri.endsWith('/calibration')&&r.data.model==='OTHER-TV'&&r.data.points.length===2));
console.log('PASS: receiver fields, port validation, saved-token retention, test control and diagnostics'); console.log('PASS: receiver fields, port validation, saved-token retention, test control and diagnostics');
+5 -1
View File
@@ -29,7 +29,11 @@ buttons[4].disabled=false;buttons[3].focus();key(39);assert.equal(doc.activeElem
key(39);assert.equal(doc.activeElement.id,'start');key(37);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(); 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'); 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'); assert(!key(40));assert(!key(38));assert.equal(doc.activeElement.id,'refresh');
buttons[2].tagName='TEXTAREA';buttons[2].focus();
assert(!key(37));assert(!key(39));assert(!key(13));
assert(!key(40));assert(!key(38));assert.equal(doc.activeElement.id,'refresh');
buttons[2].readOnly=true;assert(key(40));assert.equal(doc.activeElement.id,'autostart');
buttons[2].tagName='BUTTON'; buttons[2].tagName='BUTTON';
buttons.forEach(button=>button.disabled=true);assert(key(39)); 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'); console.log('PASS: arrow navigation, wraparound, disabled controls, OK once per press, pointer handoff, focus recovery, Back preserved');
+27
View File
@@ -0,0 +1,27 @@
'use strict';
const assert=require('assert'),fs=require('fs'),vm=require('vm');
const elements={},requests=[],timers=new Map();let next=0,uid=1000,enabled=false,deny=false,delayRoot=false,pendingRoot=null;
for(const match of fs.readFileSync('app/index.html','utf8').matchAll(/id="([^"]+)"/g))elements[match[1]]={style:{},value:'',textContent:'',attributes:{},disabled:false,addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}};
class Bridge {
call(uri,raw){
const data=JSON.parse(raw);requests.push({uri,data});let r={returnValue:true};
if(uri.endsWith('/status'))r={returnValue:true,uid,autostart:enabled,piccapInstalled:true,connectionTest:{state:'Not tested'},calibrationProfile:'auto',effectiveCalibration:'g4',customPoints:[],tvModel:'OLED65G42LW.DEUQLJP'};
if(uri.endsWith('/autostart')){if(uid)r={returnValue:false,rootRequired:true};else enabled=data.enabled;}
if(uri.includes('hbchannel')){if(delayRoot){pendingRoot=this;return;}if(deny)r={returnValue:false,errorText:'Denied by Homebrew Channel'};}
if(uri.endsWith('/restartService'))uid=0;
this.onservicecallback(JSON.stringify(r));
}
cancel(){}
}
const c={document:{getElementById(id){assert(elements[id],id);return elements[id];},createElement(){return {};}},window:{},PalmServiceBridge:Bridge,setupRemoteNavigation(){},setInterval(){},setTimeout(fn){const id=++next;timers.set(id,fn);return id;},clearTimeout(id){timers.delete(id);}};
vm.createContext(c);vm.runInContext(fs.readFileSync('app/app.js','utf8'),c);
function flush(){for(let i=0;timers.size && i<20;i++){const [id,fn]=timers.entries().next().value;timers.delete(id);fn();}assert(!timers.size);}
assert.equal(elements.grantRoot.style.display,'inline-block');elements.autostart.onclick();flush();
assert(enabled && !elements.autostart.disabled && uid===0);assert.equal(elements.grantRoot.style.display,'none');
const grant=requests.find(r=>r.uri.includes('hbchannel'));assert.deepEqual(grant.data,{id:'org.webosbrew.histogramhyperion.service'});assert(requests.some(r=>r.uri.endsWith('/restartService')));
const grantCount=requests.filter(r=>r.uri.includes('hbchannel')).length;
elements.autostart.onclick();flush();assert(!enabled);assert.equal(requests.filter(r=>r.uri.includes('hbchannel')).length,grantCount);
uid=1000;deny=true;elements.autostart.onclick();flush();assert(!enabled && !elements.autostart.disabled && !elements.grantRoot.disabled);assert(elements.error.textContent.includes('could not grant root'));
deny=false;delayRoot=true;elements.grantRoot.onclick();elements.autostart.onclick();assert(pendingRoot && c.rootWaiters.length===2);
delayRoot=false;pendingRoot.onservicecallback(JSON.stringify({returnValue:true}));flush();assert(enabled && !elements.autostart.disabled && !c.rootWaiters.length);
console.log('PASS: root-required autostart repairs only own service, confirms root after restart, retries setting, avoids unnecessary elevation and reports denial');
+32
View File
@@ -0,0 +1,32 @@
'use strict';
const assert=require('assert'),fs=require('fs'),vm=require('vm');
const elements={},events={},requests=[];let active=false,sequence=0,globalResponse=false;
['calibrationVideo','cancelCalibration','calibrate','validateCalibration','wizardStatus','customProfile'].forEach(id=>elements[id]={style:{},disabled:false,currentTime:0,textContent:'',value:'',events:{},focus(){},pause(){},play(){return Promise.resolve();},addEventListener(k,f){this.events[k]=f;}});
const c={profile:'auto',profileDirty:false,targetDirty:false,showProfile(){},display(){},setInterval(){return 1;},clearInterval(){},setTimeout(){},Date,Array,JSON,
window:{addEventListener(k,f){events[k]=f;}},document:{getElementById(id){return elements[id];},addEventListener(k,f){events[k]=f;}},
call(method,done,data){
requests.push({method,data});if(method==='calibrationSession')active=data.enabled;
const t=elements.calibrationVideo.currentTime,step=t<4?-1:Math.floor((t-4)/2);
const regions=Array.from({length:16},(_,r)=>{
const center=r*2+.5,d=Math.min(Math.abs(step-center),32-Math.abs(step-center));
const value=step<0?10:10+80*(globalResponse?1:Math.max(0,1-d/2));return [value,value,value];
});
done(method==='status'?{returnValue:true,running:true,activeSource:'HyperDRM',tvModel:'OTHER-TV',colorValid:true,calibrating:active,sampleSequence:++sequence,sampleAgeMs:10,regions}:{returnValue:true});
}
};vm.createContext(c);
vm.runInContext(fs.readFileSync('app/calibration.js','utf8'),c);vm.runInContext(fs.readFileSync('app/wizard.js','utf8'),c);
function measure(){
elements.calibrate.onclick();assert(active);assert.deepEqual(c.window.remoteNavigationScope,['cancelCalibration']);
[1,1.5,2].forEach(t=>{elements.calibrationVideo.currentTime=t;c.wizardSample();});
for(let step=0;step<32;step++)[.8,1.1,1.4].forEach(t=>{elements.calibrationVideo.currentTime=4+step*2+t;c.wizardSample();});
elements.calibrationVideo.currentTime=68;c.wizardSample();
assert(!active && c.wizard===null && c.window.remoteNavigationScope===null);
}
measure();assert.equal(c.profile,'custom');assert(c.profileDirty);
const proposal=JSON.parse(elements.customProfile.value);assert.equal(proposal.points.length,16);
assert(requests.some(r=>r.method==='calibrationDraft'));assert(!requests.some(r=>r.method==='calibration'));
c.profileDirty=false;globalResponse=true;measure();assert(elements.wizardStatus.textContent.includes('reliable spatial'));assert.equal(elements.customProfile.value,JSON.stringify(proposal,null,2));
globalResponse=false;elements.calibrate.onclick();elements.cancelCalibration.onclick();assert(!active && !c.wizard);
elements.validateCalibration.onclick();assert.equal(elements.calibrationVideo.currentTime,68);elements.calibrationVideo.events.ended();assert(!active && elements.wizardStatus.textContent.includes('Top-left red'));
elements.calibrate.onclick();let prevented=false;events.keydown({keyCode:461,preventDefault(){prevented=true;}});assert(prevented && !active);
console.log('PASS: guided measurement, proposal without save, invalid-response preservation, validation, cancel/Back and scoped remote navigation');