diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..0f53e92 --- /dev/null +++ b/.clang-format @@ -0,0 +1,7 @@ +BasedOnStyle: LLVM +IndentWidth: 4 +ColumnLimit: 100 +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLoopsOnASingleLine: false +SortIncludes: Never diff --git a/HyperDRM.manifest.json b/HyperDRM.manifest.json index 2180f76..c4480df 100644 --- a/HyperDRM.manifest.json +++ b/HyperDRM.manifest.json @@ -1,15 +1,15 @@ { "id": "org.webosbrew.histogramhyperion", - "version": "0.11.0", + "version": "0.12.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.11.0_arm.ipk", + "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.12.0_arm.ipk", "ipkHash": { - "sha256": "4e2be7de5f6e06cca4a389d17b18c770aa47ef2d862aac651c16ed02f231d711" + "sha256": "b4fcc609d763b8e1a72d84484a5d454627416dde0ef51ef65ab705173e3c0810" }, - "ipkSize": 1203394 + "ipkSize": 1204332 } diff --git a/HyperDRM_0.11.0_arm.ipk b/HyperDRM_0.12.0_arm.ipk similarity index 90% rename from HyperDRM_0.11.0_arm.ipk rename to HyperDRM_0.12.0_arm.ipk index 098b57f..4c7cbc0 100644 Binary files a/HyperDRM_0.11.0_arm.ipk and b/HyperDRM_0.12.0_arm.ipk differ diff --git a/README.md b/README.md index 199626a..53e5fe1 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Only the own service is modified. Optional Auto PicCap can configure its receive ## Installation -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. +The current package is [HyperDRM_0.12.0_arm.ipk](HyperDRM_0.12.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow. Elevate the installed own service through Homebrew Channel: @@ -192,3 +192,20 @@ service, verifies UID 0, and retries the switch. Root and boot-hook failures hav 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. + +### Development and releases + +`VERSION` is the single source for app, package and native runtime versions. +Run `python sync_version.py` after editing it; `--check` rejects stale metadata. +Run `python release.py` to execute every JavaScript and portable native test, +compile the ARM service, build a reproducible IPK and verify its embedded versions, +ARM architecture, size and SHA-256 against the manifest. On Windows this requires +Node.js and Debian WSL with GCC, JSON-C and arm-linux-gnueabi-gcc; on Linux the +same tools must be on PATH. No TV connection or installation occurs. + +After reviewing the working tree, `python release.py --push` also commits changes, +creates a new version tag and atomically pushes main and the tag to origin. +An existing tag or a branch other than main is rejected. Attach the exact verified +IPK to the matching Gitea release using an authorized session, then compare the +downloaded attachment's size and SHA-256. Git access does not authorize web uploads. +See [runtime architecture](docs/architecture.md) for module boundaries. diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..ac454c6 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.12.0 diff --git a/app/app.js b/app/app.js index c7b9e13..16353ea 100644 --- a/app/app.js +++ b/app/app.js @@ -1,117 +1,352 @@ 'use strict'; -var pending=[]; -var targetDirty=false,receiver='Hyperion'; -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'; +var pending = []; +var actionError = ''; + +function showActionError(message) { + actionError = message || ''; + document.getElementById('error').textContent = actionError; } -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.'});},uri.indexOf('hbchannel')>=0?30000:10000); - bridge.call(uri,JSON.stringify(data||{})); +function clearActionError() { + showActionError(''); } -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':''); +var targetDirty = false, + receiver = 'Hyperion'; +var profileDirty = false, + profile = 'auto', + advancedProfile = false; +var profileNames = { + auto: 'Auto', + g4: 'LG G4 (measured)', + average: 'Average (uniform)', + custom: 'Custom model' +}; - 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('receiver').textContent='Receiver: '+receiver; - } - document.getElementById('token').placeholder=result.tokenSet?'Saved token (leave blank to keep)':'No token saved'; - if(result.endpoint)document.getElementById('endpoint').textContent=result.receiver+' · '+result.endpoint+' · Priority 149 · 10 updates per second'; - var 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:''); - 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:'')+(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; - handover.setAttribute('aria-checked',String(!!result.piccapHandover)); - handover.textContent=result.piccapInstalled?'Auto PicCap: '+(result.piccapHandover?'On':'Off'):'Auto PicCap: Not installed'; - if(typeof result.autostart==='boolean'){var toggle=document.getElementById('autostart');toggle.setAttribute('aria-checked',String(result.autostart));toggle.textContent='Autostart: '+(result.autostart?'On':'Off');} - document.getElementById('error').textContent=result.errorText||result.error||''; - document.getElementById('status').textContent=result.returnValue===false?'Service unavailable':(result.running?'Running':'Stopped')+' · '+(result.frames||0)+' acknowledged color updates'; - if(result.activeSource==='PicCap'){ - document.getElementById('status').textContent='PicCap · '+(result.running?'Running':'Switching capture source')+' · '+(result.piccapConnected?'Receiver connected':'Waiting for receiver'); - document.getElementById('regions').textContent='';return; - } - if(result.piccapHandover)document.getElementById('status').textContent+=' · HyperDRM · '+result.playbackProtection; - if(result.piccapHandover && !result.piccapTargetCompatible)document.getElementById('status').textContent+=' · Auto PicCap 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(', '); - 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=''; - (result.regions||[]).forEach(function(rgb,index){var e=document.createElement('div');e.className='swatch';e.style.backgroundColor='rgb('+rgb.join(',')+')';e.title='Histogram '+index;box.appendChild(e);}); +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'; } -document.getElementById('start').onclick=function(){call('start',function(r){if(r.returnValue===false)display(r);else call('status',display);});}; -document.getElementById('stop').onclick=function(){call('stop',function(){call('status',display);});}; -document.getElementById('refresh').onclick=function(){call('status',display);}; -call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000); + +function call(method, done, data) { + if (['start', 'stop', 'autostart', 'handover', 'settings', 'testConnection', 'calibration'].indexOf(method) >= 0) clearActionError(); + callUri('luna://org.webosbrew.histogramhyperion.service/' + method, function(result) { + if (method !== 'status' && result.returnValue === false) showActionError(result.errorText || 'The action failed.'); + done(result); + }, 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.' + }); + }, 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); + } + 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) { + 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('token').placeholder = result.tokenSet ? 'Saved token (leave blank to keep)' : 'No token saved'; + if (result.endpoint) document.getElementById('endpoint').textContent = result.receiver + ' · ' + result.endpoint + ' · Priority 149 · 10 updates per second'; + var 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 : ''); + 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 : '') + (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; + handover.setAttribute('aria-checked', String(!!result.piccapHandover)); + handover.textContent = result.piccapInstalled ? 'Auto PicCap: ' + (result.piccapHandover ? 'On' : 'Off') : 'Auto PicCap: Not installed'; + if (typeof result.autostart === 'boolean') { + var toggle = document.getElementById('autostart'); + toggle.setAttribute('aria-checked', String(result.autostart)); + toggle.textContent = 'Autostart: ' + (result.autostart ? 'On' : 'Off'); + } + document.getElementById('error').textContent = actionError || result.errorText || result.error || ''; + document.getElementById('status').textContent = result.returnValue === false ? 'Service unavailable' : (result.running ? 'Running' : 'Stopped') + ' · ' + (result.frames || 0) + ' acknowledged color updates'; + if (result.activeSource === 'PicCap') { + document.getElementById('status').textContent = 'PicCap · ' + (result.running ? 'Running' : 'Switching capture source') + ' · ' + (result.piccapConnected ? 'Receiver connected' : 'Waiting for receiver'); + document.getElementById('regions').textContent = ''; + return; + } + if (result.piccapHandover) document.getElementById('status').textContent += ' · HyperDRM · ' + result.playbackProtection; + if (result.piccapHandover && !result.piccapTargetCompatible) document.getElementById('status').textContent += ' · Auto PicCap 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(', '); + 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 = ''; + (result.regions || []).forEach(function(rgb, index) { + var e = document.createElement('div'); + e.className = 'swatch'; + e.style.backgroundColor = 'rgb(' + rgb.join(',') + ')'; + e.title = 'Histogram ' + index; + box.appendChild(e); + }); +} +document.getElementById('start').onclick = function() { + call('start', function(r) { + if (r.returnValue === false) display(r); + else call('status', display); + }); +}; +document.getElementById('stop').onclick = function() { + call('stop', function() { + call('status', display); + }); +}; +document.getElementById('refresh').onclick = function() { + call('status', display); +}; +call('status', display); +setInterval(function() { + if (!pending.length) call('status', display); +}, 3000); document.getElementById('start').focus(); -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'}); +var repairingRoot = false, + rootWaiters = []; + +function repairRoot(done) { + clearActionError(); + rootWaiters.push(done); + if (repairingRoot) return; + repairingRoot = true; + document.getElementById('grantRoot').disabled = true; + showActionError('Granting root access to HyperDRM...'); + + function finish(error) { + repairingRoot = false; + document.getElementById('grantRoot').disabled = false; + if (error) showActionError(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('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) showActionError(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;});}); -document.getElementById('receiver').onclick=function(){receiver=receiver==='Hyperion'?'HyperHDR':'Hyperion';this.textContent='Receiver: '+receiver;targetDirty=true;}; -document.getElementById('saveTarget').onclick=function(){ - var port=Number(document.getElementById('port').value),button=this; - if(!Number.isInteger(port)||port<1||port>65535){document.getElementById('error').textContent='JSON port must be an integer from 1 to 65535.';return;} - var flatPort=Number(document.getElementById('piccapPort').value); - if(!Number.isInteger(flatPort)||flatPort<1||flatPort>65535){document.getElementById('error').textContent='PicCap FlatBuffer port must be an integer from 1 to 65535.';return;} - var settings={piccapPort:flatPort,host:document.getElementById('host').value.trim(),port:port,receiver:receiver,clearToken:document.getElementById('clearToken').checked}; - var token=document.getElementById('token').value.trim();if(token)settings.token=token; - button.disabled=true;call('settings',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}targetDirty=false;document.getElementById('token').value='';document.getElementById('clearToken').checked=false;call('status',display);},settings); +document.getElementById('handover').onclick = function() { + var button = this; + button.disabled = true; + call('handover', function(r) { + button.disabled = false; + if (r.returnValue === false) showActionError(r.errorText); + else call('status', display); + }, { + enabled: button.getAttribute('aria-checked') !== 'true' + }); }; -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('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); +['host', 'port', 'piccapPort', 'token', 'clearToken'].forEach(function(id) { + document.getElementById(id).addEventListener('input', function() { + targetDirty = true; + }); +}); +document.getElementById('receiver').onclick = function() { + receiver = receiver === 'Hyperion' ? 'HyperHDR' : 'Hyperion'; + this.textContent = 'Receiver: ' + receiver; + targetDirty = true; +}; +document.getElementById('saveTarget').onclick = function() { + var port = Number(document.getElementById('port').value), + button = this; + if (!Number.isInteger(port) || port < 1 || port > 65535) { + showActionError('JSON port must be an integer from 1 to 65535.'); + return; + } + var flatPort = Number(document.getElementById('piccapPort').value); + if (!Number.isInteger(flatPort) || flatPort < 1 || flatPort > 65535) { + showActionError('PicCap FlatBuffer port must be an integer from 1 to 65535.'); + return; + } + var settings = { + piccapPort: flatPort, + host: document.getElementById('host').value.trim(), + port: port, + receiver: receiver, + clearToken: document.getElementById('clearToken').checked + }; + var token = document.getElementById('token').value.trim(); + if (token) settings.token = token; + button.disabled = true; + call('settings', function(r) { + button.disabled = false; + if (r.returnValue === false) { + showActionError(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) { + showActionError('Save receiver settings before testing the connection.'); + return; + } + this.disabled = true; + call('testConnection', function(r) { + if (r.returnValue === false) showActionError(r.errorText); + call('status', display); + }); +}; +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) { + showActionError('Custom profile must be valid JSON.'); + return; + } + } + button.disabled = true; + call('calibration', function(r) { + button.disabled = false; + if (r.returnValue === false) { + showActionError(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); diff --git a/app/appinfo.json b/app/appinfo.json index 11e45ad..da045a5 100644 --- a/app/appinfo.json +++ b/app/appinfo.json @@ -1 +1,14 @@ -{"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"} +{ + "id": "org.webosbrew.histogramhyperion", + "version": "0.12.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 index 54084ef..1940c1c 100644 --- a/app/calibration.js +++ b/app/calibration.js @@ -1,35 +1,81 @@ /* 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 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;}); + +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/navigation.js b/app/navigation.js index b6377bc..b4f7474 100644 --- a/app/navigation.js +++ b/app/navigation.js @@ -1,21 +1,67 @@ -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(); +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();}}); +['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); +setupFormEditors(['host', 'port', 'piccapPort', 'token', 'customProfile'], document, window); diff --git a/app/remote.js b/app/remote.js index 72cbda6..ace8ec9 100644 --- a/app/remote.js +++ b/app/remote.js @@ -1,32 +1,49 @@ /* Directional control for webOS remotes and standard keyboards. */ function setupRemoteNavigation(buttons, doc, win) { - var lastFocused=buttons[0], enterDown=false; - buttons.forEach(function(button){ - button.addEventListener('focus',function(){lastFocused=button;}); - button.addEventListener('mouseenter',function(){if(!button.disabled)button.focus();}); - }); - doc.addEventListener('keydown',function(event){ - var code=event.keyCode||event.which; - var direction=event.key==='ArrowLeft'||event.key==='ArrowUp'||code===37||code===38?-1: - 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.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 && (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){ - if(index<0){index=enabled.indexOf(lastFocused);if(index<0)index=0;enabled[index].focus();} - else enabled[(index+direction+enabled.length)%enabled.length].focus(); - }else{ - if(enterDown||event.repeat)return; - enterDown=true; - if(index<0){index=enabled.indexOf(lastFocused);enabled[index<0?0:index].focus();} - else focused.click(); - } - }); - doc.addEventListener('keyup',function(event){if(event.key==='Enter'||event.keyCode===13||event.which===13)enterDown=false;}); - win.addEventListener('blur',function(){enterDown=false;}); + var lastFocused = buttons[0], + enterDown = false; + buttons.forEach(function(button) { + button.addEventListener('focus', function() { + lastFocused = button; + }); + button.addEventListener('mouseenter', function() { + if (!button.disabled) button.focus(); + }); + }); + doc.addEventListener('keydown', function(event) { + var code = event.keyCode || event.which; + var direction = event.key === 'ArrowLeft' || event.key === 'ArrowUp' || code === 37 || code === 38 ? -1 : + 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.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 && (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) { + if (index < 0) { + index = enabled.indexOf(lastFocused); + if (index < 0) index = 0; + enabled[index].focus(); + } else enabled[(index + direction + enabled.length) % enabled.length].focus(); + } else { + if (enterDown || event.repeat) return; + enterDown = true; + if (index < 0) { + index = enabled.indexOf(lastFocused); + enabled[index < 0 ? 0 : index].focus(); + } else focused.click(); + } + }); + doc.addEventListener('keyup', function(event) { + if (event.key === 'Enter' || event.keyCode === 13 || event.which === 13) enterDown = false; + }); + win.addEventListener('blur', function() { + enterDown = false; + }); } diff --git a/app/wizard.js b/app/wizard.js index 1f66c98..01f029c 100644 --- a/app/wizard.js +++ b/app/wizard.js @@ -1,87 +1,208 @@ '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(); +var WizardPhase = { + STARTING: 'starting', + WAITING: 'waiting', + MEASURING: 'measuring', + VALIDATING: 'validating', + COMMITTING: 'committing' +}; +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 wizardSample() { + if (!wizard || wizard.busy) return; + var session = wizard; + if (Date.now() - wizard.started > 100000) { + wizardFinish('Calibration timed out. Saved profile unchanged.'); + return; + } + if (wizard.phase !== WizardPhase.MEASURING) 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; + wizard.phase = WizardPhase.COMMITTING; + call('calibrationDraft', function(r) { + if (wizard === session) 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 !== session) 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.'); - }); - } + +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 = { + phase: WizardPhase.STARTING, + validation: validation, + started: Date.now(), + busy: false, + sequence: -1, + model: '', + baseline: [], + patches: Array.from({ + length: 32 + }, function() { + return []; + }) + }; + var session = wizard; + 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 !== session) 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() { + if (wizard !== session) return; + wizardFinish('The TV could not play the calibration video. Saved profile unchanged.'); + }); + wizard.phase = WizardPhase.WAITING; + call('start', function() { + if (wizard === session) waitCapture(); + }); + }, { + enabled: true + }); + + function waitCapture() { + if (wizard !== session) return; + call('status', function(s) { + if (wizard !== session) return; + if (s.running && s.activeSource !== 'PicCap' && s.tvModel && s.colorValid) { + wizard.phase = validation ? WizardPhase.VALIDATING : WizardPhase.MEASURING; + 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() { + if (wizard !== session) return; + 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();}); +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);}); +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_manifest.py b/build_manifest.py index 8ec192f..c584a8a 100644 --- a/build_manifest.py +++ b/build_manifest.py @@ -1,6 +1,8 @@ """Describe the exact built IPK for Homebrew Channel.""" import hashlib, json from pathlib import Path +from sync_version import synchronize +synchronize(check=True) ROOT=Path(__file__).resolve().parent BASE='https://gitea.adnau.de/matze19999/HyperDRM' RAW=BASE+'/raw/branch/main/' diff --git a/build_package.py b/build_package.py index 6e9ddab..32c692c 100644 --- a/build_package.py +++ b/build_package.py @@ -1,16 +1,18 @@ """Package own app and executable; no Signify or LG binaries are included.""" -import io, json, tarfile +import gzip, io, json, tarfile from pathlib import Path +from sync_version import synchronize +synchronize(check=True) ROOT=Path(__file__).resolve().parent ID='org.webosbrew.histogramhyperion' VERSION=json.loads((ROOT/'app/appinfo.json').read_text(encoding='utf-8'))['version'] def tar_bytes(entries): stream=io.BytesIO() - with tarfile.open(fileobj=stream,mode='w:gz') as tar: + with tarfile.open(fileobj=stream,mode='w') as tar: for name,body,mode in entries: info=tarfile.TarInfo(name);info.size=len(body);info.mode=mode;info.mtime=0 tar.addfile(info,io.BytesIO(body)) - return stream.getvalue() + return gzip.compress(stream.getvalue(), mtime=0) entries=[] for directory,target in [('app','applications/'+ID),('service','services/'+ID+'.service'),('package','packages/'+ID)]: for path in sorted((ROOT/directory).iterdir()): diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..985fd1c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,46 @@ +# Runtime boundaries + +The native service remains one translation unit because LG symbols are resolved +at runtime. Its implementation headers share the context declared in service.c; +they are included once in dependency order, not independent public libraries. +Formatting must preserve include order (`SortIncludes: Never`). + +| Module | Responsibility | +| --- | --- | +| service.c | Runtime context, LG/Luna loading, registration and shutdown | +| service_api.h | Start/stop, root restart, settings and autostart actions | +| service_calibration.h | Bounded calibration lease, proposals, profiles, model discovery | +| service_status.h | Read-only status serialization | +| capture_loop.h | Scheduler, histogram acquisition, missing-data recovery and output | +| receiver_stream.h | Independent, nonblocking receiver LED subscription | +| source_bridge.h | Bounded color continuity during source transitions | +| handover.h | Exclusive PicCap/histogram capture ownership | + +Connection stages are explicitly named in runtime_states.h: +Disconnected → Connecting → Authenticating (when configured) → Layout → Subscribed. +Any connection, authorization or parsing failure closes the connection and +schedules a bounded retry. Up to eight buffered messages are consumed per tick, +including ticks where no new bytes arrive. Buffer capacity and read budgets remain +bounded. LED count/profile changes trigger a fresh layout subscription. + +The color bridge uses Idle → Holding → WaitingFrame → Fading → Idle. It holds the +last histogram frame while PicCap starts, clears its priority before observing a +fresh PicCap frame, then blends for 500 ms. The transition expires after 3 seconds; +real black bypasses the fade. The opposite direction seeds histogram continuity +from a fresh receiver frame. Capture ownership remains in handover.h; the observer +never acquires LG capture. Stop cancels the bridge and releases owned resources. + +The UI calibration session uses Starting → Waiting → Measuring/Validating → +Committing → finished. Each asynchronous callback checks its original session +identity, so a cancelled session cannot modify a newly started session. Proposals +remain unsaved until the user selects Save profile. The native lease still bounds +the session if the UI disappears. + +UI action errors take precedence over transient status errors. Polling cannot +erase them; starting the next action clears them. Receiver settings, capture state, +calibration state and presentation errors remain distinct. + +VERSION generates app/package metadata and native/version.h. release.py tests +portable logic locally, builds the ARM service and verifies the exact package. +Hardware capture and Magic Remote behavior still require a later TV check; a host +test is not evidence of LG hardware behavior. diff --git a/docs/publishing-review.md b/docs/publishing-review.md index 6551c70..3c98eec 100644 --- a/docs/publishing-review.md +++ b/docs/publishing-review.md @@ -136,3 +136,24 @@ 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. + +## Version 0.12.0 + +Receiver stream processing now drains complete buffered messages when no new +bytes arrive. A regression fixture buffers seventeen updates and verifies all +three processing batches without additional socket writes. Action errors survive +status polling and are cleared by the next user action. Tests also cover delayed +calibration replies after cancellation and a new session. + +Connection and bridge states are named enums; UI calibration phases and session +identity checks isolate asynchronous work. The native service is separated into +runtime bootstrap, Luna actions, calibration, status and capture-loop modules. +Production C and JavaScript are formatted consistently; include dependency order +is preserved explicitly. + +VERSION generates app/package/runtime versions. release.py passes all JavaScript, +portable native and Python version-drift tests, cross-compiles the ARM service and +checks the IPK's embedded metadata, executable architecture, size and SHA-256. +Package timestamps are deterministic. No TV installation, application launch or +service restart was performed for this version while the user was watching a film. +Live LG capture and physical remote validation remain pending for this release. diff --git a/docs/release-0.12.0.md b/docs/release-0.12.0.md new file mode 100644 index 0000000..ecafc67 --- /dev/null +++ b/docs/release-0.12.0.md @@ -0,0 +1,11 @@ +# HyperDRM 0.12.0 + +- Process buffered receiver updates even when no additional network data arrives. +- Keep action errors visible across background status polling. +- Name connection, transition and calibration states; ignore stale calibration callbacks. +- Split and format native service modules and application JavaScript. +- Generate versions from VERSION and add a tested, reproducible release workflow. + +Validation: all portable native, JavaScript and Python version tests pass; the +ARM IPK's embedded versions, architecture, size and SHA-256 match its manifest. +This release has not yet been installed or validated on the TV. diff --git a/native/autostart.h b/native/autostart.h index 5edf6e1..314c80e 100644 --- a/native/autostart.h +++ b/native/autostart.h @@ -4,34 +4,66 @@ #define BOOT_DIRECTORY "/var/lib/webosbrew/init.d" #endif #ifndef BOOT_SCRIPT -#define BOOT_SCRIPT "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/boot.sh" +#define BOOT_SCRIPT \ + "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/boot.sh" #endif #define BOOT_HOOK BOOT_DIRECTORY "/60-histogramhyperion" /* Exact pre-0.6.1 hook; only this known own file may be migrated. */ -static const char legacy_boot_script[] = "#!/bin/sh\n# Histogram Hyperion own boot hook v1\n(\n attempt=0\n while [ -x /var/lib/webosbrew/init.d/60-histogramhyperion ] && [ \"$attempt\" -lt 120 ]; do\n result=$(timeout 12 luna-send -n 1 luna://org.webosbrew.histogramhyperion.service/start '{}' 2>&1)\n case \"$result\" in\n *'\"running\":true'*) exit 0 ;;\n esac\n attempt=$((attempt + 1))\n sleep 5\n done\n echo 'Histogram Hyperion boot start cancelled or timed out'\n) >/var/lib/webosbrew/histogramhyperion-boot.log 2>&1 &1)\n case \"$result\" in\n *'\"running\":true'*) exit 0 ;;\n esac\n " + "attempt=$((attempt + 1))\n sleep 5\n done\n echo 'Histogram Hyperion boot start " + "cancelled or timed out'\n) >/var/lib/webosbrew/histogramhyperion-boot.log 2>&1 0 && access(BOOT_HOOK,X_OK)==0;} -static bool set_boot(bool enabled){ - int kind=boot_hook_kind();if(kind<0)return false; - if(!enabled)return kind==0 || unlink(BOOT_HOOK)==0; - if(access(BOOT_SCRIPT,X_OK)!=0)return false; - if(kind==2)return true; - if(mkdir(BOOT_DIRECTORY,0755)!=0 && errno!=EEXIST)return false; - /* Never overwrite another process's temporary file or a foreign hook. */ - if(symlink(BOOT_SCRIPT,BOOT_HOOK ".tmp")!=0)return false; - bool ok=rename(BOOT_HOOK ".tmp",BOOT_HOOK)==0; - if(!ok)unlink(BOOT_HOOK ".tmp"); - return ok; +static bool boot_enabled(void) { + return boot_hook_kind() > 0 && access(BOOT_HOOK, X_OK) == 0; +} +static bool set_boot(bool enabled) { + int kind = boot_hook_kind(); + if (kind < 0) + return false; + if (!enabled) + return kind == 0 || unlink(BOOT_HOOK) == 0; + if (access(BOOT_SCRIPT, X_OK) != 0) + return false; + if (kind == 2) + return true; + if (mkdir(BOOT_DIRECTORY, 0755) != 0 && errno != EEXIST) + return false; + /* Never overwrite another process's temporary file or a foreign hook. */ + if (symlink(BOOT_SCRIPT, BOOT_HOOK ".tmp") != 0) + return false; + bool ok = rename(BOOT_HOOK ".tmp", BOOT_HOOK) == 0; + if (!ok) + unlink(BOOT_HOOK ".tmp"); + return ok; } #endif diff --git a/native/blackbars.h b/native/blackbars.h index 49060e0..dee18c3 100644 --- a/native/blackbars.h +++ b/native/blackbars.h @@ -2,18 +2,25 @@ #define BLACKBARS_H /* Calibrated preset-0 edge probes on this G4. Return -1 for an ambiguous * or dark frame so darkness alone cannot switch the active capture mode. */ -static inline int letterbox_candidate(const unsigned black[16],const unsigned char color[16][3]) { - const unsigned interior[]={2,6,10,15};unsigned lit=0; - for(unsigned i=0;i<4;i++){ - unsigned r=interior[i],peak=color[r][0]; - for(unsigned c=1;c<3;c++)if(color[r][c]>peak)peak=color[r][c]; - if(black[r]<20 && peak>35)lit++; - } - if(lit<2)return -1; - unsigned top=(black[3]+black[11]+black[12])/3; - unsigned bottom=(black[1]+black[5]+black[9])/3; - if(top>=55 && bottom>=25)return 1; - if(top<20 && bottom<20)return 0; - return -1; +static inline int letterbox_candidate(const unsigned black[16], const unsigned char color[16][3]) { + const unsigned interior[] = {2, 6, 10, 15}; + unsigned lit = 0; + for (unsigned i = 0; i < 4; i++) { + unsigned r = interior[i], peak = color[r][0]; + for (unsigned c = 1; c < 3; c++) + if (color[r][c] > peak) + peak = color[r][c]; + if (black[r] < 20 && peak > 35) + lit++; + } + if (lit < 2) + return -1; + unsigned top = (black[3] + black[11] + black[12]) / 3; + unsigned bottom = (black[1] + black[5] + black[9]) / 3; + if (top >= 55 && bottom >= 25) + return 1; + if (top < 20 && bottom < 20) + return 0; + return -1; } #endif diff --git a/native/calibration.h b/native/calibration.h index 27010dd..823c59f 100644 --- a/native/calibration.h +++ b/native/calibration.h @@ -2,83 +2,155 @@ #define HYPERDRM_CALIBRATION_H #include #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; +#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 unsigned calibration_generation = 1; +static inline unsigned calibration_revision(void) { + return calibration_generation; } -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; +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 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 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 && iat(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_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;icount;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 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_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; +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 diff --git a/native/capture_loop.h b/native/capture_loop.h new file mode 100644 index 0000000..c8e65dc --- /dev/null +++ b/native/capture_loop.h @@ -0,0 +1,150 @@ +/* Capture scheduler and histogram processing. */ +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); + 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())) { + start(NULL, NULL, NULL); + if (!capture) + retry_failure(&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 (!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); + 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; + for (int c = 0; c < 3; c++) { + uint64_t total = 0, black = 0; + for (int b = 0; b < 256; b++) { + total += hist[i].bins[c][b]; + if (b <= 12) + black += hist[i].bins[c][b]; + } + unsigned fraction = total ? (unsigned)(100 * black / total) : 0; + if (fraction < black_percent[i]) + black_percent[i] = fraction; + } + } + 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 (!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; + } else if ((unsigned)candidate != letterbox_mode && now - candidate_since >= 2000) + letterbox_mode = (unsigned)candidate; + if (letterbox_mode) { + if (vt_preset(capture, 1) != 0) { + set_error("Letterbox preset failed"); + stop_capture(); + return 1; + } + capture_preset = 1; + settling = 2; + next_border_probe = now + 2000; + return 1; + } + } + 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], 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; + char *line = region_command(stable_rgb, led_blends, led_count, 1500); + if (!line) { + set_error("Color buffer allocation failed"); + stop_capture(); + return 1; + } + int failed = send_line(line); + free(line); + if (failed) { + set_error("Receiver connection lost"); + stop_capture(); + return 1; + } + if (!receiver_ack()) { + set_error("Receiver did not acknowledge color data"); + stop_capture(); + return 1; + } + last_error[0] = 0; + color_valid = true; + last_output_at = milliseconds(); + frames++; + return 1; +} diff --git a/native/color_boost.h b/native/color_boost.h index 940f0c6..7d7a999 100644 --- a/native/color_boost.h +++ b/native/color_boost.h @@ -1,7 +1,8 @@ #ifndef COLOR_BOOST_H #define COLOR_BOOST_H /* Preserve the original saturation and brightness. */ -static inline void boost_color(const unsigned char in[3],unsigned char out[3]){ - for(unsigned c=0;c<3;c++)out[c]=in[c]; +static inline void boost_color(const unsigned char in[3], unsigned char out[3]) { + for (unsigned c = 0; c < 3; c++) + out[c] = in[c]; } #endif diff --git a/native/continuity.h b/native/continuity.h index 9594e05..39c731c 100644 --- a/native/continuity.h +++ b/native/continuity.h @@ -1,35 +1,76 @@ #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; +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; +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 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; +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/diagnostics.h b/native/diagnostics.h index 061fb2f..fcd5f47 100644 --- a/native/diagnostics.h +++ b/native/diagnostics.h @@ -2,77 +2,184 @@ #define HYPERDRM_DIAGNOSTICS_H /* An independent nonblocking connection. Tests never send colors or acquire * 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; typedef struct { - int fd;TestStage stage,after_send;TargetConfig target; - uint64_t started,deadline,elapsed;unsigned leds; - char request[384],error[128];size_t sent,used;char *response; - LedBlend *mapping; + int fd; + TestStage stage, after_send; + TargetConfig target; + uint64_t started, deadline, elapsed; + unsigned leds; + char request[384], error[128]; + size_t sent, used; + char *response; + LedBlend *mapping; } 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"}; - return names[probe->stage]; +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"}; + return names[probe->stage]; } -static bool probe_busy(const ReceiverProbe *probe){return probe->stage>=TEST_CONNECT && probe->stage<=TEST_LAYOUT;} -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 probe_finish(ReceiverProbe *probe,const char *error,uint64_t now){ - probe_close(probe);probe->elapsed=now-probe->started; - probe->stage=error?TEST_FAILED:TEST_DONE; - snprintf(probe->error,sizeof(probe->error),"%s",error?error:""); +static bool probe_busy(const ReceiverProbe *probe) { + return probe->stage >= TEST_CONNECT && probe->stage <= TEST_LAYOUT; } -static void probe_request(ReceiverProbe *probe,bool login,uint64_t now){ - if(login)snprintf(probe->request,sizeof(probe->request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",probe->target.token); - else snprintf(probe->request,sizeof(probe->request),"{\"command\":\"serverinfo\"}\n"); - probe->sent=probe->used=0;probe->after_send=login?TEST_LOGIN:TEST_LAYOUT; - probe->stage=TEST_SEND;probe->deadline=now+3000; +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 bool probe_begin(ReceiverProbe *probe,const TargetConfig *target,uint64_t now){ - if(probe_busy(probe))return false; - probe_close(probe);memset(probe,0,sizeof(*probe));probe->fd=-1; - probe->target=*target;probe->started=now; - probe->response=malloc(2*1024*1024+1); - if(!probe->response){probe_finish(probe,"Cannot allocate connection test buffer",now);return true;} - probe->fd=target_open(target); - if(probe->fd<0){probe_finish(probe,"Cannot open receiver connection",now);return true;} - probe->stage=TEST_CONNECT;probe->deadline=now+3000;return true; +static void probe_finish(ReceiverProbe *probe, const char *error, uint64_t now) { + probe_close(probe); + probe->elapsed = now - probe->started; + probe->stage = error ? TEST_FAILED : TEST_DONE; + snprintf(probe->error, sizeof(probe->error), "%s", error ? error : ""); } -static void probe_tick(ReceiverProbe *probe,PlaybackJson *json,uint64_t now){ - if(!probe_busy(probe))return; - if(now>=probe->deadline){probe_finish(probe,"Receiver timed out",now);return;} - int fd=probe->fd; - if(probe->stage==TEST_CONNECT){ - struct pollfd p={fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error); - if(poll(&p,1,0)<=0)return; - if(getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){probe_finish(probe,"Receiver connection refused or unreachable",now);return;} - probe_request(probe,probe->target.token[0]!=0,now);return; - } - if(probe->stage==TEST_SEND){ - size_t length=strlen(probe->request); - 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){probe_finish(probe,"Receiver closed the test connection",now);return;} - probe->sent+=(size_t)n; - if(probe->sent==length){probe->stage=probe->after_send;probe->deadline=now+3000;memset(probe->request,0,sizeof(probe->request));} - return; - } - size_t available=2*1024*1024-probe->used;if(available>65536)available=65536; - if(!available){probe_finish(probe,"Receiver response exceeds 2 MiB",now);return;} - ssize_t n=recv(fd,probe->response+probe->used,available,0); - if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; - if(n<=0){probe_finish(probe,"Receiver closed the test connection",now);return;} - probe->used+=(size_t)n;probe->response[probe->used]=0; - char *end=strchr(probe->response,'\n');if(!end)return;*end=0; - void *root=json->parse(probe->response);bool success=playback_true(json,root,"success");if(root)json->put(root); - if(!success){probe_finish(probe,probe->stage==TEST_LOGIN?"Receiver authentication failed":"Receiver rejected serverinfo; check authorization",now);return;} - if(probe->stage==TEST_LOGIN){probe_request(probe,false,now);return;} - LedBlend *mapping=NULL;unsigned count=0; - if(!layout_from_json(probe->response,&mapping,&count)){probe_finish(probe,"Receiver returned an invalid or empty LED layout",now);return;} - probe->leds=count;probe_finish(probe,NULL,now);probe->mapping=mapping; +static void probe_request(ReceiverProbe *probe, bool login, uint64_t now) { + if (login) + snprintf(probe->request, sizeof(probe->request), + "{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n", + probe->target.token); + else + snprintf(probe->request, sizeof(probe->request), "{\"command\":\"serverinfo\"}\n"); + probe->sent = probe->used = 0; + probe->after_send = login ? TEST_LOGIN : TEST_LAYOUT; + probe->stage = TEST_SEND; + probe->deadline = now + 3000; +} +static bool probe_begin(ReceiverProbe *probe, const TargetConfig *target, uint64_t now) { + if (probe_busy(probe)) + return false; + probe_close(probe); + memset(probe, 0, sizeof(*probe)); + probe->fd = -1; + probe->target = *target; + probe->started = now; + probe->response = malloc(2 * 1024 * 1024 + 1); + if (!probe->response) { + probe_finish(probe, "Cannot allocate connection test buffer", now); + return true; + } + probe->fd = target_open(target); + if (probe->fd < 0) { + probe_finish(probe, "Cannot open receiver connection", now); + return true; + } + probe->stage = TEST_CONNECT; + probe->deadline = now + 3000; + return true; +} +static void probe_tick(ReceiverProbe *probe, PlaybackJson *json, uint64_t now) { + if (!probe_busy(probe)) + return; + if (now >= probe->deadline) { + probe_finish(probe, "Receiver timed out", now); + return; + } + int fd = probe->fd; + if (probe->stage == TEST_CONNECT) { + struct pollfd p = {fd, POLLOUT, 0}; + int error = 0; + socklen_t size = sizeof(error); + if (poll(&p, 1, 0) <= 0) + return; + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &size) != 0 || error) { + probe_finish(probe, "Receiver connection refused or unreachable", now); + return; + } + probe_request(probe, probe->target.token[0] != 0, now); + return; + } + if (probe->stage == TEST_SEND) { + size_t length = strlen(probe->request); + 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) { + probe_finish(probe, "Receiver closed the test connection", now); + return; + } + probe->sent += (size_t)n; + if (probe->sent == length) { + probe->stage = probe->after_send; + probe->deadline = now + 3000; + memset(probe->request, 0, sizeof(probe->request)); + } + return; + } + size_t available = 2 * 1024 * 1024 - probe->used; + if (available > 65536) + available = 65536; + if (!available) { + probe_finish(probe, "Receiver response exceeds 2 MiB", now); + return; + } + ssize_t n = recv(fd, probe->response + probe->used, available, 0); + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR)) + return; + if (n <= 0) { + probe_finish(probe, "Receiver closed the test connection", now); + return; + } + probe->used += (size_t)n; + probe->response[probe->used] = 0; + char *end = strchr(probe->response, '\n'); + if (!end) + return; + *end = 0; + void *root = json->parse(probe->response); + bool success = playback_true(json, root, "success"); + if (root) + json->put(root); + if (!success) { + probe_finish(probe, + probe->stage == TEST_LOGIN + ? "Receiver authentication failed" + : "Receiver rejected serverinfo; check authorization", + now); + return; + } + if (probe->stage == TEST_LOGIN) { + probe_request(probe, false, now); + return; + } + LedBlend *mapping = NULL; + unsigned count = 0; + if (!layout_from_json(probe->response, &mapping, &count)) { + probe_finish(probe, "Receiver returned an invalid or empty LED layout", now); + return; + } + 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); } -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 diff --git a/native/handover.h b/native/handover.h index 17abc0d..99a06fb 100644 --- a/native/handover.h +++ b/native/handover.h @@ -5,22 +5,32 @@ #define CALIBRATION_ACTIVE false #endif #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 -#define HANDOVER_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/handover.json" +#define HANDOVER_PREF \ + "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/" \ + "handover.json" static PlaybackJson playback_json; -static bool handover_enabled,piccap_owned,piccap_busy,media_busy,piccap_connected; +static bool handover_enabled, piccap_owned, piccap_busy, media_busy, piccap_connected; static PlaybackProtection protection; -static uint64_t protection_at,next_media_probe,next_piccap_probe,handover_cooldown,piccap_action_at; -typedef enum {SOURCE_HISTOGRAM,SOURCE_PICCAP_STARTING,SOURCE_PICCAP,SOURCE_PICCAP_STOPPING} CaptureSource; +static uint64_t protection_at, next_media_probe, next_piccap_probe, handover_cooldown, + piccap_action_at; +typedef enum { + SOURCE_HISTOGRAM, + SOURCE_PICCAP_STARTING, + SOURCE_PICCAP, + SOURCE_PICCAP_STOPPING +} CaptureSource; static CaptureSource capture_source; static void *service_handle; -static bool (*call_once)(void*,const char*,const char*,Callback,void*,unsigned long*,LSError*); -static bool (*cancel_call)(void*,unsigned long,LSError*); -static unsigned long piccap_token,media_token; -static uint64_t piccap_deadline,media_deadline; -static unsigned clear_samples,piccap_fallbacks; -static uint64_t clear_since,unknown_since,piccap_disconnected_since; +static bool (*call_once)(void *, const char *, const char *, Callback, void *, unsigned long *, + LSError *); +static bool (*cancel_call)(void *, unsigned long, LSError *); +static unsigned long piccap_token, media_token; +static uint64_t piccap_deadline, media_deadline; +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 @@ -28,105 +38,245 @@ static uint64_t clear_since,unknown_since,piccap_disconnected_since; static TargetConfig piccap_target; static char piccap_error[128]; static bool piccap_request(int command); -static inline bool piccap_installed(void){return access(PICCAP_BINARY,X_OK)==0;} -static bool handover_save(bool enabled){ - const char *data=enabled?"{\"piccapHandover\":true}\n":"{\"piccapHandover\":false}\n"; - int fd=open(HANDOVER_PREF ".tmp",O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);if(fd<0)return false; - ssize_t n=write(fd,data,strlen(data));bool ok=n==(ssize_t)strlen(data) && fsync(fd)==0; - if(close(fd)!=0)ok=false; - if(ok)ok=rename(HANDOVER_PREF ".tmp",HANDOVER_PREF)==0; - if(!ok)unlink(HANDOVER_PREF ".tmp"); - return ok; +static inline bool piccap_installed(void) { + return access(PICCAP_BINARY, X_OK) == 0; } -static void handover_load(void){ - int fd=open(HANDOVER_PREF,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return; - char text[128];ssize_t n=read(fd,text,sizeof(text)-1);close(fd); - if(n>0){text[n]=0;void *root=playback_json.parse(text);handover_enabled=playback_true(&playback_json,root,"piccapHandover");if(root)playback_json.put(root);} +static bool handover_save(bool enabled) { + const char *data = enabled ? "{\"piccapHandover\":true}\n" : "{\"piccapHandover\":false}\n"; + int fd = open(HANDOVER_PREF ".tmp", O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) + return false; + ssize_t n = write(fd, data, strlen(data)); + bool ok = n == (ssize_t)strlen(data) && fsync(fd) == 0; + if (close(fd) != 0) + ok = false; + if (ok) + ok = rename(HANDOVER_PREF ".tmp", HANDOVER_PREF) == 0; + if (!ok) + unlink(HANDOVER_PREF ".tmp"); + return ok; } -static bool media_reply(void *h,void *m,void *ctx){ - (void)h;(void)ctx;media_busy=false;media_token=0; - protection=playback_classify(&playback_json,payload(m));protection_at=milliseconds(); - 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; +static void handover_load(void) { + int fd = open(HANDOVER_PREF, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return; + char text[128]; + ssize_t n = read(fd, text, sizeof(text) - 1); + close(fd); + if (n > 0) { + text[n] = 0; + void *root = playback_json.parse(text); + handover_enabled = playback_true(&playback_json, root, "piccapHandover"); + if (root) + playback_json.put(root); + } } -/* Commands: 0 status, 1 start, 2 stop, 3 getSettings, 4 setSettings. Confirm stopped status before VT reuse. */ -static bool piccap_reply(void *h,void *m,void *ctx){ - (void)h;int command=(int)(intptr_t)ctx;piccap_busy=false;piccap_token=0; - void *root=playback_json.parse(payload(m));bool ok=playback_true(&playback_json,root,"returnValue"); - if(command==0){ - void *running=playback_field(&playback_json,root,"isRunning"); - if(ok && running && playback_json.type(running)==1){ - bool active=playback_json.boolean(running); - piccap_connected=playback_true(&playback_json,root,"connected"); - 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){ - if(capture_source!=SOURCE_PICCAP_STOPPING)handover_cooldown=milliseconds()+10000; - piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;piccap_disconnected_since=0; - if(handover_cooldown=3 && capture_source==SOURCE_PICCAP_STOPPING)handover_cooldown=milliseconds()+10000; - if(root)playback_json.put(root); - next_piccap_probe=milliseconds()+500; - if(command==2)piccap_request(0); - return true; +static bool media_reply(void *h, void *m, void *ctx) { + (void)h; + (void)ctx; + media_busy = false; + media_token = 0; + protection = playback_classify(&playback_json, payload(m)); + protection_at = milliseconds(); + 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; } -static bool piccap_request(int command){ - if(piccap_busy)return false; - const char *uri=command==3?"luna://org.webosbrew.piccap.service/getSettings":command==4?"luna://org.webosbrew.piccap.service/setSettings":command==1?"luna://org.webosbrew.piccap.service/start":command==2?"luna://org.webosbrew.piccap.service/stop":"luna://org.webosbrew.piccap.service/status"; - LSError e;error_init(&e); - char data[160];snprintf(data,sizeof(data),"{\"address\":\"%s\",\"port\":%u,\"unix-socket\":false}",target_piccap_host(&piccap_target),piccap_target.piccap_port); - piccap_busy=call_once(service_handle,uri,command==4?data:"{}",piccap_reply,(void*)(intptr_t)command,&piccap_token,&e); - piccap_deadline=milliseconds()+3000; - error_free(&e);if(command)piccap_action_at=milliseconds();return piccap_busy; +/* Commands: 0 status, 1 start, 2 stop, 3 getSettings, 4 setSettings. Confirm stopped status before + * VT reuse. */ +static bool piccap_reply(void *h, void *m, void *ctx) { + (void)h; + int command = (int)(intptr_t)ctx; + piccap_busy = false; + piccap_token = 0; + void *root = playback_json.parse(payload(m)); + bool ok = playback_true(&playback_json, root, "returnValue"); + if (command == 0) { + void *running = playback_field(&playback_json, root, "isRunning"); + if (ok && running && playback_json.type(running) == 1) { + bool active = playback_json.boolean(running); + piccap_connected = playback_true(&playback_json, root, "connected"); + 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) { + if (capture_source != SOURCE_PICCAP_STOPPING) + handover_cooldown = milliseconds() + 10000; + 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(); + } + } else if (!piccap_installed()) { + piccap_owned = false; + capture_source = SOURCE_HISTOGRAM; + piccap_connected = false; + } + } else if (command == 3 && capture_source == SOURCE_PICCAP_STARTING) { + void *port = playback_field(&playback_json, root, "port"), + *socket = playback_field(&playback_json, root, "unix-socket"); + bool same = + ok && + playback_is(&playback_json, root, "address", target_piccap_host(&piccap_target)) && + port && playback_json.type(port) == 3 && + strtoul(playback_json.string(port), NULL, 10) == piccap_target.piccap_port && socket && + playback_json.type(socket) == 1 && !playback_json.boolean(socket); + if (ok) { + if (!piccap_request(same ? 1 : 4)) { + capture_source = SOURCE_PICCAP_STOPPING; + snprintf(piccap_error, sizeof(piccap_error), "PicCap endpoint request failed"); + } + } else { + capture_source = SOURCE_PICCAP_STOPPING; + snprintf(piccap_error, sizeof(piccap_error), "Cannot read PicCap settings"); + } + } else if (command == 4) { + if (!ok) { + capture_source = SOURCE_PICCAP_STOPPING; + snprintf(piccap_error, sizeof(piccap_error), "PicCap rejected receiver settings"); + } + /* setSettings restarts PicCap itself; status confirms ownership. */ + } else if (command == 1 && !ok) { + capture_source = SOURCE_PICCAP_STOPPING; + handover_cooldown = milliseconds() + 10000; + } + if (command >= 3 && capture_source == SOURCE_PICCAP_STOPPING) + handover_cooldown = milliseconds() + 10000; + if (root) + playback_json.put(root); + next_piccap_probe = milliseconds() + 500; + if (command == 2) + piccap_request(0); + return true; +} +static bool piccap_request(int command) { + if (piccap_busy) + return false; + const char *uri = command == 3 ? "luna://org.webosbrew.piccap.service/getSettings" + : command == 4 ? "luna://org.webosbrew.piccap.service/setSettings" + : command == 1 ? "luna://org.webosbrew.piccap.service/start" + : command == 2 ? "luna://org.webosbrew.piccap.service/stop" + : "luna://org.webosbrew.piccap.service/status"; + LSError e; + error_init(&e); + char data[160]; + snprintf(data, sizeof(data), "{\"address\":\"%s\",\"port\":%u,\"unix-socket\":false}", + target_piccap_host(&piccap_target), piccap_target.piccap_port); + piccap_busy = call_once(service_handle, uri, command == 4 ? data : "{}", piccap_reply, + (void *)(intptr_t)command, &piccap_token, &e); + piccap_deadline = milliseconds() + 3000; + error_free(&e); + if (command) + piccap_action_at = milliseconds(); + return piccap_busy; } /* Return true while PicCap owns capture or a stop confirmation is pending. */ -static bool handover_tick(void){ - 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;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(handover_enabled && piccap_installed() && !retries.paused && playback_json.lib && !media_busy && now>=next_media_probe){ - LSError e;error_init(&e); - media_busy=call_once(service_handle,"luna://com.webos.media/getActivePipelines","{}",media_reply,NULL,&media_token,&e); - media_deadline=now+3000; - 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=!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;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=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(capture_source==SOURCE_PICCAP_STOPPING)piccap_request(2);else piccap_request(0); - next_piccap_probe=now+1000; - } - return piccap_owned; +static bool handover_tick(void) { + 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; + 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 (handover_enabled && piccap_installed() && !retries.paused && playback_json.lib && + !media_busy && now >= next_media_probe) { + LSError e; + error_init(&e); + media_busy = call_once(service_handle, "luna://com.webos.media/getActivePipelines", "{}", + media_reply, NULL, &media_token, &e); + media_deadline = now + 3000; + 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 = !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; + 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 < 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 (capture_source == SOURCE_PICCAP_STOPPING) + piccap_request(2); + else + piccap_request(0); + next_piccap_probe = now + 1000; + } + return piccap_owned; } #endif diff --git a/native/histogram.h b/native/histogram.h index a3a3d16..02d9ecc 100644 --- a/native/histogram.h +++ b/native/histogram.h @@ -4,12 +4,18 @@ #include /* LG ABI: region ID followed by three planar channels, 256 uint16 bins * per channel. Bin index is the 8-bit intensity (0..255). */ -typedef struct { uint32_t id; uint16_t bins[3][256]; } Histogram; -_Static_assert(sizeof(Histogram)==0x604,"LG ABI record size"); -_Static_assert(offsetof(Histogram,bins)==4,"LG ABI channel offset"); +typedef struct { + uint32_t id; + uint16_t bins[3][256]; +} Histogram; +_Static_assert(sizeof(Histogram) == 0x604, "LG ABI record size"); +_Static_assert(offsetof(Histogram, bins) == 4, "LG ABI channel offset"); static inline unsigned char histogram_mean(const uint16_t bins[256]) { - uint64_t count=0,weight=0; - for(unsigned b=0;b<256;b++){count+=bins[b];weight+=(uint64_t)bins[b]*b;} - return count?(unsigned char)((weight+count/2)/count):0; + uint64_t count = 0, weight = 0; + for (unsigned b = 0; b < 256; b++) { + count += bins[b]; + weight += (uint64_t)bins[b] * b; + } + return count ? (unsigned char)((weight + count / 2) / count) : 0; } #endif diff --git a/native/interpolation.h b/native/interpolation.h index c454107..3b4f5c1 100644 --- a/native/interpolation.h +++ b/native/interpolation.h @@ -1,6 +1,10 @@ #ifndef SPATIAL_INTERPOLATION_H #define SPATIAL_INTERPOLATION_H -static inline void blend_color(const unsigned char a[3],const unsigned char b[3],unsigned weight,unsigned char out[3]){ - for(unsigned c=0;c<3;c++)out[c]=(unsigned char)(((unsigned)a[c]*(65535-weight)+(unsigned)b[c]*weight+32767)/65535); +static inline void blend_color(const unsigned char a[3], const unsigned char b[3], unsigned weight, + unsigned char out[3]) { + for (unsigned c = 0; c < 3; c++) + out[c] = + (unsigned char)(((unsigned)a[c] * (65535 - weight) + (unsigned)b[c] * weight + 32767) / + 65535); } #endif diff --git a/native/layout.h b/native/layout.h index 1f9b369..53b893a 100644 --- a/native/layout.h +++ b/native/layout.h @@ -5,54 +5,92 @@ #include #include #include -typedef struct {unsigned char a,b;unsigned weight;} LedBlend; +typedef struct { + unsigned char a, b; + unsigned weight; +} LedBlend; #include #include #include #include "calibration.h" -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; - for(unsigned i=1;i<4;i++)if(distances[i]=4?positions[edge]-4:positions[edge]; +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; + for (unsigned i = 1; i < 4; i++) + if (distances[i] < distances[edge]) + edge = i; + return positions[edge] >= 4 ? positions[edge] - 4 : positions[edge]; } -static LedBlend layout_blend(double x,double y){ - 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(right0 && n<=16384; - LedBlend *mapping=valid?calloc(n,sizeof(*mapping)):NULL;valid=valid && mapping; - const char *keys[]={"hmin","hmax","vmin","vmax"}; - for(size_t i=0;valid && i=0 && coordinates[c]<=1;} - } - if(valid)valid=coordinates[0]<=coordinates[1] && coordinates[2]<=coordinates[3]; - if(valid)mapping[i]=layout_blend((coordinates[0]+coordinates[1])/2,(coordinates[2]+coordinates[3])/2); - } - if(root)put(root); - dlclose(lib); - if(!valid){free(mapping);return false;} - *result=mapping;*count=(unsigned)n;return true; +static bool layout_from_json(const char *text, LedBlend **result, unsigned *count) { + void *lib = dlopen("libjson-c.so.5", RTLD_NOW); + if (!lib) + return false; + void *(*parse)(const char *) = dlsym(lib, "json_tokener_parse"); + int (*get)(const void *, const char *, void **) = dlsym(lib, "json_object_object_get_ex"); + int (*type)(const void *) = dlsym(lib, "json_object_get_type"); + size_t (*length)(const void *) = dlsym(lib, "json_object_array_length"); + void *(*at)(const void *, size_t) = dlsym(lib, "json_object_array_get_idx"); + double (*number)(const void *) = dlsym(lib, "json_object_get_double"); + int (*put)(void *) = dlsym(lib, "json_object_put"); + if (!parse || !get || !type || !length || !at || !number || !put) { + dlclose(lib); + return false; + } + void *root = parse(text), *info = NULL, *leds = NULL; + bool valid = root && type(root) == 4 && get(root, "info", &info) && type(info) == 4 && + get(info, "leds", &leds) && type(leds) == 5; + size_t n = valid ? length(leds) : 0; + valid = valid && n > 0 && n <= 16384; + LedBlend *mapping = valid ? calloc(n, sizeof(*mapping)) : NULL; + valid = valid && mapping; + const char *keys[] = {"hmin", "hmax", "vmin", "vmax"}; + for (size_t i = 0; valid && i < n; i++) { + void *led = at(leds, i); + double coordinates[4]; + valid = led && type(led) == 4; + for (unsigned c = 0; valid && c < 4; c++) { + void *v = NULL; + valid = get(led, keys[c], &v) && (type(v) == 2 || type(v) == 3); + if (valid) { + coordinates[c] = number(v); + valid = isfinite(coordinates[c]) && coordinates[c] >= 0 && coordinates[c] <= 1; + } + } + if (valid) + valid = coordinates[0] <= coordinates[1] && coordinates[2] <= coordinates[3]; + if (valid) + mapping[i] = layout_blend((coordinates[0] + coordinates[1]) / 2, + (coordinates[2] + coordinates[3]) / 2); + } + if (root) + put(root); + dlclose(lib); + if (!valid) { + free(mapping); + return false; + } + *result = mapping; + *count = (unsigned)n; + return true; } #endif diff --git a/native/layout_refresh.h b/native/layout_refresh.h index 9e1a985..19a65ff 100644 --- a/native/layout_refresh.h +++ b/native/layout_refresh.h @@ -1,34 +1,53 @@ #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 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 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=next_layout_check){ - layout_probe_generation=calibration_generation;probe_begin(&layout_probe,&target,now);next_layout_check=now+LAYOUT_REFRESH_MS; - } +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 diff --git a/native/playback.h b/native/playback.h index b00f708..5218b9a 100644 --- a/native/playback.h +++ b/native/playback.h @@ -5,43 +5,89 @@ #include #include typedef struct { - void *lib;void *(*parse)(const char*);int (*get)(const void*,const char*,void**); - int (*type)(const void*);size_t (*length)(const void*);void *(*at)(const void*,size_t); - const char *(*string)(const void*);int (*boolean)(const void*);int (*put)(void*); + void *lib; + void *(*parse)(const char *); + int (*get)(const void *, const char *, void **); + int (*type)(const void *); + size_t (*length)(const void *); + void *(*at)(const void *, size_t); + const char *(*string)(const void *); + int (*boolean)(const void *); + int (*put)(void *); } PlaybackJson; -static inline bool playback_json_init(PlaybackJson *j){ - j->lib=dlopen("libjson-c.so.5",RTLD_NOW);if(!j->lib)return false; - j->parse=dlsym(j->lib,"json_tokener_parse");j->get=dlsym(j->lib,"json_object_object_get_ex"); - j->type=dlsym(j->lib,"json_object_get_type");j->length=dlsym(j->lib,"json_object_array_length"); - j->at=dlsym(j->lib,"json_object_array_get_idx");j->string=dlsym(j->lib,"json_object_get_string"); - j->boolean=dlsym(j->lib,"json_object_get_boolean");j->put=dlsym(j->lib,"json_object_put"); - if(!j->parse||!j->get||!j->type||!j->length||!j->at||!j->string||!j->boolean||!j->put){dlclose(j->lib);j->lib=NULL;return false;}return true; +static inline bool playback_json_init(PlaybackJson *j) { + j->lib = dlopen("libjson-c.so.5", RTLD_NOW); + if (!j->lib) + return false; + j->parse = dlsym(j->lib, "json_tokener_parse"); + j->get = dlsym(j->lib, "json_object_object_get_ex"); + j->type = dlsym(j->lib, "json_object_get_type"); + j->length = dlsym(j->lib, "json_object_array_length"); + j->at = dlsym(j->lib, "json_object_array_get_idx"); + j->string = dlsym(j->lib, "json_object_get_string"); + j->boolean = dlsym(j->lib, "json_object_get_boolean"); + j->put = dlsym(j->lib, "json_object_put"); + if (!j->parse || !j->get || !j->type || !j->length || !j->at || !j->string || !j->boolean || + !j->put) { + dlclose(j->lib); + j->lib = NULL; + return false; + } + return true; } -static inline void *playback_field(PlaybackJson *j,void *o,const char *key){void *v=NULL;return o && j->type(o)==4 && j->get(o,key,&v)?v:NULL;} -static inline bool playback_true(PlaybackJson *j,void *o,const char *key){void *v=playback_field(j,o,key);return v && j->type(v)==1 && j->boolean(v);} -static inline bool playback_is(PlaybackJson *j,void *o,const char *key,const char *value){void *v=playback_field(j,o,key);return v && j->type(v)==6 && strcmp(j->string(v),value)==0;} -typedef enum {PLAYBACK_UNKNOWN,PLAYBACK_CLEAR,PLAYBACK_PROTECTED} PlaybackProtection; +static inline void *playback_field(PlaybackJson *j, void *o, const char *key) { + void *v = NULL; + return o && j->type(o) == 4 && j->get(o, key, &v) ? v : NULL; +} +static inline bool playback_true(PlaybackJson *j, void *o, const char *key) { + void *v = playback_field(j, o, key); + return v && j->type(v) == 1 && j->boolean(v); +} +static inline bool playback_is(PlaybackJson *j, void *o, const char *key, const char *value) { + void *v = playback_field(j, o, key); + return v && j->type(v) == 6 && strcmp(j->string(v), value) == 0; +} +typedef enum { PLAYBACK_UNKNOWN, PLAYBACK_CLEAR, PLAYBACK_PROTECTED } PlaybackProtection; /* G4 resource-state classification, not black-frame or app-name guessing. * Any active Secure Video Path wins. Only a foreground media decoder without * a secure resource is eligible; HDMI, idle and malformed replies are unknown. */ -static inline PlaybackProtection playback_classify(PlaybackJson *j,const char *text){ - void *root=j->parse(text);if(!root)return PLAYBACK_UNKNOWN; - bool clear=false,invalid=j->type(root)!=5,secure=false; - size_t n=invalid?0:j->length(root); - for(size_t i=0;iat(root,i);if(!p || j->type(p)!=4){invalid=true;continue;} - void *resources=playback_field(j,p,"resource");if(!resources)resources=playback_field(j,p,"resources"); - if(!resources || j->type(resources)!=5){invalid=true;continue;} - bool decoder=false; - for(size_t k=0;klength(resources);k++){ - void *resource=j->at(resources,k);void *name=playback_field(j,resource,"resource"); - if(!name || j->type(name)!=6){invalid=true;continue;} - const char *s=j->string(name); - if(strcmp(s,"SVP_CPB")==0)secure=true; - if(strcmp(s,"VDEC")==0)decoder=true; - } - if(decoder && playback_true(j,p,"is_foreground") && playback_is(j,p,"type","media"))clear=true; - } - j->put(root);return secure?PLAYBACK_PROTECTED:clear&&!invalid?PLAYBACK_CLEAR:PLAYBACK_UNKNOWN; +static inline PlaybackProtection playback_classify(PlaybackJson *j, const char *text) { + void *root = j->parse(text); + if (!root) + return PLAYBACK_UNKNOWN; + bool clear = false, invalid = j->type(root) != 5, secure = false; + size_t n = invalid ? 0 : j->length(root); + for (size_t i = 0; i < n; i++) { + void *p = j->at(root, i); + if (!p || j->type(p) != 4) { + invalid = true; + continue; + } + void *resources = playback_field(j, p, "resource"); + if (!resources) + resources = playback_field(j, p, "resources"); + if (!resources || j->type(resources) != 5) { + invalid = true; + continue; + } + bool decoder = false; + for (size_t k = 0; k < j->length(resources); k++) { + void *resource = j->at(resources, k); + void *name = playback_field(j, resource, "resource"); + if (!name || j->type(name) != 6) { + invalid = true; + continue; + } + const char *s = j->string(name); + if (strcmp(s, "SVP_CPB") == 0) + secure = true; + if (strcmp(s, "VDEC") == 0) + decoder = true; + } + if (decoder && playback_true(j, p, "is_foreground") && playback_is(j, p, "type", "media")) + clear = true; + } + j->put(root); + return secure ? PLAYBACK_PROTECTED : clear && !invalid ? PLAYBACK_CLEAR : PLAYBACK_UNKNOWN; } #endif diff --git a/native/receiver_stream.h b/native/receiver_stream.h index d2dd6ef..312495c 100644 --- a/native/receiver_stream.h +++ b/native/receiver_stream.h @@ -5,67 +5,223 @@ #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; +#include "runtime_states.h" +typedef struct { + int fd; + StreamStage 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 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_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 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_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 = STREAM_CONNECTING; + stream.deadline = now + 3000; + stream.revision = calibration_revision(); + stream.layout_revision = RECEIVER_LAYOUT_REVISION; } -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; - } +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 < STREAM_SUBSCRIBED && now >= stream.deadline) { + stream_close(now); + return; + } + if (stream.stage == STREAM_CONNECTING) { + 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 = STREAM_AUTHENTICATING; + } else { + snprintf(line, sizeof(line), "{\"command\":\"serverinfo\"}\n"); + stream.stage = STREAM_LAYOUT; + } + 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; + } + } + /* Drain complete buffered replies even when recv would return EAGAIN. + * The per-tick budget applies independently of incoming network traffic. */ + if (stream.used == 262144 && !strchr(stream.data, '\n')) { + 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) { + stream_close(now); + return; + } + if (n == 0 && available) { + stream_close(now); + return; + } + if (n > 0) + 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 == STREAM_AUTHENTICATING) { + if (!playback_true(j, root, "success")) { + if (root) + j->put(root); + stream_close(now); + return; + } + stream.stage = STREAM_LAYOUT; + stream_queue("{\"command\":\"serverinfo\"}\n"); + stream.deadline = now + 3000; + } else if (stream.stage == STREAM_LAYOUT) { + 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 = STREAM_SUBSCRIBED; + 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/retry.h b/native/retry.h index 6bf3204..abe2352 100644 --- a/native/retry.h +++ b/native/retry.h @@ -5,26 +5,50 @@ #include #define RETRY_DELAY_MS 5000 /* Continuous autostart retries and bounded manual source recovery. */ -typedef struct {uint64_t due;unsigned attempts,quick_retries;bool automatic,paused,blocked;} RetryState; -static void retry_reset(RetryState *state){*state=(RetryState){0};} -static void retry_pause(RetryState *state){retry_reset(state);state->paused=true;} -static void retry_update(RetryState *state,bool enabled,bool running,uint64_t now){ - bool previous=state->automatic; - state->automatic=enabled && !state->paused && !state->blocked; - if(previous && !state->automatic){state->due=0;state->attempts=0;} - if(state->automatic && !running && !state->due)state->due=now+RETRY_DELAY_MS; +typedef struct { + uint64_t due; + unsigned attempts, quick_retries; + bool automatic, paused, blocked; +} RetryState; +static void retry_reset(RetryState *state) { + *state = (RetryState){0}; } -static bool retry_take(RetryState *state,uint64_t now){ - if(!state->due || nowdue || state->paused || state->blocked)return false; - state->due=0;if(state->attemptsattempts++;return true; +static void retry_pause(RetryState *state) { + retry_reset(state); + state->paused = true; } -static void retry_failure(RetryState *state,uint64_t now){ - if(!state->paused && !state->blocked && (state->automatic || state->attempts<6)){ - if(state->quick_retries){state->quick_retries--;state->due=now+400;} - else state->due=now+RETRY_DELAY_MS; - } +static void retry_update(RetryState *state, bool enabled, bool running, uint64_t now) { + bool previous = state->automatic; + state->automatic = enabled && !state->paused && !state->blocked; + if (previous && !state->automatic) { + state->due = 0; + state->attempts = 0; + } + if (state->automatic && !running && !state->due) + state->due = now + RETRY_DELAY_MS; } -static inline void retry_source_transition(RetryState *state,uint64_t now){ - if(!state->paused && !state->blocked){state->attempts=0;state->quick_retries=2;state->due=now+200;} +static bool retry_take(RetryState *state, uint64_t now) { + if (!state->due || now < state->due || state->paused || state->blocked) + return false; + state->due = 0; + if (state->attempts < UINT_MAX) + state->attempts++; + return true; +} +static void retry_failure(RetryState *state, uint64_t now) { + if (!state->paused && !state->blocked && (state->automatic || state->attempts < 6)) { + if (state->quick_retries) { + state->quick_retries--; + state->due = now + 400; + } else + state->due = now + RETRY_DELAY_MS; + } +} +static inline void retry_source_transition(RetryState *state, uint64_t now) { + if (!state->paused && !state->blocked) { + state->attempts = 0; + state->quick_retries = 2; + state->due = now + 200; + } } #endif diff --git a/native/runtime_states.h b/native/runtime_states.h new file mode 100644 index 0000000..be02371 --- /dev/null +++ b/native/runtime_states.h @@ -0,0 +1,12 @@ +#ifndef HYPERDRM_RUNTIME_STATES_H +#define HYPERDRM_RUNTIME_STATES_H +/* Connection and source transitions are independent state machines. */ +typedef enum { + STREAM_DISCONNECTED, + STREAM_CONNECTING, + STREAM_AUTHENTICATING, + STREAM_LAYOUT, + STREAM_SUBSCRIBED +} StreamStage; +typedef enum { BRIDGE_IDLE, BRIDGE_HOLDING, BRIDGE_WAITING_FRAME, BRIDGE_FADING } BridgePhase; +#endif diff --git a/native/service.c b/native/service.c index eefee4e..b0f86d7 100644 --- a/native/service.c +++ b/native/service.c @@ -17,6 +17,7 @@ #include #include #include +#include "version.h" #include "histogram.h" #include "blackbars.h" #include "color_boost.h" @@ -30,379 +31,383 @@ #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; -typedef bool (*Callback)(void*,void*,void*); -typedef struct {const char *name;Callback function;int flags;} Method; -static bool (*reply)(void*,void*,const char*,LSError*); -static const char *(*payload)(void*); -static bool (*error_init)(LSError*); -static void (*error_free)(LSError*); -static void *(*vt_init)(bool(*)(void)); -static int (*vt_preset)(void*,int),(*vt_get)(void*,Histogram*),(*vt_finish)(void*); +typedef struct { + int code; + char *message; + const char *file; + int line; + const char *func; + void *padding; + unsigned long magic; +} LSError; +typedef bool (*Callback)(void *, void *, void *); +typedef struct { + const char *name; + Callback function; + int flags; +} Method; +static bool (*reply)(void *, void *, const char *, LSError *); +static const char *(*payload)(void *); +static bool (*error_init)(LSError *); +static void (*error_free)(LSError *); +static void *(*vt_init)(bool (*)(void)); +static int (*vt_preset)(void *, int), (*vt_get)(void *, Histogram *), (*vt_finish)(void *); static void *capture; static char lg_diagnostic[256]; /* Process-local LG diagnostics; no changes to the loaded LG library. */ -void printLog(const char *context,int level,const char *message_id, - const char *file,const char *function,int line,const char *format,...) { - (void)context;(void)message_id;(void)file; - if(capture && level>3)return; - if(format){va_list args;va_start(args,format);vsnprintf(lg_diagnostic,sizeof(lg_diagnostic),format,args);va_end(args);} - fprintf(stderr,"LG %s:%d: ",function?function:"?",line); - if(format){va_list args;va_start(args,format);vfprintf(stderr,format,args);va_end(args);} - fputc('\n',stderr); +void printLog(const char *context, int level, const char *message_id, const char *file, + const char *function, int line, const char *format, ...) { + (void)context; + (void)message_id; + (void)file; + if (capture && level > 3) + return; + if (format) { + va_list args; + va_start(args, format); + vsnprintf(lg_diagnostic, sizeof(lg_diagnostic), format, args); + va_end(args); + } + fprintf(stderr, "LG %s:%d: ", function ? function : "?", line); + if (format) { + va_list args; + va_start(args, format); + vfprintf(stderr, format, args); + va_end(args); + } + fputc('\n', stderr); } -static int output=-1; +static int output = -1; static unsigned long frames; static unsigned char rgb[16][3]; static unsigned char sent_rgb[3]; static unsigned black_percent[16]; static unsigned capture_preset; -static unsigned letterbox_mode,settling; -static int pending_letterbox=-1; -static uint64_t candidate_since,next_border_probe; +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 uint64_t sample_at, calibration_until; static unsigned sample_sequence; -#define CALIBRATION_ACTIVE (calibration_until>milliseconds()) +#define CALIBRATION_ACTIVE (calibration_until > milliseconds()) static unsigned led_count; static LedBlend *led_blends; static bool interpolation_enabled; static RetryState retries; static unsigned capture_recoveries; -static uint64_t recovery_started,last_recovery_ms,last_output_at; -static uint64_t milliseconds(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);return (uint64_t)t.tv_sec*1000+t.tv_nsec/1000000;} +static uint64_t recovery_started, last_recovery_ms, last_output_at; +static uint64_t milliseconds(void) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return (uint64_t)t.tv_sec * 1000 + t.tv_nsec / 1000000; +} static char last_error[256]; -static volatile sig_atomic_t revoked,quitting; -static int guard_pipe=-1; -static pid_t guard_pid=-1; +static volatile sig_atomic_t revoked, quitting; +static int guard_pipe = -1; +static pid_t guard_pid = -1; #define DEV_MODE "/var/luna/preferences/vt_devmode" /* LG's developer gate is enabled only during initialization. A separate * guardian removes our own marker even if this process aborts or is killed. * An existing marker is preserved, as are files replaced by another owner. */ -static bool enter_developer_mode(void){ - int marker=open(DEV_MODE,O_CREAT|O_EXCL|O_WRONLY|O_CLOEXEC,0600); - if(marker<0)return errno==EEXIST; - struct stat owned; - if(fstat(marker,&owned)!=0){close(marker);unlink(DEV_MODE);return false;} - close(marker);int p[2]; - if(pipe(p)!=0){unlink(DEV_MODE);return false;} - pid_t child=fork(); - if(child<0){close(p[0]);close(p[1]);unlink(DEV_MODE);return false;} - if(child==0){ - close(p[1]);struct pollfd fd={p[0],POLLIN|POLLHUP,0}; - /* Bounded cleanup also covers an indefinitely blocked initialization. */ - poll(&fd,1,10000);close(p[0]);struct stat current; - int rc=0; - if(lstat(DEV_MODE,¤t)==0 && current.st_dev==owned.st_dev && current.st_ino==owned.st_ino)rc=unlink(DEV_MODE); - _exit(rc==0?0:1); - } - close(p[0]);guard_pipe=p[1];guard_pid=child;return true; +static bool enter_developer_mode(void) { + int marker = open(DEV_MODE, O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600); + if (marker < 0) + return errno == EEXIST; + struct stat owned; + if (fstat(marker, &owned) != 0) { + close(marker); + unlink(DEV_MODE); + return false; + } + close(marker); + int p[2]; + if (pipe(p) != 0) { + unlink(DEV_MODE); + return false; + } + pid_t child = fork(); + if (child < 0) { + close(p[0]); + close(p[1]); + unlink(DEV_MODE); + return false; + } + if (child == 0) { + close(p[1]); + struct pollfd fd = {p[0], POLLIN | POLLHUP, 0}; + /* Bounded cleanup also covers an indefinitely blocked initialization. */ + poll(&fd, 1, 10000); + close(p[0]); + struct stat current; + int rc = 0; + if (lstat(DEV_MODE, ¤t) == 0 && current.st_dev == owned.st_dev && + current.st_ino == owned.st_ino) + rc = unlink(DEV_MODE); + _exit(rc == 0 ? 0 : 1); + } + close(p[0]); + guard_pipe = p[1]; + guard_pid = child; + return true; } -static bool leave_developer_mode(void){ - if(guard_pipe<0)return true; - close(guard_pipe);guard_pipe=-1;int status; - while(waitpid(guard_pid,&status,0)<0){if(errno!=EINTR)return false;} - guard_pid=-1;return WIFEXITED(status)&&WEXITSTATUS(status)==0; +static bool leave_developer_mode(void) { + if (guard_pipe < 0) + return true; + close(guard_pipe); + guard_pipe = -1; + int status; + while (waitpid(guard_pid, &status, 0) < 0) { + if (errno != EINTR) + return false; + } + guard_pid = -1; + return WIFEXITED(status) && WEXITSTATUS(status) == 0; } -static void quit_signal(int s){(void)s;quitting=1;} -static bool policy(void){revoked=1;return true;} -static void set_error(const char *s){snprintf(last_error,sizeof(last_error),"%s",s);fprintf(stderr,"%s\n",s);} -static int send_line(const char *line){ - size_t left=strlen(line);const char *p=line; - while(left){ssize_t n=send(output,p,left,MSG_NOSIGNAL);if(n<=0)return -1;p+=n;left-=n;} - return 0; +static void quit_signal(int s) { + (void)s; + quitting = 1; } -static bool receiver_ack(void){ - char answer[4096];size_t used=0;bool complete=false; - while(used+1=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(capture){vt_finish(capture);capture=NULL;} +static void release_capture(bool clear_output) { + layout_refresh_reset(milliseconds() + 5000); + 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)); + 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 (capture) { + vt_finish(capture); + capture = NULL; + } +} +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 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;} #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}"); +#include "service_api.h" +#include "capture_loop.h" +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 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))=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){ - 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"); - return respond(h,m,json); -} -static bool start(void *h,void *m,void *ctx){ - (void)ctx;const char *p=m?payload(m):NULL; - if(p && strcmp(p,"{}")!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Start expects an empty object; use settings to configure the receiver\"}"); - if(m)retry_reset(&retries); - if(piccap_owned)return respond(h,m,"{\"returnValue\":true,\"running\":true,\"activeSource\":\"PicCap\"}"); - if(capture)return respond(h,m,"{\"returnValue\":true,\"running\":true}"); - frames=0;revoked=0;last_error[0]=0;lg_diagnostic[0]=0; - output=target_connect(&target); - if(output<0){stop_capture();set_error("Cannot connect to configured receiver IP and JSON port");return start_failure(h,m,"Configured receiver is unavailable");} - if(!target_login()){stop_capture();set_error("Receiver token login failed; check the saved API token");return start_failure(h,m,"Receiver authentication failed");} - if(!read_layout()){stop_capture();set_error("Cannot read receiver LED layout; check JSON port and API authorization");return start_failure(h,m,"Receiver layout unavailable; check API authorization");} - if(!enter_developer_mode()){stop_capture();set_error("Developer initialization needs root. Elevate this own service through Homebrew Channel.");return start_failure(h,m,"Root access is required for temporary LG developer initialization");} - capture=vt_init(policy); - if(!leave_developer_mode()){stop_capture();retries.blocked=true;set_error("Developer marker cleanup failed; manual intervention required");return start_failure(h,m,"Developer marker cleanup failed");} - if(!capture){stop_capture();set_error("LG refused histogram initialization");return start_failure(h,m,"LG refused histogram initialization. Another capture source may hold VT_CAPTURE.");} - if(vt_preset(capture,0)!=0){stop_capture();set_error("LG histogram preset initialization failed");return start_failure(h,m,"Histogram preset failed");} - capture_preset=letterbox_mode=settling=0;pending_letterbox=-1;next_border_probe=0; - retries.due=0;retries.attempts=0;retries.quick_retries=0; - if(recovery_started){last_recovery_ms=milliseconds()-recovery_started;recovery_started=0;} - return respond(h,m,"{\"returnValue\":true,\"running\":true}"); -} -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){ - (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)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; - else if(strcmp(p,"{\"enabled\":false}")==0)enabled=false; - else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}"); - 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(!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;clear_since=unknown_since=0;next_media_probe=0; - return respond(h,m,"{\"returnValue\":true}"); -} -static bool settings(void *h,void *m,void *ctx){ - (void)ctx;TargetConfig next=target; - if(!playback_json.lib || !target_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Enter a valid IPv4/IPv6 address, integer port (1-65535), receiver and optional API token\"}"); - if(!target_save(TARGET_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}"); - bool active=capture || piccap_owned || retries.due; - stop_capture();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}"); -} -static bool test_connection(void *h,void *m,void *ctx){ - (void)ctx; - if(strcmp(payload(m),"{}") || !playback_json.lib)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Test requires an empty object and JSON-C\"}"); - if(!test_begin(&target,milliseconds()))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"A connection test is already running\"}"); - return respond(h,m,"{\"returnValue\":true,\"testing\":true}"); -} -static bool status(void *h,void *m,void *ctx){ - (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.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]); - snprintf(json+used,sizeof(json)-used,"],\"preset\":%u,\"saturationGain\":1.0,\"brightnessGain\":1.0,\"spatialInterpolation\":%s,\"blackBarDetection\":true,\"autostart\":%s,\"retryScheduled\":%s,\"retryPaused\":%s,\"retryBlocked\":%s,\"retryAttempts\":%u,\"piccapInstalled\":%s,\"piccapHandover\":%s,\"piccapConnected\":%s,\"activeSource\":\"%s\",\"playbackProtection\":\"%s\",\"letterboxDetected\":%s}",capture_preset,interpolation_enabled?"true":"false",boot_enabled()?"true":"false",retries.due?"true":"false",retries.paused?"true":"false",retries.blocked?"true":"false",retries.attempts,piccap_installed()?"true":"false",handover_enabled?"true":"false",piccap_connected?"true":"false",piccap_owned?"PicCap":"HyperDRM",protection==PLAYBACK_PROTECTED?"protected":protection==PLAYBACK_CLEAR?"non-secure decoder":"unknown",capture&&letterbox_mode?"true":"false");return respond(h,m,json); -} -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); - 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())){ - start(NULL,NULL,NULL); - if(!capture)retry_failure(&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(!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); - 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; - for(int c=0;c<3;c++){ - uint64_t total=0,black=0; - for(int b=0;b<256;b++){total+=hist[i].bins[c][b];if(b<=12)black+=hist[i].bins[c][b];} - unsigned fraction=total?(unsigned)(100*black/total):0; - if(fraction=2000)letterbox_mode=(unsigned)candidate; - if(letterbox_mode){ - if(vt_preset(capture,1)!=0){set_error("Letterbox preset failed");stop_capture();return 1;} - capture_preset=1;settling=2;next_border_probe=now+2000;return 1; - } - } - 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],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; - char *line=region_command(stable_rgb,led_blends,led_count,1500); - if(!line){set_error("Color buffer allocation failed");stop_capture();return 1;} - int failed=send_line(line);free(line); - if(failed){set_error("Receiver connection lost");stop_capture();return 1;} - if(!receiver_ack()){set_error("Receiver did not acknowledge color data");stop_capture();return 1;} - last_error[0]=0; - 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;} -int main(void){ - signal(SIGTERM,quit_signal);signal(SIGINT,quit_signal); - if(boot_enabled() && !set_boot(true))fprintf(stderr,"Own legacy boot hook migration failed\n"); - void *vt=dlopen("libvtcapture.so.1",RTLD_NOW),*ls=dlopen("libluna-service2.so.3",RTLD_NOW),*glib=dlopen("libglib-2.0.so.0",RTLD_NOW); - if(!vt||!ls||!glib){fprintf(stderr,"Missing LG library: %s\n",dlerror());return 2;} - vt_init=symbol(vt,"vtCapture_initHistogram");vt_preset=symbol(vt,"vtCapture_setPresetHistogram");vt_get=symbol(vt,"vtCapture_getHistogram");vt_finish=symbol(vt,"vtCapture_finalizeHistogram"); - call_once=symbol(ls,"LSCallOneReply"); - cancel_call=symbol(ls,"LSCallCancel"); - if(playback_json_init(&playback_json)){handover_load();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"); - bool (*reg)(const char*,void**,LSError*)=symbol(ls,"LSRegister"); - bool (*category)(void*,const char*,Method*,void*,void*,LSError*)=symbol(ls,"LSRegisterCategory"); - bool (*attach)(void*,void*,LSError*)=symbol(ls,"LSGmainAttach"); - bool (*unreg)(void*,LSError*)=symbol(ls,"LSUnregister"); - void *(*loop_new)(void*,int)=symbol(glib,"g_main_loop_new"); - int (*iterate)(void*,int)=symbol(glib,"g_main_context_iteration"); - unsigned (*timer)(unsigned,int(*)(void*),void*)=symbol(glib,"g_timeout_add"); - void (*loop_unref)(void*)=symbol(glib,"g_main_loop_unref"); - void *handle=NULL,*loop=loop_new(NULL,0);LSError e;error_init(&e); - Method methods[]={{"start",start,0},{"stop",stop,0},{"status",status,0},{"autostart",autostart,0},{"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;} - service_handle=handle; - if(!boot_enabled())retry_pause(&retries); - timer(100,tick,NULL); - while(!quitting){iterate(NULL,0);usleep(10000);} - stream_close(milliseconds());test_close();stop_capture(); - if(piccap_owned){ - handover_enabled=false;retry_pause(&retries); - uint64_t deadline=milliseconds()+4000; - while(piccap_owned && milliseconds()= 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) + 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; + else if (strcmp(p, "{\"enabled\":false}") == 0) + enabled = false; + else + return respond(h, m, "{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}"); + 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 (!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; + clear_since = unknown_since = 0; + next_media_probe = 0; + return respond(h, m, "{\"returnValue\":true}"); +} +static bool settings(void *h, void *m, void *ctx) { + (void)ctx; + TargetConfig next = target; + if (!playback_json.lib || !target_parse(&playback_json, payload(m), &next)) + return respond(h, m, + "{\"returnValue\":false,\"errorText\":\"Enter a valid IPv4/IPv6 address, " + "integer port (1-65535), receiver and optional API token\"}"); + if (!target_save(TARGET_PREF, &next)) + return respond( + h, m, "{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}"); + bool active = capture || piccap_owned || retries.due; + stop_capture(); + stream_close(milliseconds()); + bridge_phase = BRIDGE_IDLE; + 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}"); +} +static bool test_connection(void *h, void *m, void *ctx) { + (void)ctx; + if (strcmp(payload(m), "{}") || !playback_json.lib) + return respond( + h, m, + "{\"returnValue\":false,\"errorText\":\"Test requires an empty object and JSON-C\"}"); + if (!test_begin(&target, milliseconds())) + return respond( + h, m, "{\"returnValue\":false,\"errorText\":\"A connection test is already running\"}"); + return respond(h, m, "{\"returnValue\":true,\"testing\":true}"); +} +#include "service_status.h" diff --git a/native/service_calibration.h b/native/service_calibration.h new file mode 100644 index 0000000..989c17f --- /dev/null +++ b/native/service_calibration.h @@ -0,0 +1,107 @@ +/* Calibration session, proposals, saved profiles and model discovery. */ +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}"); +} diff --git a/native/service_status.h b/native/service_status.h new file mode 100644 index 0000000..b462a3b --- /dev/null +++ b/native/service_status.h @@ -0,0 +1,111 @@ +/* Read-only runtime status serialization. */ +static bool status(void *h, void *m, void *ctx) { + (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 < 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(); + 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; + 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 == STREAM_SUBSCRIBED ? "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 == BRIDGE_FADING) ? "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\":\"" HYPERDRM_VERSION + "\",\"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]); + snprintf(json + used, sizeof(json) - used, + "],\"preset\":%u,\"saturationGain\":1.0,\"brightnessGain\":1.0," + "\"spatialInterpolation\":%s,\"blackBarDetection\":true,\"autostart\":%s," + "\"retryScheduled\":%s,\"retryPaused\":%s,\"retryBlocked\":%s,\"retryAttempts\":%u," + "\"piccapInstalled\":%s,\"piccapHandover\":%s,\"piccapConnected\":%s,\"activeSource\":" + "\"%s\",\"playbackProtection\":\"%s\",\"letterboxDetected\":%s}", + capture_preset, interpolation_enabled ? "true" : "false", + boot_enabled() ? "true" : "false", retries.due ? "true" : "false", + retries.paused ? "true" : "false", retries.blocked ? "true" : "false", + retries.attempts, piccap_installed() ? "true" : "false", + handover_enabled ? "true" : "false", piccap_connected ? "true" : "false", + piccap_owned ? "PicCap" : "HyperDRM", + protection == PLAYBACK_PROTECTED ? "protected" + : protection == PLAYBACK_CLEAR ? "non-secure decoder" + : "unknown", + capture && letterbox_mode ? "true" : "false"); + return respond(h, m, json); +} diff --git a/native/source_bridge.h b/native/source_bridge.h index 775558b..0ef778a 100644 --- a/native/source_bridge.h +++ b/native/source_bridge.h @@ -1,47 +1,112 @@ #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]; +#include "runtime_states.h" +static BridgePhase bridge_phase; +static unsigned 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=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;} - } +static bool bridge_send(const unsigned char colors[16][3]) { + if (stream.stage != STREAM_SUBSCRIBED) + 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 = BRIDGE_IDLE; + 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 ? BRIDGE_HOLDING + : BRIDGE_IDLE; + 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 = BRIDGE_IDLE; + 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 = BRIDGE_IDLE; + return; + } + if (bridge_phase == BRIDGE_HOLDING && capture_source == SOURCE_PICCAP && piccap_connected && + stream.stage == STREAM_SUBSCRIBED) { + if (bridge_clear()) { + bridge_sequence = stream.sequence; + bridge_clear_at = now; + bridge_phase = BRIDGE_WAITING_FRAME; + } + } else if (bridge_phase == BRIDGE_HOLDING && now - bridge_last_sent >= 100 && + stream.stage == STREAM_SUBSCRIBED) { + if (bridge_send(bridge_from)) + bridge_last_sent = now; + } else if (bridge_phase == BRIDGE_WAITING_FRAME && 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 = BRIDGE_FADING; + 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 = BRIDGE_IDLE; + } + if (bridge_phase == BRIDGE_FADING && now - bridge_last_sent >= 100) { + if (now - bridge_fade_at >= 500) { + if (bridge_clear()) + bridge_phase = BRIDGE_IDLE; + } 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 d7cae1a..201de3e 100644 --- a/native/spatial_image.h +++ b/native/spatial_image.h @@ -5,26 +5,48 @@ #include "layout.h" #include "interpolation.h" #include "color_boost.h" -enum {SPATIAL_WIDTH=160,SPATIAL_HEIGHT=90,SPATIAL_PIXELS=SPATIAL_WIDTH*SPATIAL_HEIGHT}; +enum { SPATIAL_WIDTH = 160, SPATIAL_HEIGHT = 90, SPATIAL_PIXELS = SPATIAL_WIDTH * SPATIAL_HEIGHT }; /* 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_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)]; - line[used++]=alphabet[((color[1]&15)<<2)|(color[2]>>6)];line[used++]=alphabet[color[2]&63]; - } - line[used++]='"';line[used++]='}';line[used++]='\n';line[used]=0;return line; +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 < 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); + generation = calibration_generation; + } + const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + 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\":%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++) { + 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[1] & 15) << 2) | (color[2] >> 6)]; + line[used++] = alphabet[color[2] & 63]; + } + 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); } -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/target.h b/native/target.h index 717de34..dffc20d 100644 --- a/native/target.h +++ b/native/target.h @@ -10,80 +10,194 @@ #include #include #include "playback.h" -#define TARGET_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/target.json" -typedef struct {char host[46],receiver[16],token[257];unsigned port,piccap_port;} TargetConfig; -static TargetConfig target={.host="127.0.0.1",.receiver="Hyperion",.port=19444,.piccap_port=19400}; -static inline bool target_piccap_local(const TargetConfig *t){return t->port==19444 && (!strcmp(t->host,"127.0.0.1") || !strcmp(t->host,"::1"));} -static inline bool target_piccap_compatible(const TargetConfig *t){unsigned char address[4];return !strcmp(t->host,"::1") || inet_pton(AF_INET,t->host,address)==1;} -static inline const char *target_piccap_host(const TargetConfig *t){return !strcmp(t->host,"::1")?"127.0.0.1":t->host;} -static bool target_valid(const TargetConfig *t){ - unsigned char address[16]; - if(inet_pton(AF_INET,t->host,address)!=1 && inet_pton(AF_INET6,t->host,address)!=1)return false; - if(!t->piccap_port || t->piccap_port>65535)return false; - if(!t->port || t->port>65535 || (strcmp(t->receiver,"Hyperion") && strcmp(t->receiver,"HyperHDR")))return false; - for(const unsigned char *p=(const unsigned char*)t->token;*p;p++) - if(!((*p>='a'&&*p<='z')||(*p>='A'&&*p<='Z')||(*p>='0'&&*p<='9')||*p=='-'||*p=='_'||*p=='.'))return false; - return true; +#define TARGET_PREF \ + "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/target.json" +typedef struct { + char host[46], receiver[16], token[257]; + unsigned port, piccap_port; +} TargetConfig; +static TargetConfig target = { + .host = "127.0.0.1", .receiver = "Hyperion", .port = 19444, .piccap_port = 19400}; +static inline bool target_piccap_local(const TargetConfig *t) { + return t->port == 19444 && (!strcmp(t->host, "127.0.0.1") || !strcmp(t->host, "::1")); } -static bool target_parse(PlaybackJson *j,const char *text,TargetConfig *out){ - void *root=j->parse(text);if(!root)return false; - TargetConfig next=*out;bool valid=j->type(root)==4; - const char *names[]={"host","receiver","token"};char *fields[]={next.host,next.receiver,next.token}; - size_t sizes[]={sizeof(next.host),sizeof(next.receiver),sizeof(next.token)}; - for(unsigned i=0;valid && i<3;i++){ - void *v=playback_field(j,root,names[i]); - if(!v){if(i<2)valid=false;continue;} - if(j->type(v)!=6){valid=false;break;} - const char *s=j->string(v);if(strlen(s)>=sizes[i]){valid=false;break;} - strcpy(fields[i],s); - } - void *port=playback_field(j,root,"port"); - if(!port || j->type(port)!=3)valid=false; - else {char *end=NULL;errno=0;long n=strtol(j->string(port),&end,10);if(errno || !end || *end || n<1 || n>65535)valid=false;else next.port=(unsigned)n;} - void *flat=NULL; - if(j->type(root)==4 && j->get(root,"piccapPort",&flat)){ - if(j->type(flat)!=3)valid=false; - else {char *end=NULL;errno=0;long n=strtol(j->string(flat),&end,10);if(errno || !end || *end || n<1 || n>65535)valid=false;else next.piccap_port=(unsigned)n;} - } - void *clear=playback_field(j,root,"clearToken"); - if(clear){if(j->type(clear)!=1)valid=false;else if(j->boolean(clear))next.token[0]=0;} - valid=valid && target_valid(&next);j->put(root);if(valid)*out=next;return valid; +static inline bool target_piccap_compatible(const TargetConfig *t) { + unsigned char address[4]; + return !strcmp(t->host, "::1") || inet_pton(AF_INET, t->host, address) == 1; } -static bool target_save(const char *path,const TargetConfig *t){ - if(!target_valid(t))return false; - char tmp[512],data[512];if(snprintf(tmp,sizeof(tmp),"%s.tmp",path)>=(int)sizeof(tmp))return false; - int n=snprintf(data,sizeof(data),"{\"host\":\"%s\",\"port\":%u,\"piccapPort\":%u,\"receiver\":\"%s\",\"token\":\"%s\"}\n",t->host,t->port,t->piccap_port,t->receiver,t->token); - int fd=open(tmp,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);if(fd<0)return false; - bool ok=write(fd,data,n)==n && fsync(fd)==0;if(close(fd)!=0)ok=false; - if(ok)ok=rename(tmp,path)==0; - if(!ok)unlink(tmp); - return ok; +static inline const char *target_piccap_host(const TargetConfig *t) { + return !strcmp(t->host, "::1") ? "127.0.0.1" : t->host; } -static bool target_load(PlaybackJson *j,const char *path,TargetConfig *t){ - int fd=open(path,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return false; - char data[1024];ssize_t n=read(fd,data,sizeof(data)-1);close(fd);if(n<=0 || n==(ssize_t)sizeof(data)-1)return false; - data[n]=0;return target_parse(j,data,t); +static bool target_valid(const TargetConfig *t) { + unsigned char address[16]; + if (inet_pton(AF_INET, t->host, address) != 1 && inet_pton(AF_INET6, t->host, address) != 1) + return false; + if (!t->piccap_port || t->piccap_port > 65535) + return false; + if (!t->port || t->port > 65535 || + (strcmp(t->receiver, "Hyperion") && strcmp(t->receiver, "HyperHDR"))) + return false; + for (const unsigned char *p = (const unsigned char *)t->token; *p; p++) + if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') || + *p == '-' || *p == '_' || *p == '.')) + return false; + return true; } -static int target_open(const TargetConfig *t){ - if(!target_valid(t))return -1; - struct sockaddr_storage storage={0};socklen_t length;int family; - struct sockaddr_in *v4=(void*)&storage;struct sockaddr_in6 *v6=(void*)&storage; - if(inet_pton(AF_INET,t->host,&v4->sin_addr)==1){family=AF_INET;v4->sin_family=family;v4->sin_port=htons(t->port);length=sizeof(*v4);} - else {family=AF_INET6;v6->sin6_family=family;v6->sin6_port=htons(t->port);length=sizeof(*v6);} - int fd=socket(family,SOCK_STREAM,0);if(fd<0)return -1; - int flags=fcntl(fd,F_GETFL,0);if(flags<0 || fcntl(fd,F_SETFL,flags|O_NONBLOCK)<0){close(fd);return -1;} - if(connect(fd,(void*)&storage,length)!=0){ - if(errno!=EINPROGRESS){close(fd);return -1;} - } - return fd; +static bool target_parse(PlaybackJson *j, const char *text, TargetConfig *out) { + void *root = j->parse(text); + if (!root) + return false; + TargetConfig next = *out; + bool valid = j->type(root) == 4; + const char *names[] = {"host", "receiver", "token"}; + char *fields[] = {next.host, next.receiver, next.token}; + size_t sizes[] = {sizeof(next.host), sizeof(next.receiver), sizeof(next.token)}; + for (unsigned i = 0; valid && i < 3; i++) { + void *v = playback_field(j, root, names[i]); + if (!v) { + if (i < 2) + valid = false; + continue; + } + if (j->type(v) != 6) { + valid = false; + break; + } + const char *s = j->string(v); + if (strlen(s) >= sizes[i]) { + valid = false; + break; + } + strcpy(fields[i], s); + } + void *port = playback_field(j, root, "port"); + if (!port || j->type(port) != 3) + valid = false; + else { + char *end = NULL; + errno = 0; + long n = strtol(j->string(port), &end, 10); + if (errno || !end || *end || n < 1 || n > 65535) + valid = false; + else + next.port = (unsigned)n; + } + void *flat = NULL; + if (j->type(root) == 4 && j->get(root, "piccapPort", &flat)) { + if (j->type(flat) != 3) + valid = false; + else { + char *end = NULL; + errno = 0; + long n = strtol(j->string(flat), &end, 10); + if (errno || !end || *end || n < 1 || n > 65535) + valid = false; + else + next.piccap_port = (unsigned)n; + } + } + void *clear = playback_field(j, root, "clearToken"); + if (clear) { + if (j->type(clear) != 1) + valid = false; + else if (j->boolean(clear)) + next.token[0] = 0; + } + valid = valid && target_valid(&next); + j->put(root); + if (valid) + *out = next; + return valid; } -static int target_connect(const TargetConfig *t){ - int fd=target_open(t);if(fd<0)return -1; - struct pollfd p={.fd=fd,.events=POLLOUT};int error=0;socklen_t size=sizeof(error); - if(poll(&p,1,1000)<=0 || getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){close(fd);return -1;} - int flags=fcntl(fd,F_GETFL,0); - if(flags<0 || fcntl(fd,F_SETFL,flags & ~O_NONBLOCK)<0){close(fd);return -1;} - struct timeval timeout={1,0};setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout)); - return fd; +static bool target_save(const char *path, const TargetConfig *t) { + if (!target_valid(t)) + return false; + char tmp[512], data[512]; + if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp)) + return false; + int n = snprintf( + data, sizeof(data), + "{\"host\":\"%s\",\"port\":%u,\"piccapPort\":%u,\"receiver\":\"%s\",\"token\":\"%s\"}\n", + t->host, t->port, t->piccap_port, t->receiver, t->token); + int fd = open(tmp, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) + return false; + bool ok = write(fd, data, n) == n && fsync(fd) == 0; + if (close(fd) != 0) + ok = false; + if (ok) + ok = rename(tmp, path) == 0; + if (!ok) + unlink(tmp); + return ok; +} +static bool target_load(PlaybackJson *j, const char *path, TargetConfig *t) { + int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return false; + char data[1024]; + ssize_t n = read(fd, data, sizeof(data) - 1); + close(fd); + if (n <= 0 || n == (ssize_t)sizeof(data) - 1) + return false; + data[n] = 0; + return target_parse(j, data, t); +} +static int target_open(const TargetConfig *t) { + if (!target_valid(t)) + return -1; + struct sockaddr_storage storage = {0}; + socklen_t length; + int family; + struct sockaddr_in *v4 = (void *)&storage; + struct sockaddr_in6 *v6 = (void *)&storage; + if (inet_pton(AF_INET, t->host, &v4->sin_addr) == 1) { + family = AF_INET; + v4->sin_family = family; + v4->sin_port = htons(t->port); + length = sizeof(*v4); + } else { + family = AF_INET6; + v6->sin6_family = family; + v6->sin6_port = htons(t->port); + length = sizeof(*v6); + } + int fd = socket(family, SOCK_STREAM, 0); + if (fd < 0) + return -1; + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) < 0) { + close(fd); + return -1; + } + if (connect(fd, (void *)&storage, length) != 0) { + if (errno != EINPROGRESS) { + close(fd); + return -1; + } + } + return fd; +} +static int target_connect(const TargetConfig *t) { + int fd = target_open(t); + if (fd < 0) + return -1; + struct pollfd p = {.fd = fd, .events = POLLOUT}; + int error = 0; + socklen_t size = sizeof(error); + if (poll(&p, 1, 1000) <= 0 || getsockopt(fd, SOL_SOCKET, SO_ERROR, &error, &size) != 0 || + error) { + close(fd); + return -1; + } + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) < 0) { + close(fd); + return -1; + } + struct timeval timeout = {1, 0}; + setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout)); + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout)); + return fd; } #endif diff --git a/native/test_receiver_stream.c b/native/test_receiver_stream.c index f0343ff..99ba967 100644 --- a/native/test_receiver_stream.c +++ b/native/test_receiver_stream.c @@ -5,35 +5,110 @@ #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); +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); + } + /* More than the eight-message budget, with no further socket writes. */ + const char *buffered = + "{\"command\":\"ledcolors-ledstream-update\",\"data\":{\"leds\":[50,60,70]}}\n"; + stream.used = 0; + for (unsigned i = 0; i < 17; i++) { + memcpy(stream.data + stream.used, buffered, strlen(buffered)); + stream.used += strlen(buffered); + } + stream.data[stream.used] = 0; + for (unsigned i = 0; i < 3; i++) + stream_tick(json, 1900 + i, true); + assert(stream.sequence == 18 && stream.used == 0 && stream.colors[0][0] == 50); + void *bad = json->parse("{\"result\":{\"leds\":[0,0,999]}}"); + assert(!stream_colors(json, bad, 2000)); + json->put(bad); + assert(stream.colors[0][2] == 70); + 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; +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/version.h b/native/version.h new file mode 100644 index 0000000..1ccdd7c --- /dev/null +++ b/native/version.h @@ -0,0 +1,4 @@ +#ifndef HYPERDRM_VERSION_H +#define HYPERDRM_VERSION_H +#define HYPERDRM_VERSION "0.12.0" +#endif diff --git a/package/packageinfo.json b/package/packageinfo.json index 9294b78..3c7fd06 100644 --- a/package/packageinfo.json +++ b/package/packageinfo.json @@ -1 +1,10 @@ -{"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"} +{ + "id": "org.webosbrew.histogramhyperion", + "version": "0.12.0", + "vendor": "Local development", + "app": "org.webosbrew.histogramhyperion", + "services": [ + "org.webosbrew.histogramhyperion.service" + ], + "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM" +} diff --git a/release.py b/release.py new file mode 100644 index 0000000..3b5a1b0 --- /dev/null +++ b/release.py @@ -0,0 +1,99 @@ +"""Test, compile and verify a release locally. Never contacts the TV. + +Use --push to commit the reviewed tree and push main and its version tag. +Release attachment publication requires the separately authorized Gitea session. +""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys + +from sync_version import synchronize + +ROOT = Path(__file__).resolve().parent + +GIT = ['git', '-c', 'safe.directory=' + ROOT.as_posix()] + +def run(*args): + if args[0] == 'git': + args = (*GIT, *args[1:]) + subprocess.run(args, cwd=ROOT, check=True) + +def linux(*args): + if os.name == 'nt': + path = '/mnt/' + str(ROOT)[0].lower() + str(ROOT)[2:].replace('\\', '/') + run('wsl', '-d', 'Debian', '--cd', path, '--exec', *args) + else: + run(*args) + +def verify_package(version): + import io + import tarfile + package = ROOT / f'HyperDRM_{version}_arm.ipk' + data = package.read_bytes() + manifest = json.loads((ROOT / 'HyperDRM.manifest.json').read_text(encoding='utf-8')) + assert manifest['version'] == version + assert manifest['ipkSize'] == len(data) + assert manifest['ipkHash']['sha256'] == hashlib.sha256(data).hexdigest() + assert data.startswith(b'!\n') + offset = 8 + members = {} + while offset < len(data): + header = data[offset:offset + 60] + assert len(header) == 60 and header[58:60] == b'`\n' + size = int(header[48:58]) + offset += 60 + members[header[:16].decode().strip().rstrip('/')] = data[offset:offset + size] + offset += size + size % 2 + with tarfile.open(fileobj=io.BytesIO(members['data.tar.gz']), mode='r:gz') as archive: + for directory, filename in [('applications', 'appinfo.json'), ('packages', 'packageinfo.json')]: + entry = archive.extractfile(f'usr/palm/{directory}/org.webosbrew.histogramhyperion/{filename}') + assert json.load(entry)['version'] == version + binary = archive.extractfile('usr/palm/services/org.webosbrew.histogramhyperion.service/histogram-service').read() + assert binary[:4] == b'\x7fELF' and binary[4] == 1 and binary[18:20] == b'\x28\x00' + assert version.encode() in binary + print(f'Verified {package.name}: {len(data)} bytes, SHA256 {manifest["ipkHash"]["sha256"]}') + return package + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--push', action='store_true') + options = parser.parse_args() + version = synchronize() + run(sys.executable, '-m', 'unittest', 'discover', '-s', 'tests', '-p', 'test_*.py') + node = shutil.which('node') + if not node: + raise RuntimeError('Node.js is required for UI tests') + for test in sorted((ROOT / 'tests').glob('*.test.js')): + run(node, str(test)) + # Host tests exercise the same portable code without LG libraries or a TV. + for test in sorted((ROOT / 'native').glob('test_*.c')): + name = '/tmp/hyperdrm-' + test.stem + linux('gcc', '-Wall', '-Wextra', '-Werror', '-O2', str(test.relative_to(ROOT)).replace('\\', '/'), '-ldl', '-lm', '-o', name) + linux(name) + linux('arm-linux-gnueabi-gcc', '-Wall', '-Wextra', '-Werror', '-O2', '-rdynamic', + 'native/service.c', '-ldl', '-o', 'service/histogram-service') + run(sys.executable, 'build_package.py') + run(sys.executable, 'build_manifest.py') + verify_package(version) + run('git', 'diff', '--check') + if options.push: + branch = subprocess.check_output(GIT + ['branch', '--show-current'], cwd=ROOT, text=True).strip() + if branch != 'main': + raise RuntimeError('Release pushes require main') + # Fail before committing when this version already exists. + exists = subprocess.run(GIT + ['rev-parse', '--verify', 'refs/tags/v' + version], cwd=ROOT, capture_output=True) + if exists.returncode == 0: + raise RuntimeError('Version tag already exists; bump VERSION') + run('git', 'add', '--all') + run('git', 'commit', '-m', f'Release {version}') + run('git', 'tag', 'v' + version) + run('git', 'push', '--atomic', 'origin', 'main', 'v' + version) + print('Attach the verified IPK to the matching Gitea release through an authorized session.') + +if __name__ == '__main__': + main() diff --git a/service/histogram-service b/service/histogram-service index 02c027d..d956250 100644 Binary files a/service/histogram-service and b/service/histogram-service differ diff --git a/sync_version.py b/sync_version.py new file mode 100644 index 0000000..bc39eca --- /dev/null +++ b/sync_version.py @@ -0,0 +1,33 @@ +"""Generate runtime version metadata from VERSION; --check detects drift.""" +import argparse +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent + +def synchronize(check=False): + version = (ROOT / 'VERSION').read_text(encoding='utf-8').strip() + if not re.fullmatch(r'\d+\.\d+\.\d+', version): + raise ValueError('VERSION must contain a semantic release version') + updates = {} + for filename in ('app/appinfo.json', 'package/packageinfo.json'): + path = ROOT / filename + data = json.loads(path.read_text(encoding='utf-8')) + if data['version'] != version: + data['version'] = version + updates[path] = json.dumps(data, indent=2, ensure_ascii=False) + '\n' + header = '#ifndef HYPERDRM_VERSION_H\n#define HYPERDRM_VERSION_H\n#define HYPERDRM_VERSION "' + version + '"\n#endif\n' + path = ROOT / 'native/version.h' + if not path.exists() or path.read_text(encoding='utf-8') != header: + updates[path] = header + if check and updates: + raise ValueError('Version metadata is stale; run sync_version.py') + for path, content in updates.items(): + path.write_text(content, encoding='utf-8') + return version + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--check', action='store_true') + synchronize(parser.parse_args().check) diff --git a/tests/root-repair.test.js b/tests/root-repair.test.js index 6ae5464..4ef7ed6 100644 --- a/tests/root-repair.test.js +++ b/tests/root-repair.test.js @@ -22,6 +22,9 @@ const grant=requests.find(r=>r.uri.includes('hbchannel'));assert.deepEqual(grant 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')); +const retainedError=elements.error.textContent;c.call('status',c.display);assert.equal(elements.error.textContent,retainedError); +c.showActionError('Invalid receiver settings');c.call('status',c.display);assert.equal(elements.error.textContent,'Invalid receiver settings'); 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); +assert.equal(c.actionError,''); 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/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..8657a32 --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,50 @@ +"""Version drift must fail before an inconsistent package can be built.""" +import json +from pathlib import Path +import tempfile +import unittest + +import sync_version + + +class VersionTests(unittest.TestCase): + def setUp(self): + self.directory = tempfile.TemporaryDirectory() + self.previous_root = sync_version.ROOT + sync_version.ROOT = Path(self.directory.name) + for name in ('app', 'package', 'native'): + (sync_version.ROOT / name).mkdir() + (sync_version.ROOT / 'VERSION').write_text('1.2.3\n') + for name in ('app/appinfo.json', 'package/packageinfo.json'): + (sync_version.ROOT / name).write_text(json.dumps({'version': '1.0.0', 'id': 'unchanged'})) + + def tearDown(self): + sync_version.ROOT = self.previous_root + self.directory.cleanup() + + def test_check_rejects_drift_without_writing(self): + with self.assertRaises(ValueError): + sync_version.synchronize(check=True) + self.assertFalse((sync_version.ROOT / 'native/version.h').exists()) + + def test_sync_preserves_metadata_and_produces_runtime_version(self): + self.assertEqual(sync_version.synchronize(), '1.2.3') + sync_version.synchronize(check=True) + app = json.loads((sync_version.ROOT / 'app/appinfo.json').read_text()) + self.assertEqual(app, {'version': '1.2.3', 'id': 'unchanged'}) + self.assertIn('"1.2.3"', (sync_version.ROOT / 'native/version.h').read_text()) + + def test_invalid_version_is_rejected(self): + (sync_version.ROOT / 'VERSION').write_text('1.2\n') + with self.assertRaises(ValueError): + sync_version.synchronize() + + def test_native_drift_is_rejected(self): + sync_version.synchronize() + (sync_version.ROOT / 'native/version.h').write_text('old version') + with self.assertRaises(ValueError): + sync_version.synchronize(check=True) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/wizard.test.js b/tests/wizard.test.js index 373004d..a8b361a 100644 --- a/tests/wizard.test.js +++ b/tests/wizard.test.js @@ -1,11 +1,11 @@ 'use strict'; const assert=require('assert'),fs=require('fs'),vm=require('vm'); -const elements={},events={},requests=[];let active=false,sequence=0,globalResponse=false; +const elements={},events={},requests=[];let active=false,sequence=0,globalResponse=false,holdStatus=false,heldStatus=null; ['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; + requests.push({method,data});if(method==='status' && holdStatus){heldStatus=done;return;}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)); @@ -29,4 +29,6 @@ c.profileDirty=false;globalResponse=true;measure();assert(elements.wizardStatus. 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); +elements.calibrate.onclick();holdStatus=true;elements.calibrationVideo.currentTime=1;c.wizardSample();assert(heldStatus);const oldReply=heldStatus; +elements.cancelCalibration.onclick();holdStatus=false;elements.calibrate.onclick();const newSession=c.wizard;oldReply({returnValue:false,errorText:'Old failure'});assert.strictEqual(c.wizard,newSession);assert(!c.wizard.busy);elements.cancelCalibration.onclick(); console.log('PASS: guided measurement, proposal without save, invalid-response preservation, validation, cancel/Back and scoped remote navigation');