diff --git a/.gitignore b/.gitignore index 0118843..0b96f60 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ test-diagnostics test-calibration test-layout_refresh test-spatial_image +test-continuity +test-receiver_stream +test-source_bridge diff --git a/HyperDRM.manifest.json b/HyperDRM.manifest.json index 4d005af..2180f76 100644 --- a/HyperDRM.manifest.json +++ b/HyperDRM.manifest.json @@ -1,15 +1,15 @@ { "id": "org.webosbrew.histogramhyperion", - "version": "0.10.0", + "version": "0.11.0", "type": "web", "title": "HyperDRM", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM", "rootRequired": true, - "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.10.0_arm.ipk", + "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.11.0_arm.ipk", "ipkHash": { - "sha256": "0ad040adfc0be402f5c83e783a8f9b4e78b33b59e5b04fa3d52652bf48ddda70" + "sha256": "4e2be7de5f6e06cca4a389d17b18c770aa47ef2d862aac651c16ed02f231d711" }, - "ipkSize": 1183836 + "ipkSize": 1203394 } diff --git a/HyperDRM_0.10.0_arm.ipk b/HyperDRM_0.11.0_arm.ipk similarity index 86% rename from HyperDRM_0.10.0_arm.ipk rename to HyperDRM_0.11.0_arm.ipk index 94982c8..098b57f 100644 Binary files a/HyperDRM_0.10.0_arm.ipk and b/HyperDRM_0.11.0_arm.ipk differ diff --git a/README.md b/README.md index 556abca..199626a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ Ambient Light for webOS, Hyperion and HyperHDR. An own webOS app and native ARM - Spatial colors from 16 LG histogram regions with interpolated transitions along the screen perimeter. - 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. +- 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). - 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. @@ -25,7 +27,7 @@ Only the own service is modified. Optional Auto PicCap can configure its receive ## Installation -The current package is [HyperDRM_0.10.0_arm.ipk](HyperDRM_0.10.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: @@ -139,3 +141,54 @@ While a capture session runs, a separate nonblocking authenticated JSON connecti 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. diff --git a/app/app.js b/app/app.js index cf6b6ae..c7b9e13 100644 --- a/app/app.js +++ b/app/app.js @@ -1,18 +1,31 @@ 'use strict'; var pending=[]; var targetDirty=false,receiver='Hyperion'; -var profileDirty=false,profile='auto'; +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];document.getElementById('customEditor').style.display=profile==='custom'?'block':'none';document.getElementById('customProfile').disabled=profile!=='custom';} -function call(method,done,data){ +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;} 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);} 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); - bridge.call('luna://org.webosbrew.histogramhyperion.service/'+method,JSON.stringify(data||{})); + 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(uri,JSON.stringify(data||{})); } 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); @@ -46,6 +59,8 @@ function display(result){ else if(result.returnValue!==false)document.getElementById('status').textContent+=' · No current color output'; if(result.blackBarDetection && result.running)document.getElementById('status').textContent+=result.letterboxDetected?' · Top/bottom black bars detected':' · Full-screen capture'; if(result.spatialInterpolation)document.getElementById('status').textContent+=' · Smooth color transitions'; + if(result.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.retryPaused)document.getElementById('status').textContent+=' · Automatic retry paused until Start'; var box=document.getElementById('regions');box.textContent=''; @@ -57,7 +72,30 @@ document.getElementById('refresh').onclick=function(){call('status',display);}; call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000); 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'});}; ['host','port','piccapPort','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});}); @@ -75,4 +113,5 @@ document.getElementById('testConnection').onclick=function(){if(targetDirty){doc 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);}; -setupRemoteNavigation(['start','stop','refresh','autostart','handover','receiver','host','port','piccapPort','token','clearToken','saveTarget','testConnection','profile','customProfile','saveProfile'].map(function(id){return document.getElementById(id);}),document,window); +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); diff --git a/app/appinfo.json b/app/appinfo.json index 7671e8a..11e45ad 100644 --- a/app/appinfo.json +++ b/app/appinfo.json @@ -1 +1 @@ -{"id": "org.webosbrew.histogramhyperion", "version": "0.10.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"} diff --git a/app/calibration.js b/app/calibration.js new file mode 100644 index 0000000..54084ef --- /dev/null +++ b/app/calibration.js @@ -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;}); +} diff --git a/app/calibration.mp4 b/app/calibration.mp4 new file mode 100644 index 0000000..e31291d Binary files /dev/null and b/app/calibration.mp4 differ diff --git a/app/index.html b/app/index.html index 79ffa02..904a1fc 100644 --- a/app/index.html +++ b/app/index.html @@ -1,21 +1,58 @@ -HyperDRM - -

HyperDRM

Ambient lighting for webOS, Hyperion and HyperHDR.

Checking service …

-
- -

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

Autostart starts the service when the TV boots. Stop ends the current session. Auto PicCap uses installed PicCap for non-secure video and HyperDRM for protected or unknown playback. Keep PicCap's own autostart off. Stable unprotected playback selects PicCap; five seconds of connection loss restores HyperDRM, with a 30-second retry cooldown.

-
Receiver settings - - - - -

Connection test: Not tested

-

Use the receiver's JSON TCP port, usually 19444. IPv4 and IPv6 addresses are supported. Leave the token blank to keep a saved token. Save applies the new target to a running session. Use Up/Down to move between fields; Left/Right edit text.

-

Auto PicCap 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.

-
TV calibration -

Detecting TV model...

-

Auto selects an imported profile matching this exact TV model, or the measured LG G4 profile. Other models use uniform average colors until calibrated. Select Custom and paste a measured model profile to import it. G4 can also be selected manually. Profiles change spatial mapping; adjust colors in Hyperion/HyperHDR.

-

Priority 149 · 10 updates per second

-

-

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. The LED layout is checked every five seconds and updated automatically.

- + +HyperDRM + +

HyperDRM

+ +

Checking service …

+
+ +
+
Automatic operation +

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.

+Measure a spatial profile for your TV. +

Priority 149 · 10 updates per second

+

Adjust brightness, saturation and normal smoothing in Hyperion or HyperHDR.

+
+ + + + diff --git a/app/navigation.js b/app/navigation.js new file mode 100644 index 0000000..b6377bc --- /dev/null +++ b/app/navigation.js @@ -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); diff --git a/app/remote.js b/app/remote.js index d7fb942..72cbda6 100644 --- a/app/remote.js +++ b/app/remote.js @@ -11,10 +11,10 @@ function setupRemoteNavigation(buttons, doc, win) { event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0; var enter=event.key==='Enter'||code===13; if(!direction&&!enter)return; - var editing=doc.activeElement && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA'); - if(editing && (enter||code===37||code===39||event.key==='ArrowLeft'||event.key==='ArrowRight'))return; + var editing=doc.activeElement && !doc.activeElement.readOnly && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA'); + if(editing)return; 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; var focused=doc.activeElement,index=enabled.indexOf(focused); if(direction){ diff --git a/app/wizard.js b/app/wizard.js new file mode 100644 index 0000000..1f66c98 --- /dev/null +++ b/app/wizard.js @@ -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);}); diff --git a/build_calibration_video.py b/build_calibration_video.py new file mode 100644 index 0000000..ec93355 --- /dev/null +++ b/build_calibration_video.py @@ -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') diff --git a/docs/publishing-review.md b/docs/publishing-review.md index f002fe7..6551c70 100644 --- a/docs/publishing-review.md +++ b/docs/publishing-review.md @@ -99,3 +99,40 @@ A separate nonblocking authenticated receiver probe refreshes the LED mapping ev 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. diff --git a/native/continuity.h b/native/continuity.h new file mode 100644 index 0000000..9594e05 --- /dev/null +++ b/native/continuity.h @@ -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 diff --git a/native/handover.h b/native/handover.h index c9a1093..17abc0d 100644 --- a/native/handover.h +++ b/native/handover.h @@ -1,6 +1,9 @@ #ifndef HYPERDRM_HANDOVER_H #define HYPERDRM_HANDOVER_H #include "playback.h" +#ifndef CALIBRATION_ACTIVE +#define CALIBRATION_ACTIVE false +#endif #ifndef PICCAP_BINARY #define PICCAP_BINARY "/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos" #endif @@ -107,11 +110,11 @@ static bool handover_tick(void){ error_free(&e);next_media_probe=now+1000; } if(protection==PLAYBACK_CLEAR && now-protection_at>=3000){protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;} - bool session=handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked; + bool session=!CALIBRATION_ACTIVE && handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked; bool clear=session && protection==PLAYBACK_CLEAR && now-protection_at<3000; bool stable=clear && clear_samples>=3 && now-clear_since>=CLEAR_STABLE_MS && now>=handover_cooldown; if(capture_source==SOURCE_HISTOGRAM && stable){ - piccap_target=target;piccap_error[0]=0;piccap_disconnected_since=now;stop_capture();retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING; + 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;} } bool grace=protection==PLAYBACK_UNKNOWN && unknown_since && now-unknown_since=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;iat(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 diff --git a/native/service.c b/native/service.c index 243c7b8..eefee4e 100644 --- a/native/service.c +++ b/native/service.c @@ -27,6 +27,7 @@ #include "target.h" #include "spatial_image.h" #include "diagnostics.h" +#include "continuity.h" #define SERVICE "org.webosbrew.histogramhyperion.service" #define PRIORITY 149 typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError; @@ -60,6 +61,10 @@ static unsigned letterbox_mode,settling; static int pending_letterbox=-1; static uint64_t candidate_since,next_border_probe; 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 LedBlend *led_blends; static bool interpolation_enabled; @@ -108,6 +113,12 @@ 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;} return 0; } +static bool receiver_ack(void){ + char answer[4096];size_t used=0;bool complete=false; + while(used+1=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){ (void)ctx;const char *p=m?payload(m):NULL;bool enabled; if(p && strcmp(p,"{\"enabled\":true}")==0)enabled=true; else if(p && strcmp(p,"{\"enabled\":false}")==0)enabled=false; 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); retry_update(&retries,enabled,capture!=NULL,milliseconds()); 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){ (void)ctx;const char *p=payload(m);bool enabled; if(strcmp(p,"{\"enabled\":true}")==0)enabled=true; @@ -219,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(!target_save(TARGET_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}"); bool active=capture || piccap_owned || retries.due; - stop_capture();test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=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(); return respond(h,m,"{\"returnValue\":true}"); } @@ -230,15 +283,21 @@ static bool test_connection(void *h,void *m,void *ctx){ return respond(h,m,"{\"returnValue\":true,\"testing\":true}"); } 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); for(size_t i=0;last_error[i] && n+2=32)safe[n++]=c;}safe[n]=0; uint64_t now=milliseconds(); + char report[512];size_t k=0;for(size_t i=0;calibration_message[i] && k+2=32)report[k++]=c;}report[k]=0; 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;inow?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;inow?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.10.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); + 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]); 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]); @@ -248,7 +307,9 @@ static int tick(void *unused){ (void)unused; uint64_t now=milliseconds(); model_tick(now);test_tick(&playback_json,now);layout_refresh_tick(&playback_json,now,capture || capture_source==SOURCE_PICCAP); - if(handover_tick())return 1; + stream_tick(&playback_json,now,handover_enabled && !retries.paused && !retries.blocked); + bool owned=handover_tick();bridge_tick(now); + if(owned)return 1; if(!capture){ retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds()); if(retry_take(&retries,milliseconds())){ @@ -259,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(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;} capture_preset=0;settling=2;return 1; } 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}; for(int i=0;i<16;i++){ black_percent[i]=100; @@ -278,7 +349,7 @@ static int tick(void *unused){ 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]; } - if(capture_preset==0){ + if(!CALIBRATION_ACTIVE && capture_preset==0){ int candidate=letterbox_candidate(black_percent,rgb);uint64_t now=milliseconds(); if(candidate<0)pending_letterbox=-1; else if(candidate!=pending_letterbox){pending_letterbox=candidate;candidate_since=now;} @@ -290,26 +361,15 @@ static int tick(void *unused){ } 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 mapped_rgb[16][3];calibration_colors(rgb,mapped_rgb); + unsigned char mapped_rgb[16][3],stable_rgb[16][3];calibration_colors(rgb,mapped_rgb); + continuity_frame(&continuity,true,mapped_rgb,now,stable_rgb); interpolation_enabled=led_count && calibration_effective()!=CAL_AVERAGE; - bool image_output=strcmp(target.receiver,"HyperHDR")==0; - size_t capacity=256+(size_t)led_count*12;char *line=image_output?spatial_image_command(mapped_rgb,PRIORITY):malloc(capacity); + char *line=region_command(stable_rgb,led_blends,led_count,1500); 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=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 diff --git a/native/spatial_image.h b/native/spatial_image.h index 2ee378c..d7cae1a 100644 --- a/native/spatial_image.h +++ b/native/spatial_image.h @@ -9,7 +9,7 @@ enum {SPATIAL_WIDTH=160,SPATIAL_HEIGHT=90,SPATIAL_PIXELS=SPATIAL_WIDTH*SPATIAL_H /* Synthetic color plane from histogram samples, never captured video pixels. * HyperHDR 22 treats JSON color as a solid color; image input retains space. * 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 unsigned generation; if(generation!=calibration_generation){ for(unsigned y=0;y>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; } +static inline char *spatial_image_command(const unsigned char regions[16][3],unsigned priority){return spatial_image_command_duration(regions,priority,1500);} #endif diff --git a/native/test_continuity.c b/native/test_continuity.c new file mode 100644 index 0000000..2159599 --- /dev/null +++ b/native/test_continuity.c @@ -0,0 +1,27 @@ +#include +#include +#include +#include +#include +#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; +} diff --git a/native/test_handover.c b/native/test_handover.c index 3d4ec0d..46e34dd 100644 --- a/native/test_handover.c +++ b/native/test_handover.c @@ -12,7 +12,7 @@ static uint64_t clock_ms=10000; static unsigned stopped,called,canceled; static char last_uri[128],last_data[256]; 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 void error_init(LSError *e){(void)e;} static void error_free(LSError *e){(void)e;} diff --git a/native/test_receiver_stream.c b/native/test_receiver_stream.c new file mode 100644 index 0000000..f0343ff --- /dev/null +++ b/native/test_receiver_stream.c @@ -0,0 +1,39 @@ +#define _DEFAULT_SOURCE +#include +#include +#include +#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(©));assert(target_piccap_compatible(©));assert(!target_piccap_local(©));assert(target_piccap_host(©)); + assert(!target_parse(&json,"{}",©));assert(!target_save("/no/such/profile",©));assert(!target_load(&json,"/no/such/profile",©)); + int fd=target_connect(©);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; +} diff --git a/native/test_source_bridge.c b/native/test_source_bridge.c new file mode 100644 index 0000000..a506aac --- /dev/null +++ b/native/test_source_bridge.c @@ -0,0 +1,43 @@ +#include +#include +#include +#include +#include +#include +#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; +} diff --git a/package/packageinfo.json b/package/packageinfo.json index 76537ff..9294b78 100644 --- a/package/packageinfo.json +++ b/package/packageinfo.json @@ -1 +1 @@ -{"id": "org.webosbrew.histogramhyperion", "version": "0.10.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"} diff --git a/profiles/README.md b/profiles/README.md index 335bd44..368df6c 100644 --- a/profiles/README.md +++ b/profiles/README.md @@ -33,3 +33,13 @@ 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. diff --git a/service/histogram-service b/service/histogram-service index e2ede4e..02c027d 100644 Binary files a/service/histogram-service and b/service/histogram-service differ diff --git a/tests/calibration.test.js b/tests/calibration.test.js new file mode 100644 index 0000000..8e52903 --- /dev/null +++ b/tests/calibration.test.js @@ -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'); diff --git a/tests/form-navigation.test.js b/tests/form-navigation.test.js new file mode 100644 index 0000000..0dc0ef3 --- /dev/null +++ b/tests/form-navigation.test.js @@ -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'); diff --git a/tests/receiver-ui.test.js b/tests/receiver-ui.test.js index 57a7e31..71936d7 100644 --- a/tests/receiver-ui.test.js +++ b/tests/receiver-ui.test.js @@ -1,6 +1,6 @@ 'use strict'; 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','profile','customEditor','customProfile','saveProfile','profileStatus']; +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={}; 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=[]; @@ -14,7 +14,7 @@ const context={PalmServiceBridge:Bridge,document:{getElementById:id=>elements[id vm.runInNewContext(fs.readFileSync('app/app.js','utf8'),context); assert(elements.testResult.textContent.includes('120 LEDs')); 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')); 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(); diff --git a/tests/remote-navigation.test.js b/tests/remote-navigation.test.js index 1cc26b0..a051aa3 100644 --- a/tests/remote-navigation.test.js +++ b/tests/remote-navigation.test.js @@ -29,11 +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'); buttons[2].tagName='INPUT';buttons[2].type='text';buttons[2].focus(); assert(!key(37));assert(!key(39));assert(!key(13));assert.equal(doc.activeElement.id,'refresh'); -key(40);assert.equal(doc.activeElement.id,'autostart');key(38);assert.equal(doc.activeElement.id,'refresh'); +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)); -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].readOnly=true;assert(key(40));assert.equal(doc.activeElement.id,'autostart'); buttons[2].tagName='BUTTON'; buttons.forEach(button=>button.disabled=true);assert(key(39)); console.log('PASS: arrow navigation, wraparound, disabled controls, OK once per press, pointer handoff, focus recovery, Back preserved'); diff --git a/tests/root-repair.test.js b/tests/root-repair.test.js new file mode 100644 index 0000000..6ae5464 --- /dev/null +++ b/tests/root-repair.test.js @@ -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'); diff --git a/tests/wizard.test.js b/tests/wizard.test.js new file mode 100644 index 0000000..373004d --- /dev/null +++ b/tests/wizard.test.js @@ -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');