Release 0.12.0

This commit is contained in:
matze19999 committed 2026-10-06 22:01:38 +02:00
1 parent f74a658097
commit 7f6eeab39e
48 files changed
+3617 -1266

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
BasedOnStyle: LLVM
IndentWidth: 4
ColumnLimit: 100
AllowShortFunctionsOnASingleLine: Empty
AllowShortIfStatementsOnASingleLine: Never
AllowShortLoopsOnASingleLine: false
SortIncludes: Never
+4 -4
View File
@@ -1,15 +1,15 @@
{ {
"id": "org.webosbrew.histogramhyperion", "id": "org.webosbrew.histogramhyperion",
"version": "0.11.0", "version": "0.12.0",
"type": "web", "type": "web",
"title": "HyperDRM", "title": "HyperDRM",
"appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR",
"iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png", "iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png",
"sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM",
"rootRequired": true, "rootRequired": true,
"ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.11.0_arm.ipk", "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.12.0_arm.ipk",
"ipkHash": { "ipkHash": {
"sha256": "4e2be7de5f6e06cca4a389d17b18c770aa47ef2d862aac651c16ed02f231d711" "sha256": "b4fcc609d763b8e1a72d84484a5d454627416dde0ef51ef65ab705173e3c0810"
}, },
"ipkSize": 1203394 "ipkSize": 1204332
} }
Binary file not shown.
+18 -1
View File
@@ -27,7 +27,7 @@ Only the own service is modified. Optional Auto PicCap can configure its receive
## Installation ## 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: 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 separate messages. The explicit launch action `{"action":"repairRoot"}` invokes
the same visible repair flow. Homebrew Channel must be installed and permit the the same visible repair flow. Homebrew Channel must be installed and permit the
request; denied elevation is reported without changing autostart. 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.
+1
View File
@@ -0,0 +1 @@
0.12.0
+340 -105
View File
@@ -1,117 +1,352 @@
'use strict'; 'use strict';
var pending=[]; var pending = [];
var targetDirty=false,receiver='Hyperion'; var actionError = '';
var profileDirty=false,profile='auto',advancedProfile=false;
var profileNames={auto:'Auto',g4:'LG G4 (measured)',average:'Average (uniform)',custom:'Custom model'}; function showActionError(message) {
function showProfile(){ actionError = message || '';
document.getElementById('profile').textContent='Profile: '+profileNames[profile]; document.getElementById('error').textContent = actionError;
var visible=profile==='custom' && advancedProfile;
document.getElementById('customEditor').style.display=visible?'block':'none';
document.getElementById('customProfile').disabled=!visible;
document.getElementById('editProfile').disabled=profile!=='custom';
document.getElementById('editProfile').textContent=visible?'Hide profile JSON':'Edit profile JSON';
} }
function call(method,done,data){callUri('luna://org.webosbrew.histogramhyperion.service/'+method,done,data);} function clearActionError() {
function callUri(uri,done,data){ showActionError('');
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){ var targetDirty = false,
if(typeof result.uid==='number'){ receiver = 'Hyperion';
document.getElementById('grantRoot').style.display=result.uid===0?'none':'inline-block'; var profileDirty = false,
document.getElementById('rootHint').style.display=result.uid===0?'none':'block'; profile = 'auto',
} advancedProfile = false;
if(result.calibrationProfile && !profileDirty){ var profileNames = {
profile=result.calibrationProfile;showProfile(); auto: 'Auto',
document.getElementById('customProfile').value=JSON.stringify({model:result.customModel||result.tvModel||'TV-MODEL',points:result.customPoints&&result.customPoints.length?result.customPoints:[]},null,2); g4: 'LG G4 (measured)',
} average: 'Average (uniform)',
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':''); custom: 'Custom model'
};
if(result.host && !targetDirty){ function showProfile() {
document.getElementById('host').value=result.host;document.getElementById('port').value=result.port;document.getElementById('piccapPort').value=result.piccapPort||19400;receiver=result.receiver; document.getElementById('profile').textContent = 'Profile: ' + profileNames[profile];
document.getElementById('receiver').textContent='Receiver: '+receiver; var visible = profile === 'custom' && advancedProfile;
} document.getElementById('customEditor').style.display = visible ? 'block' : 'none';
document.getElementById('token').placeholder=result.tokenSet?'Saved token (leave blank to keep)':'No token saved'; document.getElementById('customProfile').disabled = !visible;
if(result.endpoint)document.getElementById('endpoint').textContent=result.receiver+' · '+result.endpoint+' · Priority 149 · 10 updates per second'; document.getElementById('editProfile').disabled = profile !== 'custom';
var t=result.connectionTest; document.getElementById('editProfile').textContent = visible ? 'Hide profile JSON' : 'Edit profile JSON';
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);});
} }
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);});}; function call(method, done, data) {
document.getElementById('refresh').onclick=function(){call('status',display);}; if (['start', 'stop', 'autostart', 'handover', 'settings', 'testConnection', 'calibration'].indexOf(method) >= 0) clearActionError();
call('status',display);setInterval(function(){if(!pending.length)call('status',display);},3000); 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(); document.getElementById('start').focus();
var repairingRoot=false,rootWaiters=[]; var repairingRoot = false,
function repairRoot(done){ rootWaiters = [];
rootWaiters.push(done);if(repairingRoot)return;repairingRoot=true;document.getElementById('grantRoot').disabled=true;
document.getElementById('error').textContent='Granting root access to HyperDRM...'; function repairRoot(done) {
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);});} clearActionError();
callUri('luna://org.webosbrew.hbchannel.service/elevateService',function(r){ rootWaiters.push(done);
if(r.returnValue===false){finish('Homebrew Channel could not grant root access: '+(r.errorText||'Check Homebrew Channel installation.'));return;} if (repairingRoot) return;
call('restartService',function(r){ repairingRoot = true;
if(r.returnValue===false){finish(r.errorText||'Could not restart the HyperDRM service.');return;} document.getElementById('grantRoot').disabled = true;
var attempts=0; showActionError('Granting root access to HyperDRM...');
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); function finish(error) {
}); repairingRoot = false;
},{id:'org.webosbrew.histogramhyperion.service'}); 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('grantRoot').onclick = function() {
document.getElementById('autostart').onclick=function(){ repairRoot(function(ok) {
var button=this,enabled=button.getAttribute('aria-checked')!=='true';button.disabled=true; if (ok) call('status', display);
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); document.getElementById('autostart').onclick = function() {
},{enabled:enabled});} var button = this,
save(); 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'});}; document.getElementById('handover').onclick = function() {
['host','port','piccapPort','token','clearToken'].forEach(function(id){document.getElementById(id).addEventListener('input',function(){targetDirty=true;});}); var button = this;
document.getElementById('receiver').onclick=function(){receiver=receiver==='Hyperion'?'HyperHDR':'Hyperion';this.textContent='Receiver: '+receiver;targetDirty=true;}; button.disabled = true;
document.getElementById('saveTarget').onclick=function(){ call('handover', function(r) {
var port=Number(document.getElementById('port').value),button=this; button.disabled = false;
if(!Number.isInteger(port)||port<1||port>65535){document.getElementById('error').textContent='JSON port must be an integer from 1 to 65535.';return;} if (r.returnValue === false) showActionError(r.errorText);
var flatPort=Number(document.getElementById('piccapPort').value); else call('status', display);
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}; enabled: button.getAttribute('aria-checked') !== 'true'
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('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);});}; ['host', 'port', 'piccapPort', 'token', 'clearToken'].forEach(function(id) {
document.getElementById('profile').onclick=function(){var keys=['auto','g4','average','custom'];profile=keys[(keys.indexOf(profile)+1)%keys.length];profileDirty=true;showProfile();}; document.getElementById(id).addEventListener('input', function() {
document.getElementById('customProfile').addEventListener('input',function(){profileDirty=true;}); targetDirty = 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); 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);
+14 -1
View File
@@ -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"
}
+78 -32
View File
@@ -1,35 +1,81 @@
/* Pure measurement processing; the video uses 32 clockwise edge patches. */ /* Pure measurement processing; the video uses 32 clockwise edge patches. */
function calibrationFit(baseline,patches,model){ function calibrationFit(baseline, patches, model) {
if(!model || baseline.length!==16 || patches.length!==32)throw new Error('Incomplete calibration measurements.'); if (!model || baseline.length !== 16 || patches.length !== 32) throw new Error('Incomplete calibration measurements.');
var candidates=[],edges=[0,0,0,0]; var candidates = [],
for(var region=0;region<16;region++){ edges = [0, 0, 0, 0];
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]);}); for (var region = 0; region < 16; region++) {
var peak=Math.max.apply(Math,responses),best=responses.indexOf(peak); var responses = patches.map(function(p) {
if(peak<8)continue; if (!p || p.length !== 16) throw new Error('Missing video measurements.');
// Reject regions responding equally around the screen (global histograms). return Math.max(0, p[region] - baseline[region]);
var strong=responses.filter(function(v){return v>=peak*.65;}).length; });
if(strong>10)continue; var peak = Math.max.apply(Math, responses),
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;}); best = responses.indexOf(peak);
if(distant)continue; if (peak < 8) continue;
var sum=0,weighted=0; // Reject regions responding equally around the screen (global histograms).
for(var offset=-3;offset<=3;offset++){ var strong = responses.filter(function(v) {
var index=(best+offset+32)%32,weight=Math.max(0,responses[index]-peak*.35); return v >= peak * .65;
weighted+=offset*weight;sum+=weight; }).length;
} if (strong > 10) continue;
var position=((best+.5+weighted/sum)/8+4)%4; var distant = responses.some(function(v, i) {
candidates.push({position:position,region:region,score:peak/strong}); var d = Math.min(Math.abs(i - best), 32 - Math.abs(i - best));
} return v >= peak * .65 && d > 6;
// Nested LG regions can measure the same edge position. Keep the most });
// localized response rather than making duplicate interpolation anchors. if (distant) continue;
candidates.sort(function(a,b){return b.score-a.score || a.region-b.region;}); var sum = 0,
var points=[]; weighted = 0;
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)]++;}); for (var offset = -3; offset <= 3; offset++) {
points.sort(function(a,b){return a.position-b.position;}); var index = (best + offset + 32) % 32,
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.'); weight = Math.max(0, responses[index] - peak * .35);
return {model:model,points:points}; 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){ function calibrationLuma(regions) {
if(samples.length<3)throw new Error('Not enough fresh histogram samples. Keep the app visible and repeat calibration.'); return regions.map(function(c) {
return samples[0].map(function(_,r){return samples.reduce(function(sum,s){return sum+s[r];},0)/samples.length;}); 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;
});
} }
+65 -19
View File
@@ -1,21 +1,67 @@
function showPanel(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));}); ['overview', 'receiver', 'calibration'].forEach(function(key) {
if(focus!==false)document.getElementById({overview:'start',receiver:'receiver',calibration:'calibrate'}[name]).focus(); 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);};}); ['overview', 'receiver', 'calibration'].forEach(function(name) {
document.getElementById('calibrationShortcut').onclick=function(){showPanel('calibration');}; document.getElementById(name + 'Tab').onclick = function() {
function setupFormEditors(ids,doc,win){ showPanel(name);
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; document.getElementById('calibrationShortcut').onclick = function() {
field.onclick=function(){if(input.disabled || !input.readOnly)return;input.readOnly=false;input.focus();}; showPanel('calibration');
input.addEventListener('blur',function(){input.readOnly=true;}); };
input.addEventListener('mousedown',function(event){if(input.readOnly)event.preventDefault();});
}); function setupFormEditors(ids, doc, win) {
function finishEditing(){ var pairs = ids.map(function(id) {
pairs.forEach(function(pair){if(!pair.input.readOnly){pair.input.readOnly=true;pair.input.blur();pair.field.focus();}}); return {
} input: doc.getElementById(id),
doc.addEventListener('keyboardStateChange',function(event){var detail=event.detail||{};if(detail.visibility===false || detail.isShowing===false)finishEditing();}); field: doc.getElementById(id + 'Field')
doc.addEventListener('keydown',function(event){if((event.keyCode===461 || event.key==='Escape') && pairs.some(function(pair){return !pair.input.readOnly;})){event.preventDefault();finishEditing();}}); };
});
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);
+46 -29
View File
@@ -1,32 +1,49 @@
/* Directional control for webOS remotes and standard keyboards. */ /* Directional control for webOS remotes and standard keyboards. */
function setupRemoteNavigation(buttons, doc, win) { function setupRemoteNavigation(buttons, doc, win) {
var lastFocused=buttons[0], enterDown=false; var lastFocused = buttons[0],
buttons.forEach(function(button){ enterDown = false;
button.addEventListener('focus',function(){lastFocused=button;}); buttons.forEach(function(button) {
button.addEventListener('mouseenter',function(){if(!button.disabled)button.focus();}); button.addEventListener('focus', function() {
}); lastFocused = button;
doc.addEventListener('keydown',function(event){ });
var code=event.keyCode||event.which; button.addEventListener('mouseenter', function() {
var direction=event.key==='ArrowLeft'||event.key==='ArrowUp'||code===37||code===38?-1: if (!button.disabled) button.focus();
event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0; });
var enter=event.key==='Enter'||code===13; });
if(!direction&&!enter)return; doc.addEventListener('keydown', function(event) {
var editing=doc.activeElement && !doc.activeElement.readOnly && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA'); var code = event.keyCode || event.which;
if(editing)return; var direction = event.key === 'ArrowLeft' || event.key === 'ArrowUp' || code === 37 || code === 38 ? -1 :
event.preventDefault(); event.key === 'ArrowRight' || event.key === 'ArrowDown' || code === 39 || code === 40 ? 1 : 0;
var enabled=buttons.filter(function(button){return !button.disabled && (typeof button.getClientRects!=='function' || button.getClientRects().length) && (!win.remoteNavigationScope || win.remoteNavigationScope.indexOf(button.id)>=0);}); var enter = event.key === 'Enter' || code === 13;
if(!enabled.length)return; if (!direction && !enter) return;
var focused=doc.activeElement,index=enabled.indexOf(focused); var editing = doc.activeElement && !doc.activeElement.readOnly && (doc.activeElement.tagName === 'INPUT' && doc.activeElement.type !== 'checkbox' || doc.activeElement.tagName === 'TEXTAREA');
if(direction){ if (editing) return;
if(index<0){index=enabled.indexOf(lastFocused);if(index<0)index=0;enabled[index].focus();} event.preventDefault();
else enabled[(index+direction+enabled.length)%enabled.length].focus(); var enabled = buttons.filter(function(button) {
}else{ return !button.disabled && (typeof button.getClientRects !== 'function' || button.getClientRects().length) && (!win.remoteNavigationScope || win.remoteNavigationScope.indexOf(button.id) >= 0);
if(enterDown||event.repeat)return; });
enterDown=true; if (!enabled.length) return;
if(index<0){index=enabled.indexOf(lastFocused);enabled[index<0?0:index].focus();} var focused = doc.activeElement,
else focused.click(); index = enabled.indexOf(focused);
} if (direction) {
}); if (index < 0) {
doc.addEventListener('keyup',function(event){if(event.key==='Enter'||event.keyCode===13||event.which===13)enterDown=false;}); index = enabled.indexOf(lastFocused);
win.addEventListener('blur',function(){enterDown=false;}); 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;
});
} }
+203 -82
View File
@@ -1,87 +1,208 @@
'use strict'; 'use strict';
var wizard=null; var WizardPhase = {
var video=document.getElementById('calibrationVideo'); STARTING: 'starting',
function wizardFinish(error){ WAITING: 'waiting',
if(!wizard)return; MEASURING: 'measuring',
clearInterval(wizard.timer);wizard=null;window.remoteNavigationScope=null;video.pause();video.style.display='none'; VALIDATING: 'validating',
if(typeof video.removeAttribute==='function'){video.removeAttribute('src');video.load();} COMMITTING: 'committing'
document.getElementById('cancelCalibration').disabled=true; };
document.getElementById('calibrate').disabled=false;document.getElementById('validateCalibration').disabled=false; var wizard = null;
call('calibrationSession',function(r){if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;else call('status',display);},{enabled:false}); var video = document.getElementById('calibrationVideo');
call('calibrationResult',function(){},{message:error||document.getElementById('wizardStatus').textContent});
if(error)document.getElementById('wizardStatus').textContent=error; function wizardFinish(error) {
document.getElementById('calibrate').focus(); 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; function wizardSample() {
if(Date.now()-wizard.started>100000){wizardFinish('Calibration timed out. Saved profile unchanged.');return;} if (!wizard || wizard.busy) return;
if(wizard.validation)return; var session = wizard;
var t=video.currentTime; if (Date.now() - wizard.started > 100000) {
if(t>=68){ wizardFinish('Calibration timed out. Saved profile unchanged.');
try{ return;
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); if (wizard.phase !== WizardPhase.MEASURING) return;
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.'; var t = video.currentTime;
wizard.busy=true; if (t >= 68) {
call('calibrationDraft',function(r){if(wizard)wizardFinish(r.returnValue===false?r.errorText:undefined);},{profile:'custom',model:measured.model,points:measured.points}); try {
}catch(e){wizardFinish(e.message);} var measured = calibrationFit(calibrationAverage(wizard.baseline), wizard.patches.map(calibrationAverage), wizard.model);
return; profile = 'custom';
} profileDirty = true;
var step=t<4?-1:Math.floor((t-4)/2),local=t<4?t:(t-4)%2; showProfile();
if(step<0 && (t<.8 || t>3.2) || step>=0 && (local<.65 || local>1.75))return; document.getElementById('customProfile').value = JSON.stringify(measured, null, 2);
wizard.busy=true; 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.';
call('status',function(s){ wizard.busy = true;
if(!wizard)return; wizard.phase = WizardPhase.COMMITTING;
wizard.busy=false; call('calibrationDraft', function(r) {
if(s.returnValue===false || !s.calibrating){wizardFinish(s.errorText||'Calibration session ended. Saved profile unchanged.');return;} if (wizard === session) wizardFinish(r.returnValue === false ? r.errorText : undefined);
// Ignore duplicates, held output and replies crossing a stimulus boundary. }, {
var current=video.currentTime,nowStep=current<4?-1:Math.floor((current-4)/2); profile: 'custom',
if(nowStep!==step || s.sampleSequence===wizard.sequence || s.sampleAgeMs<0 || s.sampleAgeMs>350 || !s.colorValid || !Array.isArray(s.regions) || s.regions.length!==16)return; model: measured.model,
wizard.sequence=s.sampleSequence; points: measured.points
(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...'; } 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; function wizardStart(validation) {
if(typeof showPanel==='function')showPanel('calibration',false); if (wizard) return;
if(targetDirty || profileDirty){document.getElementById('wizardStatus').textContent='Save receiver and profile edits before starting.';return;} if (typeof showPanel === 'function') showPanel('calibration', false);
document.getElementById('calibrate').disabled=true;document.getElementById('validateCalibration').disabled=true; if (targetDirty || profileDirty) {
document.getElementById('cancelCalibration').disabled=false; document.getElementById('wizardStatus').textContent = 'Save receiver and profile edits before starting.';
window.remoteNavigationScope=['cancelCalibration']; return;
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.'; document.getElementById('calibrate').disabled = true;
call('calibrationSession',function(r){ document.getElementById('validateCalibration').disabled = true;
if(!wizard)return;if(r.returnValue===false){wizardFinish(r.errorText);return;} document.getElementById('cancelCalibration').disabled = false;
// Start the video plane before waiting for histogram data. Without another window.remoteNavigationScope = ['cancelCalibration'];
// running video, waiting first would prevent calibration from ever starting. wizard = {
video.src='calibration.mp4';if(typeof video.load==='function')video.load(); phase: WizardPhase.STARTING,
video.style.display='block';var warmup=video.play(); validation: validation,
if(warmup && warmup.catch)warmup.catch(function(){wizardFinish('The TV could not play the calibration video. Saved profile unchanged.');}); started: Date.now(),
call('start',function(){waitCapture();}); busy: false,
},{enabled:true}); sequence: -1,
function waitCapture(){ model: '',
if(!wizard)return; baseline: [],
call('status',function(s){ patches: Array.from({
if(!wizard)return; length: 32
if(s.running && s.activeSource!=='PicCap' && s.tvModel && s.colorValid){ }, function() {
wizard.model=s.tvModel;video.currentTime=validation?68:0;video.style.display='block'; return [];
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); var session = wizard;
}else if(Date.now()-wizard.started<15000)setTimeout(waitCapture,500); document.getElementById('wizardStatus').textContent = 'Starting full-screen capture. Keep the app visible; do not change zoom or aspect ratio.';
else wizardFinish('No fresh full-screen histogram capture or TV model. Saved profile unchanged.'); 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('calibrate').onclick = function() {
document.getElementById('validateCalibration').onclick=function(){wizardStart(true);}; wizardStart(false);
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();}); document.getElementById('validateCalibration').onclick = function() {
video.addEventListener('error',function(){wizardFinish('Calibration video playback failed. Saved profile unchanged.');}); wizardStart(true);
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('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. // 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.';}} function wizardLaunch(raw) {
if(typeof PalmSystem!=='undefined' && PalmSystem.launchParams)setTimeout(function(){wizardLaunch(PalmSystem.launchParams);},1000); try {
window.addEventListener('webOSRelaunch',function(){if(typeof PalmSystem!=='undefined')wizardLaunch(PalmSystem.launchParams);}); 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);
});
+2
View File
@@ -1,6 +1,8 @@
"""Describe the exact built IPK for Homebrew Channel.""" """Describe the exact built IPK for Homebrew Channel."""
import hashlib, json import hashlib, json
from pathlib import Path from pathlib import Path
from sync_version import synchronize
synchronize(check=True)
ROOT=Path(__file__).resolve().parent ROOT=Path(__file__).resolve().parent
BASE='https://gitea.adnau.de/matze19999/HyperDRM' BASE='https://gitea.adnau.de/matze19999/HyperDRM'
RAW=BASE+'/raw/branch/main/' RAW=BASE+'/raw/branch/main/'
+5 -3
View File
@@ -1,16 +1,18 @@
"""Package own app and executable; no Signify or LG binaries are included.""" """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 pathlib import Path
from sync_version import synchronize
synchronize(check=True)
ROOT=Path(__file__).resolve().parent ROOT=Path(__file__).resolve().parent
ID='org.webosbrew.histogramhyperion' ID='org.webosbrew.histogramhyperion'
VERSION=json.loads((ROOT/'app/appinfo.json').read_text(encoding='utf-8'))['version'] VERSION=json.loads((ROOT/'app/appinfo.json').read_text(encoding='utf-8'))['version']
def tar_bytes(entries): def tar_bytes(entries):
stream=io.BytesIO() 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: for name,body,mode in entries:
info=tarfile.TarInfo(name);info.size=len(body);info.mode=mode;info.mtime=0 info=tarfile.TarInfo(name);info.size=len(body);info.mode=mode;info.mtime=0
tar.addfile(info,io.BytesIO(body)) tar.addfile(info,io.BytesIO(body))
return stream.getvalue() return gzip.compress(stream.getvalue(), mtime=0)
entries=[] entries=[]
for directory,target in [('app','applications/'+ID),('service','services/'+ID+'.service'),('package','packages/'+ID)]: for directory,target in [('app','applications/'+ID),('service','services/'+ID+'.service'),('package','packages/'+ID)]:
for path in sorted((ROOT/directory).iterdir()): for path in sorted((ROOT/directory).iterdir()):
+46
View File
@@ -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.
+21
View File
@@ -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 Autostart restored to On. No receiver color correction, WLED configuration or
PicCap autostart setting was changed. A local browser preview was unavailable PicCap autostart setting was changed. A local browser preview was unavailable
because the browser policy rejected file URLs; no alternate browser path was used. 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.
+11
View File
@@ -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.
+56 -24
View File
@@ -4,34 +4,66 @@
#define BOOT_DIRECTORY "/var/lib/webosbrew/init.d" #define BOOT_DIRECTORY "/var/lib/webosbrew/init.d"
#endif #endif
#ifndef BOOT_SCRIPT #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 #endif
#define BOOT_HOOK BOOT_DIRECTORY "/60-histogramhyperion" #define BOOT_HOOK BOOT_DIRECTORY "/60-histogramhyperion"
/* Exact pre-0.6.1 hook; only this known own file may be migrated. */ /* 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 </dev/null &\nexit 0\n"; 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 </dev/null "
"&\nexit 0\n";
/* 0: absent, 1: own legacy copy, 2: own symlink, -1: foreign/error. */ /* 0: absent, 1: own legacy copy, 2: own symlink, -1: foreign/error. */
static int boot_hook_kind(void){ static int boot_hook_kind(void) {
struct stat st;if(lstat(BOOT_HOOK,&st)!=0)return errno==ENOENT?0:-1; struct stat st;
if(S_ISLNK(st.st_mode)){ if (lstat(BOOT_HOOK, &st) != 0)
char target[sizeof(BOOT_SCRIPT)+1];ssize_t n=readlink(BOOT_HOOK,target,sizeof(target)); return errno == ENOENT ? 0 : -1;
return n==(ssize_t)(sizeof(BOOT_SCRIPT)-1) && memcmp(target,BOOT_SCRIPT,(size_t)n)==0?2:-1; if (S_ISLNK(st.st_mode)) {
} char target[sizeof(BOOT_SCRIPT) + 1];
if(!S_ISREG(st.st_mode))return -1; ssize_t n = readlink(BOOT_HOOK, target, sizeof(target));
char data[sizeof(legacy_boot_script)];int fd=open(BOOT_HOOK,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return -1; return n == (ssize_t)(sizeof(BOOT_SCRIPT) - 1) &&
ssize_t n=read(fd,data,sizeof(data));close(fd); memcmp(target, BOOT_SCRIPT, (size_t)n) == 0
return n==(ssize_t)(sizeof(legacy_boot_script)-1) && memcmp(data,legacy_boot_script,(size_t)n)==0?1:-1; ? 2
: -1;
}
if (!S_ISREG(st.st_mode))
return -1;
char data[sizeof(legacy_boot_script)];
int fd = open(BOOT_HOOK, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
if (fd < 0)
return -1;
ssize_t n = read(fd, data, sizeof(data));
close(fd);
return n == (ssize_t)(sizeof(legacy_boot_script) - 1) &&
memcmp(data, legacy_boot_script, (size_t)n) == 0
? 1
: -1;
} }
static bool boot_enabled(void){return boot_hook_kind()>0 && access(BOOT_HOOK,X_OK)==0;} static bool boot_enabled(void) {
static bool set_boot(bool enabled){ return boot_hook_kind() > 0 && access(BOOT_HOOK, X_OK) == 0;
int kind=boot_hook_kind();if(kind<0)return false; }
if(!enabled)return kind==0 || unlink(BOOT_HOOK)==0; static bool set_boot(bool enabled) {
if(access(BOOT_SCRIPT,X_OK)!=0)return false; int kind = boot_hook_kind();
if(kind==2)return true; if (kind < 0)
if(mkdir(BOOT_DIRECTORY,0755)!=0 && errno!=EEXIST)return false; return false;
/* Never overwrite another process's temporary file or a foreign hook. */ if (!enabled)
if(symlink(BOOT_SCRIPT,BOOT_HOOK ".tmp")!=0)return false; return kind == 0 || unlink(BOOT_HOOK) == 0;
bool ok=rename(BOOT_HOOK ".tmp",BOOT_HOOK)==0; if (access(BOOT_SCRIPT, X_OK) != 0)
if(!ok)unlink(BOOT_HOOK ".tmp"); return false;
return ok; 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 #endif
+20 -13
View File
@@ -2,18 +2,25 @@
#define BLACKBARS_H #define BLACKBARS_H
/* Calibrated preset-0 edge probes on this G4. Return -1 for an ambiguous /* 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. */ * 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]) { 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; const unsigned interior[] = {2, 6, 10, 15};
for(unsigned i=0;i<4;i++){ unsigned lit = 0;
unsigned r=interior[i],peak=color[r][0]; for (unsigned i = 0; i < 4; i++) {
for(unsigned c=1;c<3;c++)if(color[r][c]>peak)peak=color[r][c]; unsigned r = interior[i], peak = color[r][0];
if(black[r]<20 && peak>35)lit++; for (unsigned c = 1; c < 3; c++)
} if (color[r][c] > peak)
if(lit<2)return -1; peak = color[r][c];
unsigned top=(black[3]+black[11]+black[12])/3; if (black[r] < 20 && peak > 35)
unsigned bottom=(black[1]+black[5]+black[9])/3; lit++;
if(top>=55 && bottom>=25)return 1; }
if(top<20 && bottom<20)return 0; if (lit < 2)
return -1; 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 #endif
+140 -68
View File
@@ -2,83 +2,155 @@
#define HYPERDRM_CALIBRATION_H #define HYPERDRM_CALIBRATION_H
#include <math.h> #include <math.h>
#include "playback.h" #include "playback.h"
#define CALIBRATION_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/calibration.json" #define CALIBRATION_PREF \
typedef struct {double position;unsigned char region;} RimPoint; "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/" \
typedef enum {CAL_AUTO,CAL_G4,CAL_AVERAGE,CAL_CUSTOM} CalibrationKind; "calibration.json"
typedef struct {CalibrationKind kind;char model[64];unsigned count;RimPoint points[16];} Calibration; 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 Calibration calibration;
static char tv_model[64]; static char tv_model[64];
static unsigned calibration_generation=1; static unsigned calibration_generation = 1;
static inline unsigned calibration_revision(void){return calibration_generation;} static inline unsigned calibration_revision(void) {
static const RimPoint g4_rim[]={ return calibration_generation;
{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){ static const RimPoint g4_rim[] = {{0.2078431373, 7}, {0.3176470588, 3}, {0.5490196078, 11},
if(!model[0] || strlen(model)>63)return false; {0.7529411765, 12}, {0.8392156863, 13}, {1.4941176471, 10},
for(const unsigned char *p=(const unsigned char*)model;*p;p++) {2.1843137255, 9}, {2.5490196078, 1}, {2.8666666667, 5},
if(!((*p>='A'&&*p<='Z')||(*p>='a'&&*p<='z')||(*p>='0'&&*p<='9')||*p=='.'||*p=='-'||*p=='_'||*p==' '))return false; {3.4, 15}};
return true; 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. */ /* LG retail model identifiers: OLED + two-digit panel size + G4 series. */
static inline bool calibration_is_g4(const char *model){ 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'; 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){ static inline CalibrationKind calibration_effective(void) {
if(calibration.kind!=CAL_AUTO)return calibration.kind; if (calibration.kind != CAL_AUTO)
if(calibration.count>=2 && tv_model[0] && !strcmp(calibration.model,tv_model))return CAL_CUSTOM; return calibration.kind;
return calibration_is_g4(tv_model)?CAL_G4:CAL_AVERAGE; 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){ static inline bool calibration_parse(PlaybackJson *j, const char *text, Calibration *result) {
void *root=j->parse(text);if(!root)return false; void *root = j->parse(text);
Calibration next=*result;bool valid=false; if (!root)
for(unsigned k=0;k<4;k++)if(playback_is(j,root,"profile",calibration_key(k))){next.kind=k;valid=true;break;} return false;
void *points=NULL; Calibration next = *result;
if(j->type(root)==4 && j->get(root,"points",&points)){ bool valid = false;
void *model=playback_field(j,root,"model"); for (unsigned k = 0; k < 4; k++)
valid=valid && points && j->type(points)==5 && model && j->type(model)==6 && calibration_model_valid(j->string(model)); if (playback_is(j, root, "profile", calibration_key(k))) {
size_t count=valid?j->length(points):0;valid=valid && count>=2 && count<=16; next.kind = k;
if(valid){strcpy(next.model,j->string(model));next.count=count;memset(next.points,0,sizeof(next.points));} valid = true;
for(size_t i=0;valid && i<count;i++){ break;
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; void *points = NULL;
if(valid){char *end;double x=strtod(j->string(position),&end);valid=end && !*end && isfinite(x) && x>=0 && x<4; if (j->type(root) == 4 && j->get(root, "points", &points)) {
long r=strtol(j->string(region),&end,10);valid=valid && end && !*end && r>=0 && r<16 && (!i || x>next.points[i-1].position); void *model = playback_field(j, root, "model");
if(valid){next.points[i].position=x;next.points[i].region=r;} 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(next.kind==CAL_CUSTOM && next.count<2)valid=false; if (valid) {
j->put(root);if(valid)*result=next;return 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){ 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)); char temp[512], data[2048];
if(value->count){ int used = snprintf(data, sizeof(data), "{\"profile\":\"%s\"", calibration_key(value->kind));
used+=snprintf(data+used,sizeof(data)-used,",\"model\":\"%s\",\"points\":[",value->model); if (value->count) {
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, ",\"model\":\"%s\",\"points\":[",
used+=snprintf(data+used,sizeof(data)-used,"]"); value->model);
} for (unsigned i = 0; i < value->count; i++)
used+=snprintf(data+used,sizeof(data)-used,"}\n"); used +=
if(snprintf(temp,sizeof(temp),"%s.tmp",path)>=(int)sizeof(temp))return false; snprintf(data + used, sizeof(data) - used, "%s{\"position\":%.12g,\"region\":%u}",
int fd=open(temp,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);if(fd<0)return false; i ? "," : "", value->points[i].position, value->points[i].region);
bool ok=write(fd,data,used)==used && fsync(fd)==0;if(close(fd)!=0)ok=false; used += snprintf(data + used, sizeof(data) - used, "]");
if(ok)ok=rename(temp,path)==0; }
if(!ok)unlink(temp); used += snprintf(data + used, sizeof(data) - used, "}\n");
return ok; 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){ static inline void calibration_load(PlaybackJson *j, const char *path) {
int fd=open(path,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return; int fd = open(path, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
char data[2048];ssize_t n=read(fd,data,sizeof(data)-1);close(fd); if (fd < 0)
if(n<=0 || n==(ssize_t)sizeof(data)-1)return; return;
data[n]=0;calibration_parse(j,data,&calibration); 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]){ static inline void calibration_colors(const unsigned char input[16][3],
memcpy(output,input,48);if(calibration_effective()!=CAL_AVERAGE)return; unsigned char output[16][3]) {
unsigned sum[3]={0};for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)sum[c]+=input[i][c]; memcpy(output, input, 48);
for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)output[i][c]=sum[c]/16; 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 #endif
+150
View File
@@ -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;
}
+3 -2
View File
@@ -1,7 +1,8 @@
#ifndef COLOR_BOOST_H #ifndef COLOR_BOOST_H
#define COLOR_BOOST_H #define COLOR_BOOST_H
/* Preserve the original saturation and brightness. */ /* Preserve the original saturation and brightness. */
static inline void boost_color(const unsigned char in[3],unsigned char out[3]){ 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]; for (unsigned c = 0; c < 3; c++)
out[c] = in[c];
} }
#endif #endif
+68 -27
View File
@@ -1,35 +1,76 @@
#ifndef HYPERDRM_CONTINUITY_H #ifndef HYPERDRM_CONTINUITY_H
#define HYPERDRM_CONTINUITY_H #define HYPERDRM_CONTINUITY_H
/* Zero population is missing data, while populated bin zero is real black. */ /* Zero population is missing data, while populated bin zero is real black. */
static inline bool histogram_populated(const Histogram hist[16]){ static inline bool histogram_populated(const Histogram hist[16]) {
for(unsigned r=0;r<16;r++)for(unsigned c=0;c<3;c++){ for (unsigned r = 0; r < 16; r++)
uint64_t total=0;for(unsigned b=0;b<256;b++)total+=hist[r].bins[c][b]; for (unsigned c = 0; c < 3; c++) {
if(!total)return false; uint64_t total = 0;
} for (unsigned b = 0; b < 256; b++)
return true; 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; typedef struct {
static inline void continuity_seed(Continuity *s,const unsigned char colors[16][3],uint64_t now){ unsigned char last[16][3], from[16][3];
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; 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]){ static inline void continuity_mix(const unsigned char from[16][3], const unsigned char to[16][3],
if(elapsed>500)elapsed=500; unsigned elapsed, unsigned char out[16][3]) {
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; 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]){ static inline bool continuity_frame(Continuity *s, bool valid, const unsigned char input[16][3],
if(!valid){ uint64_t now, unsigned char out[16][3]) {
if(!s->missing_at)s->missing_at=now; if (!valid) {
s->holding=s->available && now-s->missing_at<1500; if (!s->missing_at)
if(s->holding)memcpy(out,s->last,sizeof(s->last)); s->missing_at = now;
return s->holding; s->holding = s->available && now - s->missing_at < 1500;
} if (s->holding)
if(s->holding){memcpy(s->from,s->last,sizeof(s->from));s->fading=true;s->fade_at=0;} memcpy(out, s->last, sizeof(s->last));
s->holding=false;s->missing_at=0; return s->holding;
/* 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 (s->holding) {
if(black)s->fading=false; memcpy(s->from, s->last, sizeof(s->from));
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;} s->fading = true;
else memcpy(out,input,sizeof(s->last)); s->fade_at = 0;
memcpy(s->last,out,sizeof(s->last));s->available=true;s->valid_at=now;return true; }
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 #endif
+173 -66
View File
@@ -2,77 +2,184 @@
#define HYPERDRM_DIAGNOSTICS_H #define HYPERDRM_DIAGNOSTICS_H
/* An independent nonblocking connection. Tests never send colors or acquire /* An independent nonblocking connection. Tests never send colors or acquire
* capture. Work per timer iteration is bounded to keep output responsive. */ * capture. Work per timer iteration is bounded to keep output responsive. */
typedef enum {TEST_IDLE,TEST_CONNECT,TEST_SEND,TEST_LOGIN,TEST_LAYOUT,TEST_DONE,TEST_FAILED} TestStage; typedef enum {
TEST_IDLE,
TEST_CONNECT,
TEST_SEND,
TEST_LOGIN,
TEST_LAYOUT,
TEST_DONE,
TEST_FAILED
} TestStage;
typedef struct { typedef struct {
int fd;TestStage stage,after_send;TargetConfig target; int fd;
uint64_t started,deadline,elapsed;unsigned leds; TestStage stage, after_send;
char request[384],error[128];size_t sent,used;char *response; TargetConfig target;
LedBlend *mapping; uint64_t started, deadline, elapsed;
unsigned leds;
char request[384], error[128];
size_t sent, used;
char *response;
LedBlend *mapping;
} ReceiverProbe; } ReceiverProbe;
static ReceiverProbe connection_test={.fd=-1}; static ReceiverProbe connection_test = {.fd = -1};
static const char *probe_stage_name(const ReceiverProbe *probe){ static const char *probe_stage_name(const ReceiverProbe *probe) {
static const char *names[]={"Not tested","Connecting","Sending request","Checking authentication","Reading LED layout","Passed","Failed"}; static const char *names[] = {
return names[probe->stage]; "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 bool probe_busy(const ReceiverProbe *probe) {
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));} return probe->stage >= TEST_CONNECT && probe->stage <= TEST_LAYOUT;
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_request(ReceiverProbe *probe,bool login,uint64_t now){ static void probe_close(ReceiverProbe *probe) {
if(login)snprintf(probe->request,sizeof(probe->request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",probe->target.token); if (probe->fd >= 0)
else snprintf(probe->request,sizeof(probe->request),"{\"command\":\"serverinfo\"}\n"); close(probe->fd);
probe->sent=probe->used=0;probe->after_send=login?TEST_LOGIN:TEST_LAYOUT; probe->fd = -1;
probe->stage=TEST_SEND;probe->deadline=now+3000; 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){ static void probe_finish(ReceiverProbe *probe, const char *error, uint64_t now) {
if(probe_busy(probe))return false; probe_close(probe);
probe_close(probe);memset(probe,0,sizeof(*probe));probe->fd=-1; probe->elapsed = now - probe->started;
probe->target=*target;probe->started=now; probe->stage = error ? TEST_FAILED : TEST_DONE;
probe->response=malloc(2*1024*1024+1); snprintf(probe->error, sizeof(probe->error), "%s", error ? error : "");
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){ static void probe_request(ReceiverProbe *probe, bool login, uint64_t now) {
if(!probe_busy(probe))return; if (login)
if(now>=probe->deadline){probe_finish(probe,"Receiver timed out",now);return;} snprintf(probe->request, sizeof(probe->request),
int fd=probe->fd; "{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",
if(probe->stage==TEST_CONNECT){ probe->target.token);
struct pollfd p={fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error); else
if(poll(&p,1,0)<=0)return; snprintf(probe->request, sizeof(probe->request), "{\"command\":\"serverinfo\"}\n");
if(getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){probe_finish(probe,"Receiver connection refused or unreachable",now);return;} probe->sent = probe->used = 0;
probe_request(probe,probe->target.token[0]!=0,now);return; probe->after_send = login ? TEST_LOGIN : TEST_LAYOUT;
} probe->stage = TEST_SEND;
if(probe->stage==TEST_SEND){ probe->deadline = now + 3000;
size_t length=strlen(probe->request); }
ssize_t n=send(fd,probe->request+probe->sent,length-probe->sent,MSG_NOSIGNAL); static bool probe_begin(ReceiverProbe *probe, const TargetConfig *target, uint64_t now) {
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; if (probe_busy(probe))
if(n<=0){probe_finish(probe,"Receiver closed the test connection",now);return;} return false;
probe->sent+=(size_t)n; probe_close(probe);
if(probe->sent==length){probe->stage=probe->after_send;probe->deadline=now+3000;memset(probe->request,0,sizeof(probe->request));} memset(probe, 0, sizeof(*probe));
return; probe->fd = -1;
} probe->target = *target;
size_t available=2*1024*1024-probe->used;if(available>65536)available=65536; probe->started = now;
if(!available){probe_finish(probe,"Receiver response exceeds 2 MiB",now);return;} probe->response = malloc(2 * 1024 * 1024 + 1);
ssize_t n=recv(fd,probe->response+probe->used,available,0); if (!probe->response) {
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; probe_finish(probe, "Cannot allocate connection test buffer", now);
if(n<=0){probe_finish(probe,"Receiver closed the test connection",now);return;} return true;
probe->used+=(size_t)n;probe->response[probe->used]=0; }
char *end=strchr(probe->response,'\n');if(!end)return;*end=0; probe->fd = target_open(target);
void *root=json->parse(probe->response);bool success=playback_true(json,root,"success");if(root)json->put(root); if (probe->fd < 0) {
if(!success){probe_finish(probe,probe->stage==TEST_LOGIN?"Receiver authentication failed":"Receiver rejected serverinfo; check authorization",now);return;} probe_finish(probe, "Cannot open receiver connection", now);
if(probe->stage==TEST_LOGIN){probe_request(probe,false,now);return;} return true;
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->stage = TEST_CONNECT;
probe->leds=count;probe_finish(probe,NULL,now);probe->mapping=mapping; 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 #endif
+255 -105
View File
@@ -5,22 +5,32 @@
#define CALIBRATION_ACTIVE false #define CALIBRATION_ACTIVE false
#endif #endif
#ifndef PICCAP_BINARY #ifndef PICCAP_BINARY
#define PICCAP_BINARY "/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos" #define PICCAP_BINARY \
"/media/developer/apps/usr/palm/services/org.webosbrew.piccap.service/hyperion-webos"
#endif #endif
#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 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 PlaybackProtection protection;
static uint64_t protection_at,next_media_probe,next_piccap_probe,handover_cooldown,piccap_action_at; static uint64_t protection_at, next_media_probe, next_piccap_probe, handover_cooldown,
typedef enum {SOURCE_HISTOGRAM,SOURCE_PICCAP_STARTING,SOURCE_PICCAP,SOURCE_PICCAP_STOPPING} CaptureSource; piccap_action_at;
typedef enum {
SOURCE_HISTOGRAM,
SOURCE_PICCAP_STARTING,
SOURCE_PICCAP,
SOURCE_PICCAP_STOPPING
} CaptureSource;
static CaptureSource capture_source; static CaptureSource capture_source;
static void *service_handle; static void *service_handle;
static bool (*call_once)(void*,const char*,const char*,Callback,void*,unsigned long*,LSError*); static bool (*call_once)(void *, const char *, const char *, Callback, void *, unsigned long *,
static bool (*cancel_call)(void*,unsigned long,LSError*); LSError *);
static unsigned long piccap_token,media_token; static bool (*cancel_call)(void *, unsigned long, LSError *);
static uint64_t piccap_deadline,media_deadline; static unsigned long piccap_token, media_token;
static unsigned clear_samples,piccap_fallbacks; static uint64_t piccap_deadline, media_deadline;
static uint64_t clear_since,unknown_since,piccap_disconnected_since; static unsigned clear_samples, piccap_fallbacks;
static uint64_t clear_since, unknown_since, piccap_disconnected_since;
#define CLEAR_STABLE_MS 2000 #define CLEAR_STABLE_MS 2000
#define UNKNOWN_GRACE_MS 1500 #define UNKNOWN_GRACE_MS 1500
#define PICCAP_LOSS_MS 5000 #define PICCAP_LOSS_MS 5000
@@ -28,105 +38,245 @@ static uint64_t clear_since,unknown_since,piccap_disconnected_since;
static TargetConfig piccap_target; static TargetConfig piccap_target;
static char piccap_error[128]; static char piccap_error[128];
static bool piccap_request(int command); static bool piccap_request(int command);
static inline bool piccap_installed(void){return access(PICCAP_BINARY,X_OK)==0;} static inline bool piccap_installed(void) {
static bool handover_save(bool enabled){ return access(PICCAP_BINARY, X_OK) == 0;
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 void handover_load(void){ static bool handover_save(bool enabled) {
int fd=open(HANDOVER_PREF,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return; const char *data = enabled ? "{\"piccapHandover\":true}\n" : "{\"piccapHandover\":false}\n";
char text[128];ssize_t n=read(fd,text,sizeof(text)-1);close(fd); int fd = open(HANDOVER_PREF ".tmp", O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW | O_CLOEXEC, 0600);
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);} 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){ static void handover_load(void) {
(void)h;(void)ctx;media_busy=false;media_token=0; int fd = open(HANDOVER_PREF, O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
protection=playback_classify(&playback_json,payload(m));protection_at=milliseconds(); if (fd < 0)
if(protection==PLAYBACK_CLEAR){ return;
if(!clear_samples)clear_since=protection_at; char text[128];
if(clear_samples<3)clear_samples++; ssize_t n = read(fd, text, sizeof(text) - 1);
unknown_since=0; close(fd);
}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;} if (n > 0) {
return true; 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 media_reply(void *h, void *m, void *ctx) {
static bool piccap_reply(void *h,void *m,void *ctx){ (void)h;
(void)h;int command=(int)(intptr_t)ctx;piccap_busy=false;piccap_token=0; (void)ctx;
void *root=playback_json.parse(payload(m));bool ok=playback_true(&playback_json,root,"returnValue"); media_busy = false;
if(command==0){ media_token = 0;
void *running=playback_field(&playback_json,root,"isRunning"); protection = playback_classify(&playback_json, payload(m));
if(ok && running && playback_json.type(running)==1){ protection_at = milliseconds();
bool active=playback_json.boolean(running); if (protection == PLAYBACK_CLEAR) {
piccap_connected=playback_true(&playback_json,root,"connected"); if (!clear_samples)
if(active){ clear_since = protection_at;
if(capture_source==SOURCE_PICCAP_STARTING)capture_source=SOURCE_PICCAP; if (clear_samples < 3)
if(piccap_connected)piccap_disconnected_since=0; clear_samples++;
else if(!piccap_disconnected_since)piccap_disconnected_since=milliseconds(); unknown_since = 0;
} } else {
else if(capture_source!=SOURCE_PICCAP_STARTING || milliseconds()-piccap_action_at>=2000){ clear_samples = 0;
if(capture_source!=SOURCE_PICCAP_STOPPING)handover_cooldown=milliseconds()+10000; clear_since = 0;
piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;piccap_disconnected_since=0; if (protection == PLAYBACK_UNKNOWN && !unknown_since)
if(handover_cooldown<milliseconds()+3000)handover_cooldown=milliseconds()+3000; unknown_since = protection_at;
if(!retries.paused)retries.due=milliseconds(); else if (protection == PLAYBACK_PROTECTED)
} unknown_since = 0;
}else if(!piccap_installed()){piccap_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;} }
}else if(command==3 && capture_source==SOURCE_PICCAP_STARTING){ return true;
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){ /* Commands: 0 status, 1 start, 2 stop, 3 getSettings, 4 setSettings. Confirm stopped status before
if(piccap_busy)return false; * VT reuse. */
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"; static bool piccap_reply(void *h, void *m, void *ctx) {
LSError e;error_init(&e); (void)h;
char data[160];snprintf(data,sizeof(data),"{\"address\":\"%s\",\"port\":%u,\"unix-socket\":false}",target_piccap_host(&piccap_target),piccap_target.piccap_port); int command = (int)(intptr_t)ctx;
piccap_busy=call_once(service_handle,uri,command==4?data:"{}",piccap_reply,(void*)(intptr_t)command,&piccap_token,&e); piccap_busy = false;
piccap_deadline=milliseconds()+3000; piccap_token = 0;
error_free(&e);if(command)piccap_action_at=milliseconds();return piccap_busy; 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. */ /* Return true while PicCap owns capture or a stop confirmation is pending. */
static bool handover_tick(void){ static bool handover_tick(void) {
uint64_t now=milliseconds(); uint64_t now = milliseconds();
if(media_busy && now>=media_deadline){LSError e;error_init(&e);cancel_call(service_handle,media_token,&e);error_free(&e);media_busy=false;media_token=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;} if (media_busy && now >= media_deadline) {
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;} LSError e;
if(handover_enabled && piccap_installed() && !retries.paused && playback_json.lib && !media_busy && now>=next_media_probe){ error_init(&e);
LSError e;error_init(&e); cancel_call(service_handle, media_token, &e);
media_busy=call_once(service_handle,"luna://com.webos.media/getActivePipelines","{}",media_reply,NULL,&media_token,&e); error_free(&e);
media_deadline=now+3000; media_busy = false;
error_free(&e);next_media_probe=now+1000; media_token = 0;
} protection = PLAYBACK_UNKNOWN;
if(protection==PLAYBACK_CLEAR && now-protection_at>=3000){protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;} clear_samples = 0;
bool session=!CALIBRATION_ACTIVE && handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked; clear_since = 0;
bool clear=session && protection==PLAYBACK_CLEAR && now-protection_at<3000; if (!unknown_since)
bool stable=clear && clear_samples>=3 && now-clear_since>=CLEAR_STABLE_MS && now>=handover_cooldown; unknown_since = now;
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_busy && now >= piccap_deadline) {
if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS;} snprintf(piccap_error, sizeof(piccap_error), "PicCap request timed out");
} LSError e;
bool grace=protection==PLAYBACK_UNKNOWN && unknown_since && now-unknown_since<UNKNOWN_GRACE_MS && capture_source==SOURCE_PICCAP; error_init(&e);
bool lost=piccap_owned && capture_source==SOURCE_PICCAP && piccap_disconnected_since && now-piccap_disconnected_since>=PICCAP_LOSS_MS; cancel_call(service_handle, piccap_token, &e);
if(lost){ error_free(&e);
snprintf(piccap_error,sizeof(piccap_error),"PicCap receiver disconnected; histogram fallback");piccap_fallbacks++;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS; piccap_busy = false;
} piccap_token = 0;
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;} capture_source = SOURCE_PICCAP_STOPPING;
if(piccap_owned && !piccap_busy && now>=next_piccap_probe){ next_piccap_probe = 0;
if(capture_source==SOURCE_PICCAP_STOPPING)piccap_request(2);else piccap_request(0); }
next_piccap_probe=now+1000; if (handover_enabled && piccap_installed() && !retries.paused && playback_json.lib &&
} !media_busy && now >= next_media_probe) {
return piccap_owned; 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 #endif
+12 -6
View File
@@ -4,12 +4,18 @@
#include <stddef.h> #include <stddef.h>
/* LG ABI: region ID followed by three planar channels, 256 uint16 bins /* LG ABI: region ID followed by three planar channels, 256 uint16 bins
* per channel. Bin index is the 8-bit intensity (0..255). */ * per channel. Bin index is the 8-bit intensity (0..255). */
typedef struct { uint32_t id; uint16_t bins[3][256]; } Histogram; typedef struct {
_Static_assert(sizeof(Histogram)==0x604,"LG ABI record size"); uint32_t id;
_Static_assert(offsetof(Histogram,bins)==4,"LG ABI channel offset"); 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]) { static inline unsigned char histogram_mean(const uint16_t bins[256]) {
uint64_t count=0,weight=0; uint64_t count = 0, weight = 0;
for(unsigned b=0;b<256;b++){count+=bins[b];weight+=(uint64_t)bins[b]*b;} for (unsigned b = 0; b < 256; b++) {
return count?(unsigned char)((weight+count/2)/count):0; count += bins[b];
weight += (uint64_t)bins[b] * b;
}
return count ? (unsigned char)((weight + count / 2) / count) : 0;
} }
#endif #endif
+6 -2
View File
@@ -1,6 +1,10 @@
#ifndef SPATIAL_INTERPOLATION_H #ifndef SPATIAL_INTERPOLATION_H
#define 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]){ static inline void blend_color(const unsigned char a[3], const unsigned char b[3], unsigned weight,
for(unsigned c=0;c<3;c++)out[c]=(unsigned char)(((unsigned)a[c]*(65535-weight)+(unsigned)b[c]*weight+32767)/65535); 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 #endif
+79 -41
View File
@@ -5,54 +5,92 @@
#include <stdlib.h> #include <stdlib.h>
#include <dlfcn.h> #include <dlfcn.h>
#include <math.h> #include <math.h>
typedef struct {unsigned char a,b;unsigned weight;} LedBlend; typedef struct {
unsigned char a, b;
unsigned weight;
} LedBlend;
#include <fcntl.h> #include <fcntl.h>
#include <unistd.h> #include <unistd.h>
#include <stdio.h> #include <stdio.h>
#include "calibration.h" #include "calibration.h"
static double screen_perimeter(double x,double y){ static double screen_perimeter(double x, double y) {
double distances[]={y,1-x,1-y,x},positions[]={x,1+y,3-x,4-y};unsigned edge=0; double distances[] = {y, 1 - x, 1 - y, x}, positions[] = {x, 1 + y, 3 - x, 4 - y};
for(unsigned i=1;i<4;i++)if(distances[i]<distances[edge])edge=i; unsigned edge = 0;
return positions[edge]>=4?positions[edge]-4:positions[edge]; 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){ static LedBlend layout_blend(double x, double y) {
const RimPoint *rim=calibration_effective()==CAL_CUSTOM?calibration.points:g4_rim; 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; double t = screen_perimeter(x, y);
while(right<n && rim[right].position<=t)right++; unsigned n = calibration_effective() == CAL_CUSTOM ? calibration.count
right%=n;unsigned left=(right+n-1)%n; : sizeof(g4_rim) / sizeof(*g4_rim),
double span=rim[right].position-rim[left].position,distance=t-rim[left].position; right = 0;
if(span<=0)span+=4; while (right < n && rim[right].position <= t)
if(distance<0)distance+=4; right++;
return (LedBlend){rim[left].region,rim[right].region,(unsigned)(distance/span*65535+0.5)}; right %= n;
unsigned left = (right + n - 1) % n;
double span = rim[right].position - rim[left].position, distance = t - rim[left].position;
if (span <= 0)
span += 4;
if (distance < 0)
distance += 4;
return (LedBlend){rim[left].region, rim[right].region,
(unsigned)(distance / span * 65535 + 0.5)};
} }
/* Use the TV's JSON-C library; validate every rectangle before replacing the /* Use the TV's JSON-C library; validate every rectangle before replacing the
* layout. The safety limit is independent of the installed LED count. */ * layout. The safety limit is independent of the installed LED count. */
static bool layout_from_json(const char *text,LedBlend **result,unsigned *count){ 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 *lib = dlopen("libjson-c.so.5", RTLD_NOW);
void *(*parse)(const char*)=dlsym(lib,"json_tokener_parse"); if (!lib)
int (*get)(const void*,const char*,void**)=dlsym(lib,"json_object_object_get_ex"); return false;
int (*type)(const void*)=dlsym(lib,"json_object_get_type"); void *(*parse)(const char *) = dlsym(lib, "json_tokener_parse");
size_t (*length)(const void*)=dlsym(lib,"json_object_array_length"); int (*get)(const void *, const char *, void **) = dlsym(lib, "json_object_object_get_ex");
void *(*at)(const void*,size_t)=dlsym(lib,"json_object_array_get_idx"); int (*type)(const void *) = dlsym(lib, "json_object_get_type");
double (*number)(const void*)=dlsym(lib,"json_object_get_double"); size_t (*length)(const void *) = dlsym(lib, "json_object_array_length");
int (*put)(void*)=dlsym(lib,"json_object_put"); void *(*at)(const void *, size_t) = dlsym(lib, "json_object_array_get_idx");
if(!parse||!get||!type||!length||!at||!number||!put){dlclose(lib);return false;} double (*number)(const void *) = dlsym(lib, "json_object_get_double");
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; int (*put)(void *) = dlsym(lib, "json_object_put");
size_t n=valid?length(leds):0;valid=valid && n>0 && n<=16384; if (!parse || !get || !type || !length || !at || !number || !put) {
LedBlend *mapping=valid?calloc(n,sizeof(*mapping)):NULL;valid=valid && mapping; dlclose(lib);
const char *keys[]={"hmin","hmax","vmin","vmax"}; return false;
for(size_t i=0;valid && i<n;i++){ }
void *led=at(leds,i);double coordinates[4];valid=led && type(led)==4; void *root = parse(text), *info = NULL, *leds = NULL;
for(unsigned c=0;valid && c<4;c++){ bool valid = root && type(root) == 4 && get(root, "info", &info) && type(info) == 4 &&
void *v=NULL;valid=get(led,keys[c],&v) && (type(v)==2||type(v)==3); get(info, "leds", &leds) && type(leds) == 5;
if(valid){coordinates[c]=number(v);valid=isfinite(coordinates[c]) && coordinates[c]>=0 && coordinates[c]<=1;} size_t n = valid ? length(leds) : 0;
} valid = valid && n > 0 && n <= 16384;
if(valid)valid=coordinates[0]<=coordinates[1] && coordinates[2]<=coordinates[3]; LedBlend *mapping = valid ? calloc(n, sizeof(*mapping)) : NULL;
if(valid)mapping[i]=layout_blend((coordinates[0]+coordinates[1])/2,(coordinates[2]+coordinates[3])/2); valid = valid && mapping;
} const char *keys[] = {"hmin", "hmax", "vmin", "vmax"};
if(root)put(root); for (size_t i = 0; valid && i < n; i++) {
dlclose(lib); void *led = at(leds, i);
if(!valid){free(mapping);return false;} double coordinates[4];
*result=mapping;*count=(unsigned)n;return true; 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 #endif
+45 -26
View File
@@ -1,34 +1,53 @@
#ifndef HYPERDRM_LAYOUT_REFRESH_H #ifndef HYPERDRM_LAYOUT_REFRESH_H
#define HYPERDRM_LAYOUT_REFRESH_H #define HYPERDRM_LAYOUT_REFRESH_H
#define LAYOUT_REFRESH_MS 5000 #define LAYOUT_REFRESH_MS 5000
static ReceiverProbe layout_probe={.fd=-1}; static ReceiverProbe layout_probe = {.fd = -1};
static uint64_t next_layout_check,last_layout_check; static uint64_t next_layout_check, last_layout_check;
static unsigned layout_updates,layout_probe_generation; static unsigned layout_updates, layout_probe_generation;
static char layout_error[128]; static char layout_error[128];
static void layout_refresh_reset(uint64_t due){ static void layout_refresh_reset(uint64_t due) {
probe_close(&layout_probe);layout_probe.stage=TEST_IDLE;next_layout_check=due; probe_close(&layout_probe);
layout_probe.stage = TEST_IDLE;
next_layout_check = due;
} }
static bool layout_mapping_changed(const LedBlend *next,unsigned count){ static bool layout_mapping_changed(const LedBlend *next, unsigned count) {
if(count!=led_count || !led_blends)return true; if (count != led_count || !led_blends)
for(unsigned i=0;i<count;i++)if(next[i].a!=led_blends[i].a || next[i].b!=led_blends[i].b || next[i].weight!=led_blends[i].weight)return true; return true;
return false; for (unsigned i = 0; i < count; i++)
if (next[i].a != led_blends[i].a || next[i].b != led_blends[i].b ||
next[i].weight != led_blends[i].weight)
return true;
return false;
} }
static void layout_refresh_tick(PlaybackJson *json,uint64_t now,bool running){ static void layout_refresh_tick(PlaybackJson *json, uint64_t now, bool running) {
if(!running){layout_refresh_reset(now+LAYOUT_REFRESH_MS);return;} if (!running) {
probe_tick(&layout_probe,json,now); layout_refresh_reset(now + LAYOUT_REFRESH_MS);
if(layout_probe.stage==TEST_DONE){ return;
if(layout_probe_generation==calibration_generation){ }
if(layout_mapping_changed(layout_probe.mapping,layout_probe.leds)){ probe_tick(&layout_probe, json, now);
free(led_blends);led_blends=layout_probe.mapping;layout_probe.mapping=NULL;led_count=layout_probe.leds;layout_updates++; if (layout_probe.stage == TEST_DONE) {
} if (layout_probe_generation == calibration_generation) {
last_layout_check=now;layout_error[0]=0; if (layout_mapping_changed(layout_probe.mapping, layout_probe.leds)) {
}else next_layout_check=now; free(led_blends);
probe_close(&layout_probe);layout_probe.stage=TEST_IDLE; led_blends = layout_probe.mapping;
}else if(layout_probe.stage==TEST_FAILED){ layout_probe.mapping = NULL;
snprintf(layout_error,sizeof(layout_error),"%s",layout_probe.error);layout_probe.stage=TEST_IDLE; led_count = layout_probe.leds;
} layout_updates++;
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; 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 #endif
+79 -33
View File
@@ -5,43 +5,89 @@
#include <string.h> #include <string.h>
#include <dlfcn.h> #include <dlfcn.h>
typedef struct { typedef struct {
void *lib;void *(*parse)(const char*);int (*get)(const void*,const char*,void**); void *lib;
int (*type)(const void*);size_t (*length)(const void*);void *(*at)(const void*,size_t); void *(*parse)(const char *);
const char *(*string)(const void*);int (*boolean)(const void*);int (*put)(void*); 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; } PlaybackJson;
static inline bool playback_json_init(PlaybackJson *j){ static inline bool playback_json_init(PlaybackJson *j) {
j->lib=dlopen("libjson-c.so.5",RTLD_NOW);if(!j->lib)return false; j->lib = dlopen("libjson-c.so.5", RTLD_NOW);
j->parse=dlsym(j->lib,"json_tokener_parse");j->get=dlsym(j->lib,"json_object_object_get_ex"); if (!j->lib)
j->type=dlsym(j->lib,"json_object_get_type");j->length=dlsym(j->lib,"json_object_array_length"); return false;
j->at=dlsym(j->lib,"json_object_array_get_idx");j->string=dlsym(j->lib,"json_object_get_string"); j->parse = dlsym(j->lib, "json_tokener_parse");
j->boolean=dlsym(j->lib,"json_object_get_boolean");j->put=dlsym(j->lib,"json_object_put"); j->get = dlsym(j->lib, "json_object_object_get_ex");
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; 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 void *playback_field(PlaybackJson *j, void *o, const char *key) {
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);} void *v = NULL;
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;} return o && j->type(o) == 4 && j->get(o, key, &v) ? v : NULL;
typedef enum {PLAYBACK_UNKNOWN,PLAYBACK_CLEAR,PLAYBACK_PROTECTED} PlaybackProtection; }
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. /* G4 resource-state classification, not black-frame or app-name guessing.
* Any active Secure Video Path wins. Only a foreground media decoder without * Any active Secure Video Path wins. Only a foreground media decoder without
* a secure resource is eligible; HDMI, idle and malformed replies are unknown. */ * a secure resource is eligible; HDMI, idle and malformed replies are unknown. */
static inline PlaybackProtection playback_classify(PlaybackJson *j,const char *text){ static inline PlaybackProtection playback_classify(PlaybackJson *j, const char *text) {
void *root=j->parse(text);if(!root)return PLAYBACK_UNKNOWN; void *root = j->parse(text);
bool clear=false,invalid=j->type(root)!=5,secure=false; if (!root)
size_t n=invalid?0:j->length(root); return PLAYBACK_UNKNOWN;
for(size_t i=0;i<n;i++){ bool clear = false, invalid = j->type(root) != 5, secure = false;
void *p=j->at(root,i);if(!p || j->type(p)!=4){invalid=true;continue;} size_t n = invalid ? 0 : j->length(root);
void *resources=playback_field(j,p,"resource");if(!resources)resources=playback_field(j,p,"resources"); for (size_t i = 0; i < n; i++) {
if(!resources || j->type(resources)!=5){invalid=true;continue;} void *p = j->at(root, i);
bool decoder=false; if (!p || j->type(p) != 4) {
for(size_t k=0;k<j->length(resources);k++){ invalid = true;
void *resource=j->at(resources,k);void *name=playback_field(j,resource,"resource"); continue;
if(!name || j->type(name)!=6){invalid=true;continue;} }
const char *s=j->string(name); void *resources = playback_field(j, p, "resource");
if(strcmp(s,"SVP_CPB")==0)secure=true; if (!resources)
if(strcmp(s,"VDEC")==0)decoder=true; resources = playback_field(j, p, "resources");
} if (!resources || j->type(resources) != 5) {
if(decoder && playback_true(j,p,"is_foreground") && playback_is(j,p,"type","media"))clear=true; invalid = true;
} continue;
j->put(root);return secure?PLAYBACK_PROTECTED:clear&&!invalid?PLAYBACK_CLEAR:PLAYBACK_UNKNOWN; }
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 #endif
+215 -59
View File
@@ -5,67 +5,223 @@
#endif #endif
/* Independent raw LED subscription: bounded, nonblocking, before receiver /* Independent raw LED subscription: bounded, nonblocking, before receiver
* color correction. Old result and newer data envelopes are both accepted. */ * 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; #include "runtime_states.h"
static ReceiverStream stream={.fd=-1}; typedef struct {
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};} int fd;
static bool stream_queue(const char *line){ StreamStage stage;
if(stream.fd<0 || stream.request)return false; uint64_t deadline, retry_at, updated;
stream.request=strdup(line);if(!stream.request)return false;stream.length=strlen(line);stream.sent=0;return true; 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){ static bool stream_queue(const char *line) {
stream.fd=target_open(&target);if(stream.fd<0){stream.retry_at=now+5000;return;} if (stream.fd < 0 || stream.request)
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; 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){ static void stream_start(uint64_t now) {
void *body=playback_field(j,root,"result");if(!body)body=playback_field(j,root,"data"); stream.fd = target_open(&target);
void *leds=playback_field(j,body,"leds"); if (stream.fd < 0) {
if(!leds || j->type(leds)!=5)return false; stream.retry_at = now + 5000;
if(j->length(leds)!=(size_t)stream.count*3){stream.revision=0;return false;} return;
uint64_t sums[16][3]={{0}},weights[16]={0}; }
for(unsigned i=0;i<stream.count;i++){ stream.data = malloc(262145);
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;} if (!stream.data) {
LedBlend b=stream.mapping[i];unsigned w[2]={65535-b.weight,b.weight},r[2]={b.a,b.b}; stream_close(now);
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];} return;
} }
unsigned char mean[3]={0};uint64_t total=0,all[3]={0}; stream.stage = STREAM_CONNECTING;
for(unsigned r=0;r<16;r++){total+=weights[r];for(unsigned c=0;c<3;c++)all[c]+=sums[r][c];} stream.deadline = now + 3000;
if(!total)return false; stream.revision = calibration_revision();
for(unsigned c=0;c<3;c++)mean[c]=(unsigned char)(all[c]/total); stream.layout_revision = RECEIVER_LAYOUT_REVISION;
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){ static bool stream_colors(PlaybackJson *j, void *root, uint64_t now) {
if(!enabled){if(stream.fd>=0)stream_close(now);return;} void *body = playback_field(j, root, "result");
if(stream.fd>=0 && (stream.revision!=calibration_revision() || stream.layout_revision!=RECEIVER_LAYOUT_REVISION)){stream_close(now);stream.retry_at=now;} if (!body)
if(stream.fd<0){if(now>=stream.retry_at)stream_start(now);return;} body = playback_field(j, root, "data");
if(stream.stage<4 && now>=stream.deadline){stream_close(now);return;} void *leds = playback_field(j, body, "leds");
if(stream.stage==1){ if (!leds || j->type(leds) != 5)
struct pollfd p={stream.fd,POLLOUT,0};int error=0;socklen_t size=sizeof(error);if(poll(&p,1,0)<=0)return; return false;
if(getsockopt(stream.fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){stream_close(now);return;} if (j->length(leds) != (size_t)stream.count * 3) {
char line[384];if(target.token[0]){snprintf(line,sizeof(line),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",target.token);stream.stage=2;} stream.revision = 0;
else {snprintf(line,sizeof(line),"{\"command\":\"serverinfo\"}\n");stream.stage=3;} return false;
if(!stream_queue(line)){stream_close(now);return;} }
} uint64_t sums[16][3] = {{0}}, weights[16] = {0};
if(stream.request){ for (unsigned i = 0; i < stream.count; i++) {
ssize_t n=send(stream.fd,stream.request+stream.sent,stream.length-stream.sent,MSG_NOSIGNAL); unsigned values[3];
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; for (unsigned c = 0; c < 3; c++) {
if(n<=0){stream_close(now);return;}stream.sent+=(size_t)n; void *v = j->at(leds, i * 3 + c);
if(stream.sent==stream.length){free(stream.request);stream.request=NULL;} if (!v || j->type(v) != 3)
} return false;
if(stream.used==262144){stream_close(now);return;} char *end;
size_t available=262144-stream.used;if(available>65536)available=65536; long n = strtol(j->string(v), &end, 10);
ssize_t n=recv(stream.fd,stream.data+stream.used,available,0); if (*end || n < 0 || n > 255)
if(n<0 && (errno==EAGAIN || errno==EWOULDBLOCK || errno==EINTR))return; return false;
if(n<=0){stream_close(now);return;}stream.used+=(size_t)n;stream.data[stream.used]=0; values[c] = (unsigned)n;
for(unsigned messages=0;messages<8;messages++){ }
char *end=strchr(stream.data,'\n');if(!end)break;*end=0;void *root=j->parse(stream.data); LedBlend b = stream.mapping[i];
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;} unsigned w[2] = {65535 - b.weight, b.weight}, r[2] = {b.a, b.b};
else if(stream.stage==3){ for (unsigned k = 0; k < 2; k++) {
LedBlend *mapping=NULL;unsigned count=0;if(!layout_from_json(stream.data,&mapping,&count)){if(root)j->put(root);stream_close(now);return;} weights[r[k]] += w[k];
stream.mapping=mapping;stream.count=count;stream.stage=4;stream_queue("{\"command\":\"ledcolors\",\"subcommand\":\"ledstream-start\",\"interval\":100}\n"); for (unsigned c = 0; c < 3; c++)
}else if(playback_is(j,root,"command","ledcolors-ledstream-update"))stream_colors(j,root,now); sums[r[k]][c] += (uint64_t)values[c] * w[k];
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; }
} 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 #endif
+42 -18
View File
@@ -5,26 +5,50 @@
#include <limits.h> #include <limits.h>
#define RETRY_DELAY_MS 5000 #define RETRY_DELAY_MS 5000
/* Continuous autostart retries and bounded manual source recovery. */ /* Continuous autostart retries and bounded manual source recovery. */
typedef struct {uint64_t due;unsigned attempts,quick_retries;bool automatic,paused,blocked;} RetryState; typedef struct {
static void retry_reset(RetryState *state){*state=(RetryState){0};} uint64_t due;
static void retry_pause(RetryState *state){retry_reset(state);state->paused=true;} unsigned attempts, quick_retries;
static void retry_update(RetryState *state,bool enabled,bool running,uint64_t now){ bool automatic, paused, blocked;
bool previous=state->automatic; } RetryState;
state->automatic=enabled && !state->paused && !state->blocked; static void retry_reset(RetryState *state) {
if(previous && !state->automatic){state->due=0;state->attempts=0;} *state = (RetryState){0};
if(state->automatic && !running && !state->due)state->due=now+RETRY_DELAY_MS;
} }
static bool retry_take(RetryState *state,uint64_t now){ static void retry_pause(RetryState *state) {
if(!state->due || now<state->due || state->paused || state->blocked)return false; retry_reset(state);
state->due=0;if(state->attempts<UINT_MAX)state->attempts++;return true; state->paused = true;
} }
static void retry_failure(RetryState *state,uint64_t now){ static void retry_update(RetryState *state, bool enabled, bool running, uint64_t now) {
if(!state->paused && !state->blocked && (state->automatic || state->attempts<6)){ bool previous = state->automatic;
if(state->quick_retries){state->quick_retries--;state->due=now+400;} state->automatic = enabled && !state->paused && !state->blocked;
else state->due=now+RETRY_DELAY_MS; 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){ static bool retry_take(RetryState *state, uint64_t now) {
if(!state->paused && !state->blocked){state->attempts=0;state->quick_retries=2;state->due=now+200;} 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 #endif
+12
View File
@@ -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
+344 -339
View File
@@ -17,6 +17,7 @@
#include <sys/time.h> #include <sys/time.h>
#include <stdarg.h> #include <stdarg.h>
#include <time.h> #include <time.h>
#include "version.h"
#include "histogram.h" #include "histogram.h"
#include "blackbars.h" #include "blackbars.h"
#include "color_boost.h" #include "color_boost.h"
@@ -30,379 +31,383 @@
#include "continuity.h" #include "continuity.h"
#define SERVICE "org.webosbrew.histogramhyperion.service" #define SERVICE "org.webosbrew.histogramhyperion.service"
#define PRIORITY 149 #define PRIORITY 149
typedef struct {int code;char *message;const char *file;int line;const char *func;void *padding;unsigned long magic;} LSError; typedef struct {
typedef bool (*Callback)(void*,void*,void*); int code;
typedef struct {const char *name;Callback function;int flags;} Method; char *message;
static bool (*reply)(void*,void*,const char*,LSError*); const char *file;
static const char *(*payload)(void*); int line;
static bool (*error_init)(LSError*); const char *func;
static void (*error_free)(LSError*); void *padding;
static void *(*vt_init)(bool(*)(void)); unsigned long magic;
static int (*vt_preset)(void*,int),(*vt_get)(void*,Histogram*),(*vt_finish)(void*); } 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 void *capture;
static char lg_diagnostic[256]; static char lg_diagnostic[256];
/* Process-local LG diagnostics; no changes to the loaded LG library. */ /* Process-local LG diagnostics; no changes to the loaded LG library. */
void printLog(const char *context,int level,const char *message_id, void printLog(const char *context, int level, const char *message_id, const char *file,
const char *file,const char *function,int line,const char *format,...) { const char *function, int line, const char *format, ...) {
(void)context;(void)message_id;(void)file; (void)context;
if(capture && level>3)return; (void)message_id;
if(format){va_list args;va_start(args,format);vsnprintf(lg_diagnostic,sizeof(lg_diagnostic),format,args);va_end(args);} (void)file;
fprintf(stderr,"LG %s:%d: ",function?function:"?",line); if (capture && level > 3)
if(format){va_list args;va_start(args,format);vfprintf(stderr,format,args);va_end(args);} return;
fputc('\n',stderr); 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 long frames;
static unsigned char rgb[16][3]; static unsigned char rgb[16][3];
static unsigned char sent_rgb[3]; static unsigned char sent_rgb[3];
static unsigned black_percent[16]; static unsigned black_percent[16];
static unsigned capture_preset; static unsigned capture_preset;
static unsigned letterbox_mode,settling; static unsigned letterbox_mode, settling;
static int pending_letterbox=-1; static int pending_letterbox = -1;
static uint64_t candidate_since,next_border_probe; static uint64_t candidate_since, next_border_probe;
static bool color_valid; static bool color_valid;
static Continuity continuity; static Continuity continuity;
static uint64_t sample_at,calibration_until; static uint64_t sample_at, calibration_until;
static unsigned sample_sequence; static unsigned sample_sequence;
#define CALIBRATION_ACTIVE (calibration_until>milliseconds()) #define CALIBRATION_ACTIVE (calibration_until > milliseconds())
static unsigned led_count; static unsigned led_count;
static LedBlend *led_blends; static LedBlend *led_blends;
static bool interpolation_enabled; static bool interpolation_enabled;
static RetryState retries; static RetryState retries;
static unsigned capture_recoveries; static unsigned capture_recoveries;
static uint64_t recovery_started,last_recovery_ms,last_output_at; 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 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 char last_error[256];
static volatile sig_atomic_t revoked,quitting; static volatile sig_atomic_t revoked, quitting;
static int guard_pipe=-1; static int guard_pipe = -1;
static pid_t guard_pid=-1; static pid_t guard_pid = -1;
#define DEV_MODE "/var/luna/preferences/vt_devmode" #define DEV_MODE "/var/luna/preferences/vt_devmode"
/* LG's developer gate is enabled only during initialization. A separate /* LG's developer gate is enabled only during initialization. A separate
* guardian removes our own marker even if this process aborts or is killed. * 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. */ * An existing marker is preserved, as are files replaced by another owner. */
static bool enter_developer_mode(void){ static bool enter_developer_mode(void) {
int marker=open(DEV_MODE,O_CREAT|O_EXCL|O_WRONLY|O_CLOEXEC,0600); int marker = open(DEV_MODE, O_CREAT | O_EXCL | O_WRONLY | O_CLOEXEC, 0600);
if(marker<0)return errno==EEXIST; if (marker < 0)
struct stat owned; return errno == EEXIST;
if(fstat(marker,&owned)!=0){close(marker);unlink(DEV_MODE);return false;} struct stat owned;
close(marker);int p[2]; if (fstat(marker, &owned) != 0) {
if(pipe(p)!=0){unlink(DEV_MODE);return false;} close(marker);
pid_t child=fork(); unlink(DEV_MODE);
if(child<0){close(p[0]);close(p[1]);unlink(DEV_MODE);return false;} return false;
if(child==0){ }
close(p[1]);struct pollfd fd={p[0],POLLIN|POLLHUP,0}; close(marker);
/* Bounded cleanup also covers an indefinitely blocked initialization. */ int p[2];
poll(&fd,1,10000);close(p[0]);struct stat current; if (pipe(p) != 0) {
int rc=0; unlink(DEV_MODE);
if(lstat(DEV_MODE,&current)==0 && current.st_dev==owned.st_dev && current.st_ino==owned.st_ino)rc=unlink(DEV_MODE); return false;
_exit(rc==0?0:1); }
} pid_t child = fork();
close(p[0]);guard_pipe=p[1];guard_pid=child;return true; 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, &current) == 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){ static bool leave_developer_mode(void) {
if(guard_pipe<0)return true; if (guard_pipe < 0)
close(guard_pipe);guard_pipe=-1;int status; return true;
while(waitpid(guard_pid,&status,0)<0){if(errno!=EINTR)return false;} close(guard_pipe);
guard_pid=-1;return WIFEXITED(status)&&WEXITSTATUS(status)==0; 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 void quit_signal(int s) {
static bool policy(void){revoked=1;return true;} (void)s;
static void set_error(const char *s){snprintf(last_error,sizeof(last_error),"%s",s);fprintf(stderr,"%s\n",s);} quitting = 1;
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 bool receiver_ack(void){ static bool policy(void) {
char answer[4096];size_t used=0;bool complete=false; revoked = 1;
while(used+1<sizeof(answer)){ssize_t n=recv(output,answer+used,sizeof(answer)-used-1,0);if(n<=0)break;used+=(size_t)n;answer[used]=0;if(strchr(answer,'\n')){complete=true;break;}} return true;
PlaybackJson json={0};if(!complete || !playback_json_init(&json))return false;
void *root=json.parse(answer);bool ok=playback_true(&json,root,"success");if(root)json.put(root);dlclose(json.lib);return ok;
} }
static bool read_layout(void){ static void set_error(const char *s) {
const size_t capacity=2*1024*1024;char *data=malloc(capacity+1);if(!data)return false; snprintf(last_error, sizeof(last_error), "%s", s);
bool complete=false;size_t used=0; fprintf(stderr, "%s\n", s);
if(send_line("{\"command\":\"serverinfo\"}\n")==0){
while(used<capacity){ssize_t n=recv(output,data+used,capacity-used,0);if(n<=0)break;used+=(size_t)n;data[used]=0;char *end=strchr(data,'\n');if(end){*end=0;complete=true;break;}}
}
LedBlend *mapping=NULL;unsigned count=0;bool ok=complete && layout_from_json(data,&mapping,&count);
if(ok){free(led_blends);led_blends=mapping;led_count=count;}free(data);interpolation_enabled=ok && calibration_effective()!=CAL_AVERAGE;return ok;
} }
static bool target_login(void){ static int send_line(const char *line) {
if(!target.token[0])return true; size_t left = strlen(line);
char request[384],answer[4096];size_t used=0; const char *p = line;
snprintf(request,sizeof(request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",target.token); while (left) {
if(send_line(request)!=0)return false; ssize_t n = send(output, p, left, MSG_NOSIGNAL);
while(used+1<sizeof(answer)){ if (n <= 0)
ssize_t n=recv(output,answer+used,sizeof(answer)-used-1,0);if(n<=0)return false; return -1;
used+=(size_t)n;answer[used]=0;if(strchr(answer,'\n'))break; p += n;
} left -= n;
PlaybackJson json={0};if(!playback_json_init(&json))return false; }
void *root=json.parse(answer);bool ok=playback_true(&json,root,"success");if(root)json.put(root);dlclose(json.lib);return ok; return 0;
}
static bool receiver_ack(void) {
char answer[4096];
size_t used = 0;
bool complete = false;
while (used + 1 < sizeof(answer)) {
ssize_t n = recv(output, answer + used, sizeof(answer) - used - 1, 0);
if (n <= 0)
break;
used += (size_t)n;
answer[used] = 0;
if (strchr(answer, '\n')) {
complete = true;
break;
}
}
PlaybackJson json = {0};
if (!complete || !playback_json_init(&json))
return false;
void *root = json.parse(answer);
bool ok = playback_true(&json, root, "success");
if (root)
json.put(root);
dlclose(json.lib);
return ok;
}
static bool read_layout(void) {
const size_t capacity = 2 * 1024 * 1024;
char *data = malloc(capacity + 1);
if (!data)
return false;
bool complete = false;
size_t used = 0;
if (send_line("{\"command\":\"serverinfo\"}\n") == 0) {
while (used < capacity) {
ssize_t n = recv(output, data + used, capacity - used, 0);
if (n <= 0)
break;
used += (size_t)n;
data[used] = 0;
char *end = strchr(data, '\n');
if (end) {
*end = 0;
complete = true;
break;
}
}
}
LedBlend *mapping = NULL;
unsigned count = 0;
bool ok = complete && layout_from_json(data, &mapping, &count);
if (ok) {
free(led_blends);
led_blends = mapping;
led_count = count;
}
free(data);
interpolation_enabled = ok && calibration_effective() != CAL_AVERAGE;
return ok;
}
static bool target_login(void) {
if (!target.token[0])
return true;
char request[384], answer[4096];
size_t used = 0;
snprintf(request, sizeof(request),
"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",
target.token);
if (send_line(request) != 0)
return false;
while (used + 1 < sizeof(answer)) {
ssize_t n = recv(output, answer + used, sizeof(answer) - used - 1, 0);
if (n <= 0)
return false;
used += (size_t)n;
answer[used] = 0;
if (strchr(answer, '\n'))
break;
}
PlaybackJson json = {0};
if (!playback_json_init(&json))
return false;
void *root = json.parse(answer);
bool ok = playback_true(&json, root, "success");
if (root)
json.put(root);
dlclose(json.lib);
return ok;
} }
static void layout_refresh_reset(uint64_t due); static void layout_refresh_reset(uint64_t due);
static void release_capture(bool clear_output){ static void release_capture(bool clear_output) {
layout_refresh_reset(milliseconds()+5000); layout_refresh_reset(milliseconds() + 5000);
free(led_blends);led_blends=NULL;led_count=0;interpolation_enabled=false; free(led_blends);
color_valid=false;memset(rgb,0,sizeof(rgb));memset(sent_rgb,0,sizeof(sent_rgb)); led_blends = NULL;
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;} led_count = 0;
if(capture){vt_finish(capture);capture=NULL;} 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 "handover.h"
#include "layout_refresh.h" #include "layout_refresh.h"
#define RECEIVER_LAYOUT_REVISION layout_updates #define RECEIVER_LAYOUT_REVISION layout_updates
#include "receiver_stream.h" #include "receiver_stream.h"
#include "source_bridge.h" #include "source_bridge.h"
static bool calibration_session(void *h,void *m,void *ctx){ #include "service_api.h"
(void)ctx;const char *p=payload(m); #include "capture_loop.h"
if(!strcmp(p,"{\"enabled\":true}")){ static void *symbol(void *lib, const char *name) {
calibration_until=milliseconds()+110000; void *s = dlsym(lib, name);
if(capture && vt_preset(capture,0)!=0){calibration_until=0;return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot select full-screen calibration capture\"}");} if (!s) {
capture_preset=letterbox_mode=0;settling=2;pending_letterbox=-1; fprintf(stderr, "Missing symbol %s\n", name);
}else if(!strcmp(p,"{\"enabled\":false}"))calibration_until=0; exit(2);
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}"); }
return respond(h,m,"{\"returnValue\":true}"); return s;
} }
static Calibration calibration_draft; int main(void) {
static char calibration_message[256]; signal(SIGTERM, quit_signal);
static bool report_calibration(void *h,void *m,void *ctx){ signal(SIGINT, quit_signal);
if(!playback_json.lib)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"JSON-C is unavailable\"}"); if (boot_enabled() && !set_boot(true))
(void)ctx;void *root=playback_json.parse(payload(m)),*message=playback_field(&playback_json,root,"message"); fprintf(stderr, "Own legacy boot hook migration failed\n");
bool valid=message && playback_json.type(message)==6 && strlen(playback_json.string(message))<sizeof(calibration_message); void *vt = dlopen("libvtcapture.so.1", RTLD_NOW),
if(valid)snprintf(calibration_message,sizeof(calibration_message),"%s",playback_json.string(message)); *ls = dlopen("libluna-service2.so.3", RTLD_NOW),
if(root)playback_json.put(root); *glib = dlopen("libglib-2.0.so.0", RTLD_NOW);
return respond(h,m,valid?"{\"returnValue\":true}":"{\"returnValue\":false,\"errorText\":\"Invalid calibration result message\"}"); if (!vt || !ls || !glib) {
} fprintf(stderr, "Missing LG library: %s\n", dlerror());
static bool draft_calibration(void *h,void *m,void *ctx){ return 2;
(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\"}"); vt_init = symbol(vt, "vtCapture_initHistogram");
calibration_draft=next;return respond(h,m,"{\"returnValue\":true}"); vt_preset = symbol(vt, "vtCapture_setPresetHistogram");
} vt_get = symbol(vt, "vtCapture_getHistogram");
static bool model_busy; vt_finish = symbol(vt, "vtCapture_finalizeHistogram");
static unsigned long model_token; call_once = symbol(ls, "LSCallOneReply");
static uint64_t model_deadline,next_model_probe; cancel_call = symbol(ls, "LSCallCancel");
static bool model_reply(void *h,void *m,void *ctx){ if (playback_json_init(&playback_json)) {
(void)h;(void)ctx;model_busy=false;model_token=0; handover_load();
void *root=playback_json.parse(payload(m)),*model=playback_field(&playback_json,root,"modelName"); target_load(&playback_json, TARGET_PREF, &target);
if(playback_true(&playback_json,root,"returnValue") && model && playback_json.type(model)==6 && calibration_model_valid(playback_json.string(model))){ calibration_load(&playback_json, CALIBRATION_PREF);
snprintf(tv_model,sizeof(tv_model),"%s",playback_json.string(model));calibration_generation++;layout_refresh_reset(milliseconds()); }
} reply = symbol(ls, "LSMessageReply");
if(root)playback_json.put(root); payload = symbol(ls, "LSMessageGetPayload");
return true; error_init = symbol(ls, "LSErrorInit");
} error_free = symbol(ls, "LSErrorFree");
static void model_tick(uint64_t now){ bool (*reg)(const char *, void **, LSError *) = symbol(ls, "LSRegister");
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;} bool (*category)(void *, const char *, Method *, void *, void *, LSError *) =
if(!tv_model[0] && !model_busy && now>=next_model_probe){ symbol(ls, "LSRegisterCategory");
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; 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");
static bool set_calibration(void *h,void *m,void *ctx){ int (*iterate)(void *, int) = symbol(glib, "g_main_context_iteration");
(void)ctx;Calibration next=calibration; unsigned (*timer)(unsigned, int (*)(void *), void *) = symbol(glib, "g_timeout_add");
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)\"}"); void (*loop_unref)(void *) = symbol(glib, "g_main_loop_unref");
if(!calibration_save(CALIBRATION_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot save calibration profile\"}"); void *handle = NULL, *loop = loop_new(NULL, 0);
calibration=next;calibration_generation++;layout_refresh_reset(milliseconds()); LSError e;
return respond(h,m,"{\"returnValue\":true}"); error_init(&e);
} Method methods[] = {{"start", start, 0},
static bool start_failure(void *h,void *m,const char *message){ {"stop", stop, 0},
retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds()); {"status", status, 0},
char json[512];snprintf(json,sizeof(json),"{\"returnValue\":false,\"errorText\":\"%s\",\"retryScheduled\":%s,\"retryBlocked\":%s}",message,retries.due?"true":"false",retries.blocked?"true":"false"); {"autostart", autostart, 0},
return respond(h,m,json); {"restartService", restart_service, 0},
} {"handover", handover, 0},
static bool start(void *h,void *m,void *ctx){ {"settings", settings, 0},
(void)ctx;const char *p=m?payload(m):NULL; {"testConnection", test_connection, 0},
if(p && strcmp(p,"{}")!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Start expects an empty object; use settings to configure the receiver\"}"); {"calibration", set_calibration, 0},
if(m)retry_reset(&retries); {"calibrationSession", calibration_session, 0},
if(piccap_owned)return respond(h,m,"{\"returnValue\":true,\"running\":true,\"activeSource\":\"PicCap\"}"); {"calibrationDraft", draft_calibration, 0},
if(capture)return respond(h,m,"{\"returnValue\":true,\"running\":true}"); {"calibrationResult", report_calibration, 0},
frames=0;revoked=0;last_error[0]=0;lg_diagnostic[0]=0; {NULL, NULL, 0}};
output=target_connect(&target); if (!reg(SERVICE, &handle, &e) || !category(handle, "/", methods, NULL, NULL, &e) ||
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");} !attach(handle, loop, &e)) {
if(!target_login()){stop_capture();set_error("Receiver token login failed; check the saved API token");return start_failure(h,m,"Receiver authentication failed");} fprintf(stderr, "Luna registration failed: %s\n", e.message ? e.message : "unknown");
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");} error_free(&e);
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");} return 3;
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");} service_handle = handle;
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 (!boot_enabled())
if(vt_preset(capture,0)!=0){stop_capture();set_error("LG histogram preset initialization failed");return start_failure(h,m,"Histogram preset failed");} retry_pause(&retries);
capture_preset=letterbox_mode=settling=0;pending_letterbox=-1;next_border_probe=0; timer(100, tick, NULL);
retries.due=0;retries.attempts=0;retries.quick_retries=0; while (!quitting) {
if(recovery_started){last_recovery_ms=milliseconds()-recovery_started;recovery_started=0;} iterate(NULL, 0);
return respond(h,m,"{\"returnValue\":true,\"running\":true}"); usleep(10000);
} }
static bool stop(void *h,void *m,void *ctx){ stream_close(milliseconds());
(void)ctx;calibration_until=0;retry_pause(&retries); test_close();
bool bridging=bridge_phase!=0;stream_close(milliseconds());bridge_phase=0; stop_capture();
memset(&continuity,0,sizeof(continuity));stop_capture(); if (piccap_owned) {
if(bridging){ handover_enabled = false;
output=target_connect(&target); retry_pause(&retries);
if(output>=0){if(target_login())send_line("{\"command\":\"clear\",\"priority\":149}\n");close(output);output=-1;} uint64_t deadline = milliseconds() + 4000;
} while (piccap_owned && milliseconds() < deadline) {
return respond(h,m,"{\"returnValue\":true,\"running\":false}"); handover_tick();
} iterate(NULL, 0);
static bool autostart(void *h,void *m,void *ctx){ usleep(10000);
(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; unreg(handle, &e);
else return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Expected enabled boolean\"}"); error_free(&e);
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.\"}"); loop_unref(loop);
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.\"}"); return 0;
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<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==4?"true":"false");
used+=snprintf(json+used,sizeof(json)-used,"\"sampleSequence\":%u,\"sampleAgeMs\":%lld,\"holdingColors\":%s,\"sourceFade\":%s,\"calibrating\":%s,",sample_sequence,sample_at?(long long)(now-sample_at):-1,continuity.holding?"true":"false",(continuity.fading || bridge_phase==3)?"true":"false",CALIBRATION_ACTIVE?"true":"false");
used+=snprintf(json+used,sizeof(json)-used,"\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\",\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",",target.piccap_port,test_stage_name(),connection_test.leds,(unsigned long long)connection_test.elapsed,connection_test.error,last_output_at?(long long)(now-last_output_at):-1,(unsigned long long)(retries.due>now?retries.due-now:0),capture_source==SOURCE_PICCAP_STARTING?"Starting PicCap":capture_source==SOURCE_PICCAP_STOPPING?"Stopping PicCap":piccap_owned?"PicCap":capture?"Histogram":"Stopped",piccap_error);
used+=snprintf(json+used,sizeof(json)-used,"\"version\":\"0.11.0\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s,\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,endpoint,target.host,target.port,target.receiver,target.token[0]?"true":"false",target_piccap_compatible(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe);
for(int i=0;i<16;i++)used+=snprintf(json+used,sizeof(json)-used,"%s[%u,%u,%u]",i?",":"",rgb[i][0],rgb[i][1],rgb[i][2]);
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<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;
}
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()<deadline){handover_tick();iterate(NULL,0);usleep(10000);}
}
unreg(handle,&e);error_free(&e);loop_unref(loop);return 0;
} }
+202
View File
@@ -0,0 +1,202 @@
/* Luna API handlers. Included after the shared runtime context. */
#include "service_calibration.h"
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 != BRIDGE_IDLE;
stream_close(milliseconds());
bridge_phase = BRIDGE_IDLE;
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 = 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"
+107
View File
@@ -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}");
}
+111
View File
@@ -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);
}
+105 -40
View File
@@ -1,47 +1,112 @@
#ifndef HYPERDRM_SOURCE_BRIDGE_H #ifndef HYPERDRM_SOURCE_BRIDGE_H
#define HYPERDRM_SOURCE_BRIDGE_H #define HYPERDRM_SOURCE_BRIDGE_H
static unsigned bridge_phase,bridge_sequence; #include "runtime_states.h"
static uint64_t bridge_started,bridge_clear_at,bridge_fade_at,bridge_last_sent; static BridgePhase bridge_phase;
static unsigned char bridge_from[16][3],bridge_to[16][3]; 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 CaptureSource bridge_source;
static char *region_command(const unsigned char colors[16][3],const LedBlend *mapping,unsigned count,unsigned duration){ static char *region_command(const unsigned char colors[16][3], const LedBlend *mapping,
if(!count || !mapping)return NULL; unsigned count, unsigned duration) {
if(!strcmp(target.receiver,"HyperHDR"))return spatial_image_command_duration(colors,PRIORITY,duration); if (!count || !mapping)
size_t capacity=256+(size_t)count*12;char *line=malloc(capacity);if(!line)return NULL; return NULL;
size_t n=(size_t)snprintf(line,capacity,"{\"command\":\"color\",\"priority\":%d,\"duration\":%u,\"origin\":\"HyperDRM\",\"color\":[",PRIORITY,duration); if (!strcmp(target.receiver, "HyperHDR"))
for(unsigned i=0;i<count;i++){unsigned char value[3];LedBlend b=mapping[i];blend_color(colors[b.a],colors[b.b],b.weight,value);n+=(size_t)snprintf(line+n,capacity-n,"%s%u,%u,%u",i?",":"",value[0],value[1],value[2]);} return spatial_image_command_duration(colors, PRIORITY, duration);
snprintf(line+n,capacity-n,"]}\n");return line; size_t capacity = 256 + (size_t)count * 12;
char *line = malloc(capacity);
if (!line)
return NULL;
size_t n = (size_t)snprintf(line, capacity,
"{\"command\":\"color\",\"priority\":%d,\"duration\":%u,\"origin\":"
"\"HyperDRM\",\"color\":[",
PRIORITY, duration);
for (unsigned i = 0; i < count; i++) {
unsigned char value[3];
LedBlend b = mapping[i];
blend_color(colors[b.a], colors[b.b], b.weight, value);
n += (size_t)snprintf(line + n, capacity - n, "%s%u,%u,%u", i ? "," : "", value[0],
value[1], value[2]);
}
snprintf(line + n, capacity - n, "]}\n");
return line;
} }
static bool bridge_clear(void){return stream.stage==4 && stream_queue("{\"command\":\"clear\",\"priority\":149}\n");} static bool bridge_clear(void) {
static bool bridge_send(const unsigned char colors[16][3]){ return stream.stage == STREAM_SUBSCRIBED &&
if(stream.stage!=4)return false; stream_queue("{\"command\":\"clear\",\"priority\":149}\n");
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){ static bool bridge_send(const unsigned char colors[16][3]) {
if(retries.paused || retries.blocked){if(bridge_phase)bridge_clear();bridge_phase=0;bridge_source=capture_source;return;} if (stream.stage != STREAM_SUBSCRIBED)
if(capture_source!=bridge_source){ return false;
if(capture_source==SOURCE_PICCAP_STARTING){ char *line = region_command(colors, stream.mapping, stream.count, 1500);
bridge_phase=continuity.available && now-continuity.valid_at<2000?1:0;bridge_started=now;memcpy(bridge_from,continuity.last,sizeof(bridge_from)); if (!line)
}else if(capture_source==SOURCE_PICCAP_STOPPING || (bridge_source==SOURCE_PICCAP && capture_source==SOURCE_HISTOGRAM)){ return false;
bridge_phase=0; bool ok = stream_queue(line);
if(stream.valid && now-stream.updated<1000){continuity_seed(&continuity,stream.colors,now);bridge_send(stream.colors);} free(line);
} return ok;
bridge_source=capture_source; }
} static void bridge_tick(uint64_t now) {
if(bridge_phase && now-bridge_started>=3000){if(bridge_clear())bridge_phase=0;return;} if (retries.paused || retries.blocked) {
if(bridge_phase==1 && capture_source==SOURCE_PICCAP && piccap_connected && stream.stage==4){ if (bridge_phase)
if(bridge_clear()){bridge_sequence=stream.sequence;bridge_clear_at=now;bridge_phase=2;} bridge_clear();
}else if(bridge_phase==1 && now-bridge_last_sent>=100 && stream.stage==4){ bridge_phase = BRIDGE_IDLE;
if(bridge_send(bridge_from))bridge_last_sent=now; bridge_source = capture_source;
}else if(bridge_phase==2 && stream.valid && stream.sequence>bridge_sequence && stream.updated>=bridge_clear_at+150){ return;
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 (capture_source != bridge_source) {
if(black)bridge_phase=0; if (capture_source == SOURCE_PICCAP_STARTING) {
} bridge_phase = continuity.available && now - continuity.valid_at < 2000 ? BRIDGE_HOLDING
if(bridge_phase==3 && now-bridge_last_sent>=100){ : BRIDGE_IDLE;
if(now-bridge_fade_at>=500){if(bridge_clear())bridge_phase=0;} bridge_started = now;
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;} 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 #endif
+40 -18
View File
@@ -5,26 +5,48 @@
#include "layout.h" #include "layout.h"
#include "interpolation.h" #include "interpolation.h"
#include "color_boost.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. /* Synthetic color plane from histogram samples, never captured video pixels.
* HyperHDR 22 treats JSON color as a solid color; image input retains space. * HyperHDR 22 treats JSON color as a solid color; image input retains space.
* The receiver samples this plane using its configured LED rectangles. */ * The receiver samples this plane using its configured LED rectangles. */
static char *spatial_image_command_duration(const unsigned char regions[16][3],unsigned priority,unsigned duration){ static char *spatial_image_command_duration(const unsigned char regions[16][3], unsigned priority,
static LedBlend blends[SPATIAL_PIXELS];static unsigned generation; unsigned duration) {
if(generation!=calibration_generation){ static LedBlend blends[SPATIAL_PIXELS];
for(unsigned y=0;y<SPATIAL_HEIGHT;y++)for(unsigned x=0;x<SPATIAL_WIDTH;x++) static unsigned generation;
blends[y*SPATIAL_WIDTH+x]=layout_blend((x+0.5)/SPATIAL_WIDTH,(y+0.5)/SPATIAL_HEIGHT); if (generation != calibration_generation) {
generation=calibration_generation; for (unsigned y = 0; y < SPATIAL_HEIGHT; y++)
} for (unsigned x = 0; x < SPATIAL_WIDTH; x++)
const char alphabet[]="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; blends[y * SPATIAL_WIDTH + x] =
size_t capacity=SPATIAL_PIXELS*4+256;char *line=malloc(capacity);if(!line)return NULL; layout_blend((x + 0.5) / SPATIAL_WIDTH, (y + 0.5) / SPATIAL_HEIGHT);
size_t used=snprintf(line,capacity,"{\"command\":\"image\",\"priority\":%u,\"duration\":%u,\"origin\":\"HyperDRM\",\"imagewidth\":%u,\"imageheight\":%u,\"format\":\"rgb\",\"name\":\"Histogram colors\",\"imagedata\":\"",priority,duration,SPATIAL_WIDTH,SPATIAL_HEIGHT); generation = calibration_generation;
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); const char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
line[used++]=alphabet[color[0]>>2];line[used++]=alphabet[((color[0]&3)<<4)|(color[1]>>4)]; size_t capacity = SPATIAL_PIXELS * 4 + 256;
line[used++]=alphabet[((color[1]&15)<<2)|(color[2]>>6)];line[used++]=alphabet[color[2]&63]; char *line = malloc(capacity);
} if (!line)
line[used++]='"';line[used++]='}';line[used++]='\n';line[used]=0;return 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 #endif
+184 -70
View File
@@ -10,80 +10,194 @@
#include <sys/time.h> #include <sys/time.h>
#include <unistd.h> #include <unistd.h>
#include "playback.h" #include "playback.h"
#define TARGET_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/target.json" #define TARGET_PREF \
typedef struct {char host[46],receiver[16],token[257];unsigned port,piccap_port;} TargetConfig; "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/target.json"
static TargetConfig target={.host="127.0.0.1",.receiver="Hyperion",.port=19444,.piccap_port=19400}; typedef struct {
static inline bool target_piccap_local(const TargetConfig *t){return t->port==19444 && (!strcmp(t->host,"127.0.0.1") || !strcmp(t->host,"::1"));} char host[46], receiver[16], token[257];
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;} unsigned port, piccap_port;
static inline const char *target_piccap_host(const TargetConfig *t){return !strcmp(t->host,"::1")?"127.0.0.1":t->host;} } TargetConfig;
static bool target_valid(const TargetConfig *t){ static TargetConfig target = {
unsigned char address[16]; .host = "127.0.0.1", .receiver = "Hyperion", .port = 19444, .piccap_port = 19400};
if(inet_pton(AF_INET,t->host,address)!=1 && inet_pton(AF_INET6,t->host,address)!=1)return false; static inline bool target_piccap_local(const TargetConfig *t) {
if(!t->piccap_port || t->piccap_port>65535)return false; return t->port == 19444 && (!strcmp(t->host, "127.0.0.1") || !strcmp(t->host, "::1"));
if(!t->port || t->port>65535 || (strcmp(t->receiver,"Hyperion") && strcmp(t->receiver,"HyperHDR")))return false;
for(const unsigned char *p=(const unsigned char*)t->token;*p;p++)
if(!((*p>='a'&&*p<='z')||(*p>='A'&&*p<='Z')||(*p>='0'&&*p<='9')||*p=='-'||*p=='_'||*p=='.'))return false;
return true;
} }
static bool target_parse(PlaybackJson *j,const char *text,TargetConfig *out){ static inline bool target_piccap_compatible(const TargetConfig *t) {
void *root=j->parse(text);if(!root)return false; unsigned char address[4];
TargetConfig next=*out;bool valid=j->type(root)==4; return !strcmp(t->host, "::1") || inet_pton(AF_INET, t->host, address) == 1;
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 bool target_save(const char *path,const TargetConfig *t){ static inline const char *target_piccap_host(const TargetConfig *t) {
if(!target_valid(t))return false; return !strcmp(t->host, "::1") ? "127.0.0.1" : t->host;
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){ static bool target_valid(const TargetConfig *t) {
int fd=open(path,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return false; unsigned char address[16];
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; if (inet_pton(AF_INET, t->host, address) != 1 && inet_pton(AF_INET6, t->host, address) != 1)
data[n]=0;return target_parse(j,data,t); 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){ static bool target_parse(PlaybackJson *j, const char *text, TargetConfig *out) {
if(!target_valid(t))return -1; void *root = j->parse(text);
struct sockaddr_storage storage={0};socklen_t length;int family; if (!root)
struct sockaddr_in *v4=(void*)&storage;struct sockaddr_in6 *v6=(void*)&storage; return false;
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);} TargetConfig next = *out;
else {family=AF_INET6;v6->sin6_family=family;v6->sin6_port=htons(t->port);length=sizeof(*v6);} bool valid = j->type(root) == 4;
int fd=socket(family,SOCK_STREAM,0);if(fd<0)return -1; const char *names[] = {"host", "receiver", "token"};
int flags=fcntl(fd,F_GETFL,0);if(flags<0 || fcntl(fd,F_SETFL,flags|O_NONBLOCK)<0){close(fd);return -1;} char *fields[] = {next.host, next.receiver, next.token};
if(connect(fd,(void*)&storage,length)!=0){ size_t sizes[] = {sizeof(next.host), sizeof(next.receiver), sizeof(next.token)};
if(errno!=EINPROGRESS){close(fd);return -1;} for (unsigned i = 0; valid && i < 3; i++) {
} void *v = playback_field(j, root, names[i]);
return fd; 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){ static bool target_save(const char *path, const TargetConfig *t) {
int fd=target_open(t);if(fd<0)return -1; if (!target_valid(t))
struct pollfd p={.fd=fd,.events=POLLOUT};int error=0;socklen_t size=sizeof(error); return false;
if(poll(&p,1,1000)<=0 || getsockopt(fd,SOL_SOCKET,SO_ERROR,&error,&size)!=0 || error){close(fd);return -1;} char tmp[512], data[512];
int flags=fcntl(fd,F_GETFL,0); if (snprintf(tmp, sizeof(tmp), "%s.tmp", path) >= (int)sizeof(tmp))
if(flags<0 || fcntl(fd,F_SETFL,flags & ~O_NONBLOCK)<0){close(fd);return -1;} return false;
struct timeval timeout={1,0};setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout)); int n = snprintf(
return fd; 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 #endif
+105 -30
View File
@@ -5,35 +5,110 @@
#include "target.h" #include "target.h"
#include "layout.h" #include "layout.h"
#include "receiver_stream.h" #include "receiver_stream.h"
static void fixture(PlaybackJson *json,bool modern,bool login){ static void fixture(PlaybackJson *json, bool modern, bool login) {
int server=socket(AF_INET,SOCK_STREAM,0);assert(server>=0); int server = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in address={.sin_family=AF_INET,.sin_addr={htonl(INADDR_LOOPBACK)}}; assert(server >= 0);
assert(!bind(server,(void*)&address,sizeof(address)) && !listen(server,1));socklen_t size=sizeof(address);assert(!getsockname(server,(void*)&address,&size)); struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr = {htonl(INADDR_LOOPBACK)}};
target.port=ntohs(address.sin_port);snprintf(target.token,sizeof(target.token),"%s",login?"fixture-token":""); assert(!bind(server, (void *)&address, sizeof(address)) && !listen(server, 1));
pid_t child=fork();assert(child>=0); socklen_t size = sizeof(address);
if(!child){ assert(!getsockname(server, (void *)&address, &size));
int fd=accept(server,NULL,NULL);if(fd<0)_exit(1);FILE *input=fdopen(dup(fd),"r");char request[512]; target.port = ntohs(address.sin_port);
if(login){if(!fgets(request,sizeof(request),input) || !strstr(request,"authorize"))_exit(2);write(fd,"{\"success\":true}\n",17);} snprintf(target.token, sizeof(target.token), "%s", login ? "fixture-token" : "");
if(!fgets(request,sizeof(request),input) || !strstr(request,"serverinfo"))_exit(3); pid_t child = fork();
const char *layout="{\"success\":true,\"info\":{\"leds\":[{\"hmin\":0,\"hmax\":1,\"vmin\":0,\"vmax\":0.1}]}}\n";write(fd,layout,strlen(layout)); assert(child >= 0);
if(!fgets(request,sizeof(request),input) || !strstr(request,"ledstream-start"))_exit(4); if (!child) {
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"; int fd = accept(server, NULL, NULL);
write(fd,reply,13);usleep(20000);write(fd,reply+13,strlen(reply)-13);usleep(500000);close(fd);fclose(input);close(server);_exit(0); if (fd < 0)
} _exit(1);
stream_close(0);stream.retry_at=0; FILE *input = fdopen(dup(fd), "r");
for(unsigned i=0;i<500 && !stream.valid;i++){stream_tick(json,1000+i*5,true);usleep(1000);} char request[512];
assert(stream.valid && stream.sequence==1 && stream.count==1); if (login) {
for(unsigned r=0;r<16;r++){assert(stream.colors[r][0]==20 && stream.colors[r][1]==30 && stream.colors[r][2]==40);} if (!fgets(request, sizeof(request), input) || !strstr(request, "authorize"))
void *bad=json->parse("{\"result\":{\"leds\":[0,0,999]}}");assert(!stream_colors(json,bad,2000));json->put(bad);assert(stream.colors[0][2]==40); _exit(2);
bad=json->parse("{\"data\":{\"leds\":[0,0,0]}}");assert(stream_colors(json,bad,2000));json->put(bad);assert(stream.colors[0][2]==0); write(fd, "{\"success\":true}\n", 17);
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); 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){ int main(void) {
PlaybackJson json={0};assert(playback_json_init(&json));fixture(&json,false,false);fixture(&json,true,true); PlaybackJson json = {0};
stream_start(1000);stream_tick(&json,5000,true);assert(stream.fd<0);stream_close(0); assert(playback_json_init(&json));
TargetConfig copy=target;assert(target_valid(&copy));assert(target_piccap_compatible(&copy));assert(!target_piccap_local(&copy));assert(target_piccap_host(&copy)); fixture(&json, false, false);
assert(!target_parse(&json,"{}",&copy));assert(!target_save("/no/such/profile",&copy));assert(!target_load(&json,"/no/such/profile",&copy)); fixture(&json, true, true);
int fd=target_connect(&copy);if(fd>=0)close(fd); stream_start(1000);
dlclose(json.lib);puts("PASS: fragmented raw LED subscription, token login, old/new envelopes, invalid samples, real black, timeout and cleanup");return 0; stream_tick(&json, 5000, true);
assert(stream.fd < 0);
stream_close(0);
TargetConfig copy = target;
assert(target_valid(&copy));
assert(target_piccap_compatible(&copy));
assert(!target_piccap_local(&copy));
assert(target_piccap_host(&copy));
assert(!target_parse(&json, "{}", &copy));
assert(!target_save("/no/such/profile", &copy));
assert(!target_load(&json, "/no/such/profile", &copy));
int fd = target_connect(&copy);
if (fd >= 0)
close(fd);
dlclose(json.lib);
puts("PASS: fragmented raw LED subscription, token login, old/new envelopes, invalid samples, "
"real black, timeout and cleanup");
return 0;
} }
+4
View File
@@ -0,0 +1,4 @@
#ifndef HYPERDRM_VERSION_H
#define HYPERDRM_VERSION_H
#define HYPERDRM_VERSION "0.12.0"
#endif
+10 -1
View File
@@ -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"
}
+99
View File
@@ -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'!<arch>\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()
Binary file not shown.
+33
View File
@@ -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)
+3
View File
@@ -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; 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); 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')); 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); 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); 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'); console.log('PASS: root-required autostart repairs only own service, confirms root after restart, retries setting, avoids unnecessary elevation and reports denial');
+50
View File
@@ -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()
+4 -2
View File
@@ -1,11 +1,11 @@
'use strict'; 'use strict';
const assert=require('assert'),fs=require('fs'),vm=require('vm'); const assert=require('assert'),fs=require('fs'),vm=require('vm');
const 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;}}); ['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, 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;}}, window:{addEventListener(k,f){events[k]=f;}},document:{getElementById(id){return elements[id];},addEventListener(k,f){events[k]=f;}},
call(method,done,data){ 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 t=elements.calibrationVideo.currentTime,step=t<4?-1:Math.floor((t-4)/2);
const regions=Array.from({length:16},(_,r)=>{ const regions=Array.from({length:16},(_,r)=>{
const center=r*2+.5,d=Math.min(Math.abs(step-center),32-Math.abs(step-center)); const 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); 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.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();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'); console.log('PASS: guided measurement, proposal without save, invalid-response preservation, validation, cancel/Back and scoped remote navigation');