diff --git a/.gitignore b/.gitignore index 75c495a..0118843 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,7 @@ tests/receiver-proxy.log test-spatial-image test-diagnostics + +test-calibration +test-layout_refresh +test-spatial_image diff --git a/HyperDRM.manifest.json b/HyperDRM.manifest.json index 215e00e..4d005af 100644 --- a/HyperDRM.manifest.json +++ b/HyperDRM.manifest.json @@ -1,15 +1,15 @@ { "id": "org.webosbrew.histogramhyperion", - "version": "0.9.0", + "version": "0.10.0", "type": "web", "title": "HyperDRM", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "iconUri": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/app/icon.png", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM", "rootRequired": true, - "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.9.0_arm.ipk", + "ipkUrl": "https://gitea.adnau.de/matze19999/HyperDRM/raw/branch/main/HyperDRM_0.10.0_arm.ipk", "ipkHash": { - "sha256": "f9cb0b4035a0b4f527da814ecb0ea1e0c184fc92154f60899b6425c84e0d2d9b" + "sha256": "0ad040adfc0be402f5c83e783a8f9b4e78b33b59e5b04fa3d52652bf48ddda70" }, - "ipkSize": 1177866 + "ipkSize": 1183836 } diff --git a/HyperDRM_0.9.0_arm.ipk b/HyperDRM_0.10.0_arm.ipk similarity index 89% rename from HyperDRM_0.9.0_arm.ipk rename to HyperDRM_0.10.0_arm.ipk index 3747887..94982c8 100644 Binary files a/HyperDRM_0.9.0_arm.ipk and b/HyperDRM_0.10.0_arm.ipk differ diff --git a/README.md b/README.md index 89102be..556abca 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Ambient Light for webOS, Hyperion and HyperHDR. An own webOS app and native ARM ## Features - Spatial colors from 16 LG histogram regions with interpolated transitions along the screen perimeter. -- LED count, ordering and screen rectangles read from Hyperion at each service start. No fixed 200-LED layout; layouts with 50 and 500 LEDs were tested. +- LED count, ordering and screen rectangles read from the receiver at startup and refreshed every five seconds without capture restart. No fixed 200-LED layout; layouts with 50 and 500 LEDs were tested. - Automatic detection of top/bottom black bars on the calibrated LG G4. - Original saturation and brightness (both gains 1.0). - Start, stop, status and persistent boot autostart switch in the TV app. @@ -19,13 +19,13 @@ Tested on a rooted LG G4 with webOS 24, Homebrew Channel and Hyperion 2.0.16 and DRM playback color output was confirmed on the test TV. Support depends on LG's capture implementation; this does not guarantee support on other devices or apps. HyperDRM transmits colors, not captured video frames. The video preview does not show the TV picture. HyperHDR may show a generated histogram color plane instead. -The 16 histogram samples are independent measurements; interpolation produces smoother transitions without adding independently captured detail. Sample positions are approximate G4 calibration values. Top/bottom bar detection is supported; side bars, arbitrary bar thickness and HDR color calibration remain limited. +The 16 histogram samples are independent measurements; interpolation produces smoother transitions without adding independently captured detail. Sample positions come from the selected model profile; the built-in profile contains approximate G4 calibration values. Auto uses uniform average colors on uncalibrated models. Top/bottom bar detection is supported; side bars, arbitrary bar thickness and HDR color calibration remain limited. Only the own service is modified. Optional Auto PicCap can configure its receiver endpoint and start and stop the installed PicCap service through its existing API. No Hue, LG library, driver or firmware binaries are included. For histogram initialization, the service temporarily creates LG's developer marker and uses a guardian process to remove its own marker afterward; an existing marker is preserved. ## Installation -The current package is [HyperDRM_0.9.0_arm.ipk](HyperDRM_0.9.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow. +The current package is [HyperDRM_0.10.0_arm.ipk](HyperDRM_0.10.0_arm.ipk). Install it using the TV's webOS/Homebrew package installation workflow. Elevate the installed own service through Homebrew Channel: @@ -90,7 +90,7 @@ Remote navigation regression tests: `node tests/remote-navigation.test.js`. Version 0.7.0 adds **Auto PicCap**, disabled by default. The switch is available when PicCap is installed. Keep PicCap's own autostart disabled. From 0.9.0, HyperDRM manages PicCap's receiver address, FlatBuffer port and Unix socket setting when automatic handover is enabled; other capture preferences and the autostart preference are preserved. -When the session is started, the controller polls LG's active media resources. Two consecutive foreground media-decoder samples without a Secure Video Path resource allow PicCap to start. HyperDRM releases its histogram capture and clears priority 149 first, allowing PicCap's configured input priority to take over. Any `SVP_CPB` resource selects protected playback; missing, stale, malformed, idle or HDMI state selects the conservative HyperDRM fallback. PicCap must confirm that it has stopped before histogram capture resumes. Detection is specific to the tested G4 resource model, not a universal DRM-state API. +When the session is started, the controller polls LG's active media resources. From 0.10.0, three consecutive foreground media-decoder samples over at least two seconds without a Secure Video Path resource allow PicCap to start. HyperDRM releases its histogram capture and clears priority 149 first, allowing PicCap's configured input priority to take over. Any `SVP_CPB` resource selects protected playback immediately. Missing, stale, malformed, idle or HDMI state selects the conservative HyperDRM fallback; an already active PicCap session gets at most 1.5 seconds of unknown-state grace for short playback pauses. Unknown states never start PicCap. PicCap must confirm that it has stopped before histogram capture resumes. Detection is specific to the tested G4 resource model, not a universal DRM-state API. The switch is saved in the own service container and applies after reboot when Autostart is enabled. Stop pauses both managed capture paths until Start. Turning Auto PicCap off stops a PicCap session started by this controller and restores HyperDRM. Avoid starting another capture service independently while automatic handover is active. @@ -127,3 +127,15 @@ The diagnostic line reports service version/root status, capture transition, pla Before PicCap starts, its endpoint is read through getSettings. Matching settings are reused. Otherwise setSettings updates only address, port and unix-socket:false; the official implementation restarts PicCap itself, so the controller checks status rather than issuing another start. Endpoint changes are saved by PicCap and remain after disabling handover. Changing the receiver stops the managed PicCap session before configuring the new endpoint. Settings errors/timeouts stop PicCap before histogram fallback, with a cooldown to avoid rapid restarts. Feature tests are native/test_diagnostics.c, native/test_target.c, native/test_handover.c and tests/receiver-ui.test.js. The native tests cover fragmented replies, rejected authentication, invalid layout, refused connection, timeout, custom PicCap endpoint, configuration failure and exclusive capture ownership. Test-only JSON and FlatBuffer relays allow a real receiver to be reached through another IP and ports without changing receiver configuration. + +## Capture resilience and live layout (0.10.0) + +If managed PicCap reports disconnected for five seconds, HyperDRM requests its stop, waits for confirmed stopped status and resumes histogram output. Reconnection inside that window clears the loss timer. A 30-second cooldown prevents immediate PicCap restart after a connection failure; normal capture transitions have a three-second reentry delay. Manual Stop, disabling handover, changing the receiver and secure playback take precedence over unknown-state grace. + +While a capture session runs, a separate nonblocking authenticated JSON connection checks the LED layout every five seconds. Changed LED count, positions or order replace the validated mapping between frames. Invalid or timed-out responses retain the last good mapping and report a layout error; capture continues. Profile changes cancel obsolete probes. Receiver JSON streaming and the user connection test have independent sockets. HyperHDR continues to apply its own current LED rectangles to the generated color plane. + +## TV model profiles (0.10.0) + +Select Auto, LG G4 (measured), Average (uniform), or Custom model in TV calibration and select Save profile. The app obtains the model identifier through LG's system-property service. Auto selects an imported profile matching that exact identifier, then the built-in G4 series profile, otherwise uniform average colors. Unknown models are not assigned an invented spatial calibration. A manual G4 selection is available when model detection is unavailable. + +Custom imports accept measured model JSON with 2-16 sorted clockwise perimeter points and histogram region indices 0-15. Profiles persist privately in the own service container and apply to the current session without restarting capture. See [profile format and calibration guidance](profiles/README.md). Only G4 hardware has been validated; importing a profile does not guarantee histogram support on another TV. Color calibration remains in Hyperion/HyperHDR. diff --git a/app/app.js b/app/app.js index 142ecad..cf6b6ae 100644 --- a/app/app.js +++ b/app/app.js @@ -1,6 +1,9 @@ 'use strict'; var pending=[]; var targetDirty=false,receiver='Hyperion'; +var profileDirty=false,profile='auto'; +var profileNames={auto:'Auto',g4:'LG G4 (measured)',average:'Average (uniform)',custom:'Custom model'}; +function showProfile(){document.getElementById('profile').textContent='Profile: '+profileNames[profile];document.getElementById('customEditor').style.display=profile==='custom'?'block':'none';document.getElementById('customProfile').disabled=profile!=='custom';} function call(method,done,data){ 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; @@ -10,6 +13,12 @@ function call(method,done,data){ bridge.call('luna://org.webosbrew.histogramhyperion.service/'+method,JSON.stringify(data||{})); } function display(result){ + 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; @@ -19,7 +28,7 @@ function display(result){ 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:''); + 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'; @@ -32,6 +41,7 @@ function display(result){ } 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'; @@ -62,4 +72,7 @@ document.getElementById('saveTarget').onclick=function(){ button.disabled=true;call('settings',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}targetDirty=false;document.getElementById('token').value='';document.getElementById('clearToken').checked=false;call('status',display);},settings); }; document.getElementById('testConnection').onclick=function(){if(targetDirty){document.getElementById('error').textContent='Save receiver settings before testing the connection.';return;}this.disabled=true;call('testConnection',function(r){if(r.returnValue===false)document.getElementById('error').textContent=r.errorText;call('status',display);});}; -setupRemoteNavigation(['start','stop','refresh','autostart','handover','receiver','host','port','piccapPort','token','clearToken','saveTarget','testConnection'].map(function(id){return document.getElementById(id);}),document,window); +document.getElementById('profile').onclick=function(){var keys=['auto','g4','average','custom'];profile=keys[(keys.indexOf(profile)+1)%keys.length];profileDirty=true;showProfile();}; +document.getElementById('customProfile').addEventListener('input',function(){profileDirty=true;}); +document.getElementById('saveProfile').onclick=function(){var data={profile:profile},button=this;if(profile==='custom'){try{var parsed=JSON.parse(document.getElementById('customProfile').value);data.model=parsed.model;data.points=parsed.points;}catch(e){document.getElementById('error').textContent='Custom profile must be valid JSON.';return;}}button.disabled=true;call('calibration',function(r){button.disabled=false;if(r.returnValue===false){document.getElementById('error').textContent=r.errorText;return;}profileDirty=false;call('status',display);},data);}; +setupRemoteNavigation(['start','stop','refresh','autostart','handover','receiver','host','port','piccapPort','token','clearToken','saveTarget','testConnection','profile','customProfile','saveProfile'].map(function(id){return document.getElementById(id);}),document,window); diff --git a/app/appinfo.json b/app/appinfo.json index 0460b9a..7671e8a 100644 --- a/app/appinfo.json +++ b/app/appinfo.json @@ -1 +1 @@ -{"id": "org.webosbrew.histogramhyperion", "version": "0.9.0", "vendor": "Local development", "type": "web", "main": "index.html", "title": "HyperDRM", "icon": "icon.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, "largeIcon": "large-icon.png", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"} +{"id": "org.webosbrew.histogramhyperion", "version": "0.10.0", "vendor": "Local development", "type": "web", "main": "index.html", "title": "HyperDRM", "icon": "icon.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, "largeIcon": "large-icon.png", "appDescription": "Ambient lighting for webOS, Hyperion and HyperHDR", "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"} diff --git a/app/index.html b/app/index.html index 699918c..79ffa02 100644 --- a/app/index.html +++ b/app/index.html @@ -1,9 +1,9 @@ HyperDRM - +

HyperDRM

Ambient lighting for webOS, Hyperion and HyperHDR.

Checking service …

-

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

Autostart starts the service when the TV boots. Stop ends the current session. Auto PicCap uses installed PicCap for non-secure video and HyperDRM for protected or unknown playback. Keep PicCap's own autostart off.

+

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

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

Receiver settings @@ -12,7 +12,10 @@

Connection test: Not tested

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

Auto PicCap uses the same receiver IP with its FlatBuffer port, usually 19400. Enable the FlatBuffer server on the receiver. When switching to PicCap, its saved address, port and Unix socket setting are updated; other PicCap preferences are preserved. PicCap supports IPv4 targets; remote IPv6 uses HyperDRM. The API token applies to JSON only.

+
TV calibration +

Detecting TV model...

+

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

Priority 149 · 10 updates per second

-

Adjust saturation, brightness and smoothing in your receiver. The receiver layout determines the number and positions of LEDs. Colors come from 16 LG histogram regions with interpolated edge colors. HyperHDR receives a generated color plane, which its image mapping converts to LED colors. Restart the service after changing the LED layout.

+

Adjust saturation, brightness and smoothing in your receiver. The receiver layout determines the number and positions of LEDs. Colors come from 16 LG histogram regions with interpolated edge colors. HyperHDR receives a generated color plane, which its image mapping converts to LED colors. The LED layout is checked every five seconds and updated automatically.

diff --git a/app/remote.js b/app/remote.js index 1af554f..d7fb942 100644 --- a/app/remote.js +++ b/app/remote.js @@ -11,7 +11,7 @@ function setupRemoteNavigation(buttons, doc, win) { event.key==='ArrowRight'||event.key==='ArrowDown'||code===39||code===40?1:0; var enter=event.key==='Enter'||code===13; if(!direction&&!enter)return; - var editing=doc.activeElement && doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox'; + var editing=doc.activeElement && (doc.activeElement.tagName==='INPUT' && doc.activeElement.type!=='checkbox' || doc.activeElement.tagName==='TEXTAREA'); if(editing && (enter||code===37||code===39||event.key==='ArrowLeft'||event.key==='ArrowRight'))return; event.preventDefault(); var enabled=buttons.filter(function(button){return !button.disabled;}); diff --git a/docs/publishing-review.md b/docs/publishing-review.md index 54760b6..f002fe7 100644 --- a/docs/publishing-review.md +++ b/docs/publishing-review.md @@ -89,3 +89,13 @@ ARM tests on the G4 passed connection diagnostics, fragmented replies, invalid l Switching the user playback back to DRM stopped PicCap automatically and resumed histogram output through the remote JSON relay. Seven actual HyperHDR LED-stream samples contained 89-91 distinct colors for 120 LEDs, with no all-black sample. Both test endpoints were restored to local defaults and the synthetic token was removed. The remote FlatBuffer relay received over 411 MB from PicCap during the test session. The final port validation also rejects explicit JSON null while retaining the default for older preferences without the new field. + +## Capture resilience, live layout and model profiles (0.10.0) + +Managed PicCap requires three clear media samples over two seconds before starting. Active PicCap has a bounded 1.5-second grace for unknown state; secure playback, manual Stop, handover disable and endpoint changes take precedence. Five seconds of reported receiver disconnection trigger a confirmed PicCap stop and histogram fallback, followed by a 30-second cooldown. A successful reconnection clears the loss timer. ARM controller tests passed stable gating, pause grace expiry, immediate secure transition, reconnect/loss/cooldown, exclusive capture, custom endpoints and request failures. + +A separate nonblocking authenticated receiver probe refreshes the LED mapping every five seconds. Invalid responses retain the good mapping. Tests passed 50-to-500 LED replacement, changed positions at the same count, unchanged mappings, obsolete profile-result cancellation and failure retention. The live relay test read 50 LEDs at frame 2, changed to 500 at frame 53, applied changed 500-LED positions at frame 101, retained output after an empty layout at frame 149, and restored the actual 120-LED layout at frame 195. Capture/output continued across these checks without resetting the frame counter. Receiver configuration and WLED settings were not modified. + +The app automatically detected OLED65G42LW.DEUQLJP and selected the measured G4 profile. A live switch to Average produced one unique output color across four HyperHDR LED-stream samples; switching back to G4 produced 80-97 unique colors. Auto and the original receiver/handover preferences were restored afterward. Custom profiles validate 2-16 sorted rim positions, bounded region indices and model identifiers, persist privately, and are selected automatically only for the exact imported model identifier. No measured spatial profile for other hardware is claimed. Native tests passed profile parsing/persistence, model selection and average fallback. The synthetic-image test verifies geometry cache replacement after a custom-profile change. + +The receiver UI and remote-navigation tests passed, including custom JSON input and profile controls. Diagnosis export was deliberately excluded from this release. Cold boot and other TV models remain unverified. diff --git a/native/calibration.h b/native/calibration.h new file mode 100644 index 0000000..27010dd --- /dev/null +++ b/native/calibration.h @@ -0,0 +1,84 @@ +#ifndef HYPERDRM_CALIBRATION_H +#define HYPERDRM_CALIBRATION_H +#include +#include "playback.h" +#define CALIBRATION_PREF "/media/developer/apps/usr/palm/services/org.webosbrew.histogramhyperion.service/calibration.json" +typedef struct {double position;unsigned char region;} RimPoint; +typedef enum {CAL_AUTO,CAL_G4,CAL_AVERAGE,CAL_CUSTOM} CalibrationKind; +typedef struct {CalibrationKind kind;char model[64];unsigned count;RimPoint points[16];} Calibration; +static Calibration calibration; +static char tv_model[64]; +static unsigned calibration_generation=1; +static inline unsigned calibration_revision(void){return calibration_generation;} +static const RimPoint g4_rim[]={ + {0.2078431373,7},{0.3176470588,3},{0.5490196078,11},{0.7529411765,12}, + {0.8392156863,13},{1.4941176471,10},{2.1843137255,9},{2.5490196078,1}, + {2.8666666667,5},{3.4,15} +}; +static inline const char *calibration_key(CalibrationKind kind){ + const char *keys[]={"auto","g4","average","custom"};return keys[kind]; +} +static inline bool calibration_model_valid(const char *model){ + if(!model[0] || strlen(model)>63)return false; + for(const unsigned char *p=(const unsigned char*)model;*p;p++) + if(!((*p>='A'&&*p<='Z')||(*p>='a'&&*p<='z')||(*p>='0'&&*p<='9')||*p=='.'||*p=='-'||*p=='_'||*p==' '))return false; + return true; +} +/* LG retail model identifiers: OLED + two-digit panel size + G4 series. */ +static inline bool calibration_is_g4(const char *model){ + return strlen(model)>=8 && !strncmp(model,"OLED",4) && model[4]>='0' && model[4]<='9' && model[5]>='0' && model[5]<='9' && model[6]=='G' && model[7]=='4'; +} +static inline CalibrationKind calibration_effective(void){ + if(calibration.kind!=CAL_AUTO)return calibration.kind; + if(calibration.count>=2 && tv_model[0] && !strcmp(calibration.model,tv_model))return CAL_CUSTOM; + return calibration_is_g4(tv_model)?CAL_G4:CAL_AVERAGE; +} +static inline bool calibration_parse(PlaybackJson *j,const char *text,Calibration *result){ + void *root=j->parse(text);if(!root)return false; + Calibration next=*result;bool valid=false; + for(unsigned k=0;k<4;k++)if(playback_is(j,root,"profile",calibration_key(k))){next.kind=k;valid=true;break;} + void *points=NULL; + if(j->type(root)==4 && j->get(root,"points",&points)){ + void *model=playback_field(j,root,"model"); + valid=valid && points && j->type(points)==5 && model && j->type(model)==6 && calibration_model_valid(j->string(model)); + size_t count=valid?j->length(points):0;valid=valid && count>=2 && count<=16; + if(valid){strcpy(next.model,j->string(model));next.count=count;memset(next.points,0,sizeof(next.points));} + for(size_t i=0;valid && iat(points,i),*position=playback_field(j,point,"position"),*region=playback_field(j,point,"region"); + valid=position && (j->type(position)==2 || j->type(position)==3) && region && j->type(region)==3; + if(valid){char *end;double x=strtod(j->string(position),&end);valid=end && !*end && isfinite(x) && x>=0 && x<4; + long r=strtol(j->string(region),&end,10);valid=valid && end && !*end && r>=0 && r<16 && (!i || x>next.points[i-1].position); + if(valid){next.points[i].position=x;next.points[i].region=r;} + } + } + } + if(next.kind==CAL_CUSTOM && next.count<2)valid=false; + j->put(root);if(valid)*result=next;return valid; +} +static inline bool calibration_save(const char *path,const Calibration *value){ + char temp[512],data[2048];int used=snprintf(data,sizeof(data),"{\"profile\":\"%s\"",calibration_key(value->kind)); + if(value->count){ + used+=snprintf(data+used,sizeof(data)-used,",\"model\":\"%s\",\"points\":[",value->model); + for(unsigned i=0;icount;i++)used+=snprintf(data+used,sizeof(data)-used,"%s{\"position\":%.12g,\"region\":%u}",i?",":"",value->points[i].position,value->points[i].region); + used+=snprintf(data+used,sizeof(data)-used,"]"); + } + used+=snprintf(data+used,sizeof(data)-used,"}\n"); + if(snprintf(temp,sizeof(temp),"%s.tmp",path)>=(int)sizeof(temp))return false; + int fd=open(temp,O_WRONLY|O_CREAT|O_EXCL|O_NOFOLLOW|O_CLOEXEC,0600);if(fd<0)return false; + bool ok=write(fd,data,used)==used && fsync(fd)==0;if(close(fd)!=0)ok=false; + if(ok)ok=rename(temp,path)==0; + if(!ok)unlink(temp); + return ok; +} +static inline void calibration_load(PlaybackJson *j,const char *path){ + int fd=open(path,O_RDONLY|O_NOFOLLOW|O_CLOEXEC);if(fd<0)return; + char data[2048];ssize_t n=read(fd,data,sizeof(data)-1);close(fd); + if(n<=0 || n==(ssize_t)sizeof(data)-1)return; + data[n]=0;calibration_parse(j,data,&calibration); +} +static inline void calibration_colors(const unsigned char input[16][3],unsigned char output[16][3]){ + memcpy(output,input,48);if(calibration_effective()!=CAL_AVERAGE)return; + unsigned sum[3]={0};for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)sum[c]+=input[i][c]; + for(unsigned i=0;i<16;i++)for(unsigned c=0;c<3;c++)output[i][c]=sum[c]/16; +} +#endif diff --git a/native/diagnostics.h b/native/diagnostics.h index f7df088..061fb2f 100644 --- a/native/diagnostics.h +++ b/native/diagnostics.h @@ -3,69 +3,76 @@ /* An independent nonblocking connection. Tests never send colors or acquire * capture. Work per timer iteration is bounded to keep output responsive. */ typedef enum {TEST_IDLE,TEST_CONNECT,TEST_SEND,TEST_LOGIN,TEST_LAYOUT,TEST_DONE,TEST_FAILED} TestStage; -static struct { +typedef struct { int fd;TestStage stage,after_send;TargetConfig target; uint64_t started,deadline,elapsed;unsigned leds; char request[384],error[128];size_t sent,used;char *response; -} connection_test={.fd=-1}; -static const char *test_stage_name(void){ + LedBlend *mapping; +} ReceiverProbe; +static ReceiverProbe connection_test={.fd=-1}; +static const char *probe_stage_name(const ReceiverProbe *probe){ static const char *names[]={"Not tested","Connecting","Sending request","Checking authentication","Reading LED layout","Passed","Failed"}; - return names[connection_test.stage]; + return names[probe->stage]; } -static bool test_busy(void){return connection_test.stage>=TEST_CONNECT && connection_test.stage<=TEST_LAYOUT;} -static void test_close(void){if(connection_test.fd>=0)close(connection_test.fd);connection_test.fd=-1;free(connection_test.response);connection_test.response=NULL;memset(connection_test.request,0,sizeof(connection_test.request));memset(connection_test.target.token,0,sizeof(connection_test.target.token));} -static void test_finish(const char *error,uint64_t now){ - test_close();connection_test.elapsed=now-connection_test.started; - connection_test.stage=error?TEST_FAILED:TEST_DONE; - snprintf(connection_test.error,sizeof(connection_test.error),"%s",error?error:""); +static bool probe_busy(const ReceiverProbe *probe){return probe->stage>=TEST_CONNECT && probe->stage<=TEST_LAYOUT;} +static void probe_close(ReceiverProbe *probe){if(probe->fd>=0)close(probe->fd);probe->fd=-1;free(probe->mapping);probe->mapping=NULL;free(probe->response);probe->response=NULL;memset(probe->request,0,sizeof(probe->request));memset(probe->target.token,0,sizeof(probe->target.token));} +static void probe_finish(ReceiverProbe *probe,const char *error,uint64_t now){ + probe_close(probe);probe->elapsed=now-probe->started; + probe->stage=error?TEST_FAILED:TEST_DONE; + snprintf(probe->error,sizeof(probe->error),"%s",error?error:""); } -static void test_request(bool login,uint64_t now){ - if(login)snprintf(connection_test.request,sizeof(connection_test.request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",connection_test.target.token); - else snprintf(connection_test.request,sizeof(connection_test.request),"{\"command\":\"serverinfo\"}\n"); - connection_test.sent=connection_test.used=0;connection_test.after_send=login?TEST_LOGIN:TEST_LAYOUT; - connection_test.stage=TEST_SEND;connection_test.deadline=now+3000; +static void probe_request(ReceiverProbe *probe,bool login,uint64_t now){ + if(login)snprintf(probe->request,sizeof(probe->request),"{\"command\":\"authorize\",\"subcommand\":\"login\",\"token\":\"%s\"}\n",probe->target.token); + else snprintf(probe->request,sizeof(probe->request),"{\"command\":\"serverinfo\"}\n"); + probe->sent=probe->used=0;probe->after_send=login?TEST_LOGIN:TEST_LAYOUT; + probe->stage=TEST_SEND;probe->deadline=now+3000; } -static bool test_begin(const TargetConfig *target,uint64_t now){ - if(test_busy())return false; - test_close();memset(&connection_test,0,sizeof(connection_test));connection_test.fd=-1; - connection_test.target=*target;connection_test.started=now; - connection_test.response=malloc(2*1024*1024+1); - if(!connection_test.response){test_finish("Cannot allocate connection test buffer",now);return true;} - connection_test.fd=target_open(target); - if(connection_test.fd<0){test_finish("Cannot open receiver connection",now);return true;} - connection_test.stage=TEST_CONNECT;connection_test.deadline=now+3000;return true; +static bool probe_begin(ReceiverProbe *probe,const TargetConfig *target,uint64_t now){ + if(probe_busy(probe))return false; + probe_close(probe);memset(probe,0,sizeof(*probe));probe->fd=-1; + probe->target=*target;probe->started=now; + probe->response=malloc(2*1024*1024+1); + if(!probe->response){probe_finish(probe,"Cannot allocate connection test buffer",now);return true;} + probe->fd=target_open(target); + if(probe->fd<0){probe_finish(probe,"Cannot open receiver connection",now);return true;} + probe->stage=TEST_CONNECT;probe->deadline=now+3000;return true; } -static void test_tick(PlaybackJson *json,uint64_t now){ - if(!test_busy())return; - if(now>=connection_test.deadline){test_finish("Receiver timed out",now);return;} - int fd=connection_test.fd; - if(connection_test.stage==TEST_CONNECT){ +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){test_finish("Receiver connection refused or unreachable",now);return;} - test_request(connection_test.target.token[0]!=0,now);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(connection_test.stage==TEST_SEND){ - size_t length=strlen(connection_test.request); - ssize_t n=send(fd,connection_test.request+connection_test.sent,length-connection_test.sent,MSG_NOSIGNAL); + 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){test_finish("Receiver closed the test connection",now);return;} - connection_test.sent+=(size_t)n; - if(connection_test.sent==length){connection_test.stage=connection_test.after_send;connection_test.deadline=now+3000;memset(connection_test.request,0,sizeof(connection_test.request));} + if(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-connection_test.used;if(available>65536)available=65536; - if(!available){test_finish("Receiver response exceeds 2 MiB",now);return;} - ssize_t n=recv(fd,connection_test.response+connection_test.used,available,0); + 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){test_finish("Receiver closed the test connection",now);return;} - connection_test.used+=(size_t)n;connection_test.response[connection_test.used]=0; - char *end=strchr(connection_test.response,'\n');if(!end)return;*end=0; - void *root=json->parse(connection_test.response);bool success=playback_true(json,root,"success");if(root)json->put(root); - if(!success){test_finish(connection_test.stage==TEST_LOGIN?"Receiver authentication failed":"Receiver rejected serverinfo; check authorization",now);return;} - if(connection_test.stage==TEST_LOGIN){test_request(false,now);return;} + if(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(connection_test.response,&mapping,&count)){test_finish("Receiver returned an invalid or empty LED layout",now);return;} - free(mapping);connection_test.leds=count;test_finish(NULL,now); + 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);} #endif diff --git a/native/handover.h b/native/handover.h index f076411..c9a1093 100644 --- a/native/handover.h +++ b/native/handover.h @@ -16,7 +16,12 @@ static bool (*call_once)(void*,const char*,const char*,Callback,void*,unsigned l static bool (*cancel_call)(void*,unsigned long,LSError*); static unsigned long piccap_token,media_token; static uint64_t piccap_deadline,media_deadline; -static unsigned clear_samples; +static unsigned clear_samples,piccap_fallbacks; +static uint64_t clear_since,unknown_since,piccap_disconnected_since; +#define CLEAR_STABLE_MS 2000 +#define UNKNOWN_GRACE_MS 1500 +#define PICCAP_LOSS_MS 5000 +#define PICCAP_FAILURE_COOLDOWN_MS 30000 static TargetConfig piccap_target; static char piccap_error[128]; static bool piccap_request(int command); @@ -38,7 +43,11 @@ static void handover_load(void){ static bool media_reply(void *h,void *m,void *ctx){ (void)h;(void)ctx;media_busy=false;media_token=0; protection=playback_classify(&playback_json,payload(m));protection_at=milliseconds(); - if(protection==PLAYBACK_CLEAR){if(clear_samples<2)clear_samples++;}else clear_samples=0; + if(protection==PLAYBACK_CLEAR){ + if(!clear_samples)clear_since=protection_at; + if(clear_samples<3)clear_samples++; + unknown_since=0; + }else {clear_samples=0;clear_since=0;if(protection==PLAYBACK_UNKNOWN && !unknown_since)unknown_since=protection_at;else if(protection==PLAYBACK_PROTECTED)unknown_since=0;} return true; } /* Commands: 0 status, 1 start, 2 stop, 3 getSettings, 4 setSettings. Confirm stopped status before VT reuse. */ @@ -50,10 +59,15 @@ static bool piccap_reply(void *h,void *m,void *ctx){ 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(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_owned=false;capture_source=SOURCE_HISTOGRAM;piccap_connected=false;piccap_disconnected_since=0; + if(handover_cooldown=media_deadline){LSError e;error_init(&e);cancel_call(service_handle,media_token,&e);error_free(&e);media_busy=false;media_token=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;} + if(media_busy && now>=media_deadline){LSError e;error_init(&e);cancel_call(service_handle,media_token,&e);error_free(&e);media_busy=false;media_token=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;} if(piccap_busy && now>=piccap_deadline){snprintf(piccap_error,sizeof(piccap_error),"PicCap request timed out");LSError e;error_init(&e);cancel_call(service_handle,piccap_token,&e);error_free(&e);piccap_busy=false;piccap_token=0;capture_source=SOURCE_PICCAP_STOPPING;next_piccap_probe=0;} if(handover_enabled && piccap_installed() && !retries.paused && playback_json.lib && !media_busy && now>=next_media_probe){ LSError e;error_init(&e); @@ -92,13 +106,20 @@ static bool handover_tick(void){ media_deadline=now+3000; error_free(&e);next_media_probe=now+1000; } - bool clear=handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked && - protection==PLAYBACK_CLEAR && clear_samples>=2 && now-protection_at<3000 && now>=handover_cooldown; - if(capture_source==SOURCE_HISTOGRAM && clear){ - piccap_target=target;piccap_error[0]=0;stop_capture();retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING; - if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+10000;} + if(protection==PLAYBACK_CLEAR && now-protection_at>=3000){protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=0;if(!unknown_since)unknown_since=now;} + bool session=handover_enabled && target_piccap_compatible(&target) && piccap_installed() && !retries.paused && !retries.blocked; + bool clear=session && protection==PLAYBACK_CLEAR && now-protection_at<3000; + bool stable=clear && clear_samples>=3 && now-clear_since>=CLEAR_STABLE_MS && now>=handover_cooldown; + if(capture_source==SOURCE_HISTOGRAM && stable){ + piccap_target=target;piccap_error[0]=0;piccap_disconnected_since=now;stop_capture();retries.due=0;piccap_owned=true;capture_source=SOURCE_PICCAP_STARTING; + if(!piccap_request(3)){capture_source=SOURCE_PICCAP_STOPPING;handover_cooldown=now+PICCAP_FAILURE_COOLDOWN_MS;} } - if(piccap_owned && (!clear || strcmp(piccap_target.host,target.host) || piccap_target.piccap_port!=target.piccap_port) && capture_source!=SOURCE_PICCAP_STOPPING){capture_source=SOURCE_PICCAP_STOPPING;next_piccap_probe=0;} + bool grace=protection==PLAYBACK_UNKNOWN && unknown_since && now-unknown_since=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; diff --git a/native/layout.h b/native/layout.h index b50a820..1f9b369 100644 --- a/native/layout.h +++ b/native/layout.h @@ -6,19 +6,18 @@ #include #include typedef struct {unsigned char a,b;unsigned weight;} LedBlend; -/* Measured G4 rim samples, independent of LED count and wiring order. */ -static const struct {double position;unsigned char region;} rim[]={ - {0.2078431373,7},{0.3176470588,3},{0.5490196078,11},{0.7529411765,12}, - {0.8392156863,13},{1.4941176471,10},{2.1843137255,9},{2.5490196078,1}, - {2.8666666667,5},{3.4,15} -}; +#include +#include +#include +#include "calibration.h" static double screen_perimeter(double x,double y){ double distances[]={y,1-x,1-y,x},positions[]={x,1+y,3-x,4-y};unsigned edge=0; for(unsigned i=1;i<4;i++)if(distances[i]=4?positions[edge]-4:positions[edge]; } static LedBlend layout_blend(double x,double y){ - double t=screen_perimeter(x,y);unsigned n=sizeof(rim)/sizeof(*rim),right=0; + const RimPoint *rim=calibration_effective()==CAL_CUSTOM?calibration.points:g4_rim; + double t=screen_perimeter(x,y);unsigned n=calibration_effective()==CAL_CUSTOM?calibration.count:sizeof(g4_rim)/sizeof(*g4_rim),right=0; while(right=next_layout_check){ + layout_probe_generation=calibration_generation;probe_begin(&layout_probe,&target,now);next_layout_check=now+LAYOUT_REFRESH_MS; + } +} +#endif diff --git a/native/service.c b/native/service.c index 0d1f3cf..243c7b8 100644 --- a/native/service.c +++ b/native/service.c @@ -114,7 +114,8 @@ static bool read_layout(void){ if(send_line("{\"command\":\"serverinfo\"}\n")==0){ while(used=0){if(clear_output && frames){char line[80];snprintf(line,sizeof(line),"{\"command\":\"clear\",\"priority\":%d}\n",PRIORITY);send_line(line);}close(output);output=-1;} @@ -137,6 +140,32 @@ static void release_capture(bool clear_output){ static void stop_capture(void){release_capture(true);} static bool respond(void *h,void *m,const char *s){if(!h||!m)return true;LSError e;error_init(&e);bool ok=reply(h,m,s,&e);error_free(&e);return ok;} #include "handover.h" +#include "layout_refresh.h" +static bool model_busy; +static unsigned long model_token; +static uint64_t model_deadline,next_model_probe; +static bool model_reply(void *h,void *m,void *ctx){ + (void)h;(void)ctx;model_busy=false;model_token=0; + void *root=playback_json.parse(payload(m)),*model=playback_field(&playback_json,root,"modelName"); + if(playback_true(&playback_json,root,"returnValue") && model && playback_json.type(model)==6 && calibration_model_valid(playback_json.string(model))){ + snprintf(tv_model,sizeof(tv_model),"%s",playback_json.string(model));calibration_generation++;layout_refresh_reset(milliseconds()); + } + if(root)playback_json.put(root); + return true; +} +static void model_tick(uint64_t now){ + if(model_busy && now>=model_deadline){LSError e;error_init(&e);cancel_call(service_handle,model_token,&e);error_free(&e);model_busy=false;model_token=0;} + if(!tv_model[0] && !model_busy && now>=next_model_probe){ + LSError e;error_init(&e);model_busy=call_once(service_handle,"luna://com.webos.service.tv.systemproperty/getSystemInfo","{\"keys\":[\"modelName\"]}",model_reply,NULL,&model_token,&e);error_free(&e);model_deadline=now+3000;next_model_probe=now+60000; + } +} +static bool set_calibration(void *h,void *m,void *ctx){ + (void)ctx;Calibration next=calibration; + if(!playback_json.lib || !calibration_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Select a valid profile; custom profiles need a model and 2-16 sorted rim points (position 0-4, region 0-15)\"}"); + if(!calibration_save(CALIBRATION_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Cannot save calibration profile\"}"); + calibration=next;calibration_generation++;layout_refresh_reset(milliseconds()); + return respond(h,m,"{\"returnValue\":true}"); +} static bool start_failure(void *h,void *m,const char *message){ 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"); @@ -182,7 +211,7 @@ static bool handover(void *h,void *m,void *ctx){ if(!playback_json.lib || geteuid()!=0)return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Root service and JSON-C are required\"}"); if(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;next_media_probe=0; + handover_enabled=enabled;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=0;next_media_probe=0; return respond(h,m,"{\"returnValue\":true}"); } static bool settings(void *h,void *m,void *ctx){ @@ -190,7 +219,7 @@ static bool settings(void *h,void *m,void *ctx){ if(!playback_json.lib || !target_parse(&playback_json,payload(m),&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Enter a valid IPv4/IPv6 address, integer port (1-65535), receiver and optional API token\"}"); if(!target_save(TARGET_PREF,&next))return respond(h,m,"{\"returnValue\":false,\"errorText\":\"Could not save receiver settings\"}"); bool active=capture || piccap_owned || retries.due; - stop_capture();test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0; + stop_capture();test_close();connection_test.stage=TEST_IDLE;target=next;last_error[0]=0;protection=PLAYBACK_UNKNOWN;clear_samples=0;clear_since=unknown_since=0; if(active && !retries.paused && !retries.blocked)retries.due=milliseconds(); return respond(h,m,"{\"returnValue\":true}"); } @@ -205,8 +234,11 @@ static bool status(void *h,void *m,void *ctx){ snprintf(endpoint,sizeof(endpoint),strchr(target.host,':')?"[%s]:%u":"%s:%u",target.host,target.port); for(size_t i=0;last_error[i] && n+2=32)safe[n++]=c;}safe[n]=0; uint64_t now=milliseconds(); - int used=snprintf(json,sizeof(json),"{\"returnValue\":true,\"piccapPort\":%u,\"connectionTest\":{\"state\":\"%s\",\"ledCount\":%u,\"elapsedMs\":%llu,\"error\":\"%s\"},\"lastOutputAgeMs\":%lld,\"retryInMs\":%llu,\"captureState\":\"%s\",\"piccapError\":\"%s\",",target.piccap_port,test_stage_name(),connection_test.leds,(unsigned long long)connection_test.elapsed,connection_test.error,last_output_at?(long long)(now-last_output_at):-1,(unsigned long long)(retries.due>now?retries.due-now:0),capture_source==SOURCE_PICCAP_STARTING?"Starting PicCap":capture_source==SOURCE_PICCAP_STOPPING?"Stopping PicCap":piccap_owned?"PicCap":capture?"Histogram":"Stopped",piccap_error); - used+=snprintf(json+used,sizeof(json)-used,"\"version\":\"0.9.0\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s,\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,endpoint,target.host,target.port,target.receiver,target.token[0]?"true":"false",target_piccap_compatible(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe); + int used=snprintf(json,sizeof(json),"{\"calibrationProfile\":\"%s\",\"effectiveCalibration\":\"%s\",\"tvModel\":\"%s\",\"customModel\":\"%s\",\"customPoints\":[",calibration_key(calibration.kind),calibration_key(calibration_effective()),tv_model,calibration.model); + for(unsigned i=0;inow?handover_cooldown-now:0)); + 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.10.0\",\"running\":%s,\"colorValid\":%s,\"frames\":%lu,\"captureRecoveries\":%u,\"lastRecoveryMs\":%llu,\"endpoint\":\"%s\",\"host\":\"%s\",\"port\":%u,\"receiver\":\"%s\",\"tokenSet\":%s,\"piccapTargetCompatible\":%s,\"priority\":%d,\"uid\":%u,\"drmVerified\":false,\"mappingVerified\":false,\"outputColor\":[%u,%u,%u],\"error\":\"%s\",\"regions\":[",(capture||capture_source==SOURCE_PICCAP)?"true":"false",color_valid?"true":"false",frames,capture_recoveries,(unsigned long long)last_recovery_ms,endpoint,target.host,target.port,target.receiver,target.token[0]?"true":"false",target_piccap_compatible(&target)?"true":"false",PRIORITY,(unsigned)geteuid(),sent_rgb[0],sent_rgb[1],sent_rgb[2],safe); 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]); @@ -214,7 +246,8 @@ static bool status(void *h,void *m,void *ctx){ } static int tick(void *unused){ (void)unused; - test_tick(&playback_json,milliseconds()); + uint64_t now=milliseconds(); + model_tick(now);test_tick(&playback_json,now);layout_refresh_tick(&playback_json,now,capture || capture_source==SOURCE_PICCAP); if(handover_tick())return 1; if(!capture){ retry_update(&retries,boot_enabled() && geteuid()==0,false,milliseconds()); @@ -257,14 +290,16 @@ static int tick(void *unused){ } for(int c=0;c<3;c++)sent_rgb[c]=(unsigned char)(aggregate[c]/16); unsigned char boosted_mean[3];boost_color(sent_rgb,boosted_mean);memcpy(sent_rgb,boosted_mean,3); + unsigned char mapped_rgb[16][3];calibration_colors(rgb,mapped_rgb); + interpolation_enabled=led_count && calibration_effective()!=CAL_AVERAGE; bool image_output=strcmp(target.receiver,"HyperHDR")==0; - size_t capacity=256+(size_t)led_count*12;char *line=image_output?spatial_image_command(rgb,PRIORITY):malloc(capacity); + size_t capacity=256+(size_t)led_count*12;char *line=image_output?spatial_image_command(mapped_rgb,PRIORITY):malloc(capacity); if(!line){set_error("Color buffer allocation failed");stop_capture();return 1;} if(!image_output){ size_t written=(size_t)snprintf(line,capacity,"{\"command\":\"color\",\"priority\":%d,\"duration\":1500,\"origin\":\"HyperDRM\",\"color\":[",PRIORITY); for(unsigned i=0;i +#include +#include "layout.h" +int main(void){ + (void)layout_from_json; + PlaybackJson json={0};assert(playback_json_init(&json)); + assert(calibration_effective()==CAL_AVERAGE); + strcpy(tv_model,"OLED65G42LW.DEUQLJP");assert(calibration_effective()==CAL_G4); + assert(!calibration_is_g4("OLED65G52LW") && !calibration_is_g4("LG G4")); + Calibration next=calibration; + const char *custom="{\"profile\":\"custom\",\"model\":\"OLED65C42LA\",\"points\":[{\"position\":0,\"region\":2},{\"position\":2,\"region\":10}]}"; + assert(calibration_parse(&json,custom,&next));calibration=next;calibration_generation++; + LedBlend b=layout_blend(.5,0);assert(b.a==2 && b.b==10 && b.weight>=16383 && b.weight<=16385); + const char *bad[]={"{}","{\"profile\":\"custom\",\"points\":null}","{\"profile\":\"other\"}","{\"profile\":\"custom\",\"model\":\"bad\\\"name\",\"points\":[]}","{\"profile\":\"custom\",\"model\":\"TV\",\"points\":[{\"position\":2,\"region\":1},{\"position\":1,\"region\":2}]}","{\"profile\":\"custom\",\"model\":\"TV\",\"points\":[{\"position\":0,\"region\":16},{\"position\":4,\"region\":2}]}"}; + for(unsigned i=0;i +#include +#include +#include "target.h" +#include "layout.h" +#include "diagnostics.h" +static unsigned led_count; +static LedBlend *led_blends; +#include "layout_refresh.h" +static void completed(unsigned count,unsigned generation){ + layout_probe.mapping=calloc(count,sizeof(LedBlend));assert(layout_probe.mapping); + for(unsigned i=0;i>4);actual[1]=(v[1]<<4)|(v[2]>>2);actual[2]=(v[2]<<6)|v[3];assert(!memcmp(expected,actual,3));free(command); puts("PASS: image command, exact neutral RGB base64, spatial edge differences and cached geometry");return 0; } diff --git a/package/packageinfo.json b/package/packageinfo.json index 0f922b7..76537ff 100644 --- a/package/packageinfo.json +++ b/package/packageinfo.json @@ -1 +1 @@ -{"id": "org.webosbrew.histogramhyperion", "version": "0.9.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"} +{"id": "org.webosbrew.histogramhyperion", "version": "0.10.0", "vendor": "Local development", "app": "org.webosbrew.histogramhyperion", "services": ["org.webosbrew.histogramhyperion.service"], "sourceUrl": "https://gitea.adnau.de/matze19999/HyperDRM"} diff --git a/profiles/README.md b/profiles/README.md new file mode 100644 index 0000000..335bd44 --- /dev/null +++ b/profiles/README.md @@ -0,0 +1,35 @@ +# TV model calibration profiles + +The built-in LG G4 profile is measured. Other models do not have verified spatial +profiles yet. Auto uses uniform average colors on an unknown model, unless an +imported custom profile matches its exact reported model identifier. + +To import measured data, select Custom in the TV app, paste the profile JSON, +and select Save profile. Auto subsequently uses the custom profile on that exact +model. One custom model profile is stored in the own service container. Importing +another replaces it; switching to Auto, G4 or Average preserves the imported data. + +```json +{ + "model": "EXACT-MODEL-IDENTIFIER-FROM-APP", + "points": [ + {"position": 0.25, "region": 7}, + {"position": 2.25, "region": 9} + ] +} +``` + +This two-point example describes the file format, not a calibrated TV model. +Use 2-16 actual measured points sorted by position. Region indices are 0-15. +The clockwise perimeter position starts at the top-left corner: top 0-1, right +1-2, bottom 2-3, left 3-4. The final point interpolates back to the first. + +Use the coordinate-ramp calibration video with full-screen playback, observe +the LG histogram region colors, and derive their measured perimeter positions. +Validate all four edges against the receiver LED output before sharing a model +profile. A model match alone does not establish correctness on another panel, +SoC or firmware; document that test context with contributed profiles. + +Profiles only control spatial region assignment. Saturation, brightness and +smoothing remain receiver settings. Runtime selection invalidates the generated +HyperHDR geometry and refreshes the Hyperion LED mapping without capture restart. diff --git a/service/histogram-service b/service/histogram-service index 7bdbfcb..e2ede4e 100644 Binary files a/service/histogram-service and b/service/histogram-service differ diff --git a/tests/layout-proxy.py b/tests/layout-proxy.py new file mode 100644 index 0000000..99dbe7c --- /dev/null +++ b/tests/layout-proxy.py @@ -0,0 +1,63 @@ +"""Test-only JSON relay with a controlled serverinfo LED layout. + +Only the configured TV may connect. Receiver settings remain unchanged. +The control file can select count/shift, an invalid layout or passthrough. +""" +import argparse +import json +from pathlib import Path +import socket +import socketserver + +parser = argparse.ArgumentParser() +parser.add_argument('--bind', required=True) +parser.add_argument('--port', type=int, default=19445) +parser.add_argument('--upstream', required=True) +parser.add_argument('--upstream-port', type=int, default=19444) +parser.add_argument('--control-file', required=True) +args = parser.parse_args() + + +class Relay(socketserver.StreamRequestHandler): + def handle(self): + if self.client_address[0] != args.upstream: + return + try: + with socket.create_connection((args.upstream, args.upstream_port), timeout=3) as remote: + incoming = remote.makefile('rb') + while line := self.rfile.readline(2 * 1024 * 1024): + command = json.loads(line) + remote.sendall(line) + reply = incoming.readline(2 * 1024 * 1024) + if not reply: + return + if command.get('command') == 'serverinfo': + control = json.loads(Path(args.control_file).read_text(encoding='utf-8')) + response = json.loads(reply) + if control.get('invalid'): + response['info']['leds'] = [] + elif 'count' in control: + count = control['count'] + if not isinstance(count, int) or not 1 <= count <= 16384: + return + shift = control.get('shift', 0) + response['info']['leds'] = [ + {'hmin': (i + shift) / count, 'hmax': (i + 1) / count, + 'vmin': 0, 'vmax': .05} + for i in range(count) + ] + reply = json.dumps(response).encode() + b'\n' + print(json.dumps({'serverinfo': control}), flush=True) + self.request.sendall(reply[:10]) + self.request.sendall(reply[10:]) + except (OSError, ValueError, KeyError): + pass + + +class Server(socketserver.ThreadingTCPServer): + daemon_threads = True + + +with Server((args.bind, args.port), Relay) as server: + print('Layout relay ready', flush=True) + server.serve_forever() diff --git a/tests/receiver-ui.test.js b/tests/receiver-ui.test.js index dccf20a..57a7e31 100644 --- a/tests/receiver-ui.test.js +++ b/tests/receiver-ui.test.js @@ -1,10 +1,10 @@ 'use strict'; const assert=require('assert'),fs=require('fs'),vm=require('vm'); -const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics']; +const ids=['host','port','piccapPort','receiver','token','clearToken','endpoint','handover','autostart','error','status','regions','start','stop','refresh','saveTarget','testConnection','testResult','diagnostics','profile','customEditor','customProfile','saveProfile','profileStatus']; const elements={}; ids.forEach(id=>elements[id]={value:'',textContent:'',disabled:false,checked:false,attributes:{},style:{},addEventListener(){},focus(){},appendChild(){},setAttribute(k,v){this.attributes[k]=v;},getAttribute(k){return this.attributes[k];}}); const requests=[]; -const status={returnValue:true,version:'0.9.0',host:'127.0.0.1',port:19444,piccapPort:19400,receiver:'HyperHDR',tokenSet:true,piccapInstalled:true,autostart:true,captureState:'Histogram',playbackProtection:'protected',connectionTest:{state:'Passed',ledCount:120,elapsedMs:300,error:''},uid:0}; +const status={returnValue:true,version:'0.9.0',host:'127.0.0.1',port:19444,piccapPort:19400,receiver:'HyperHDR',tokenSet:true,piccapInstalled:true,autostart:true,captureState:'Histogram',playbackProtection:'protected',connectionTest:{state:'Passed',ledCount:120,elapsedMs:300,error:''},uid:0,calibrationProfile:'auto',effectiveCalibration:'g4',tvModel:'OLED65G42LW.DEUQLJP',customModel:'',customPoints:[]}; class Bridge { call(uri,data){requests.push({uri,data:JSON.parse(data)});this.onservicecallback(JSON.stringify(uri.endsWith('/status')?status:{returnValue:true}));} cancel(){} @@ -21,4 +21,7 @@ elements.piccapPort.value='19446';elements.host.value='10.1.0.100';elements.toke const saved=requests.find(r=>r.uri.endsWith('/settings'));assert.strictEqual(saved.data.piccapPort,19446);assert.strictEqual(saved.data.host,'10.1.0.100');assert(!('token' in saved.data)); elements.testConnection.onclick();assert(requests.some(r=>r.uri.endsWith('/testConnection'))); assert.strictEqual(elements.token.value,'');assert(!elements.diagnostics.textContent.includes('fixture-token')); +elements.profile.onclick();assert(elements.profile.textContent.includes('LG G4'));elements.saveProfile.onclick();assert(requests.some(r=>r.uri.endsWith('/calibration')&&r.data.profile==='g4')); +context.profile='custom';context.showProfile();elements.customProfile.value='invalid';elements.saveProfile.onclick();assert(elements.error.textContent.includes('valid JSON')); +elements.customProfile.value=JSON.stringify({model:'OTHER-TV',points:[{position:0,region:1},{position:2,region:5}]});elements.saveProfile.onclick();assert(requests.some(r=>r.uri.endsWith('/calibration')&&r.data.model==='OTHER-TV'&&r.data.points.length===2)); console.log('PASS: receiver fields, port validation, saved-token retention, test control and diagnostics'); diff --git a/tests/remote-navigation.test.js b/tests/remote-navigation.test.js index 164ef9a..1cc26b0 100644 --- a/tests/remote-navigation.test.js +++ b/tests/remote-navigation.test.js @@ -30,6 +30,10 @@ key(39);assert.equal(doc.activeElement.id,'start');key(37);assert.equal(doc.acti buttons[2].tagName='INPUT';buttons[2].type='text';buttons[2].focus(); assert(!key(37));assert(!key(39));assert(!key(13));assert.equal(doc.activeElement.id,'refresh'); key(40);assert.equal(doc.activeElement.id,'autostart');key(38);assert.equal(doc.activeElement.id,'refresh'); +buttons[2].tagName='TEXTAREA';buttons[2].focus(); +assert(!key(37));assert(!key(39));assert(!key(13)); +key(40);assert.equal(doc.activeElement.id,'autostart'); +key(38);assert.equal(doc.activeElement.id,'refresh'); buttons[2].tagName='BUTTON'; buttons.forEach(button=>button.disabled=true);assert(key(39)); console.log('PASS: arrow navigation, wraparound, disabled controls, OK once per press, pointer handoff, focus recovery, Back preserved');