33%
44% Standalone classical `figure` (NOT a uifigure -- the companion owns the
55% only uifigure). Constructed by FastSenseCompanion.openTagStatusTable().
6- % Pulls the initial row set from TagRegistry, then refreshes only dirty
7- % rows when the companion's scanLiveTagUpdates_ calls markTagsDirty(keys).
6+ % Pulls the initial row set from TagRegistry, then refreshes rows via TWO
7+ % complementary mechanisms:
8+ % 1. Push-on-write: companion.scanLiveTagUpdates_ calls markTagsDirty(keys)
9+ % whenever sample counts grow (zero-cost when window is closed).
10+ % 2. Window-owned RefreshTimer_: ticks every RefreshPeriod_ seconds while
11+ % the window is open and re-queries every tracked tag. This guarantees
12+ % the table reflects reality even when the companion is NOT in Live
13+ % mode (e.g. user just wants to monitor activity without running the
14+ % full live pipeline). Quick task 260519-bs4 follow-up patch.
15+ %
16+ % The "Activity" column (between "Last updated" and "Samples") shows
17+ % "Live" when X(end) is within InactiveThresholdSeconds_ of the current
18+ % wall-clock time (using the same time-base conversion as
19+ % formatLastUpdated_ -- datenum or posixtime). Otherwise "Inactive".
820%
921% Lifecycle:
1022% w = TagStatusTableWindow();
11- % w.openWith(registry, theme, companion); % builds the figure, fills the table
23+ % w.openWith(registry, theme, companion); % builds the figure, fills the table, starts timer
1224% w.markTagsDirty({'press_a','temp_b'}); % rebuild only those rows; re-apply filter
1325% w.applyTheme(theme); % live theme switch
14- % w.close(); % programmatic close; fires DetachClosed
26+ % w.close(); % programmatic close; stops timer; fires DetachClosed
1527%
1628% Events fired:
1729% DetachClosed -- fired exactly once when the window closes (user X click,
3850 Registry_ = [] % TagRegistry handle (or class name placeholder)
3951 Theme_ = [] % resolved CompanionTheme struct
4052 Companion_ = [] % FastSenseCompanion handle (uialert parent + detach)
41- RowBuffer_ = cell(0 , 10 )
53+ RowBuffer_ = cell(0 , 11 )
4254 KeyToRow_ = [] % containers.Map(key -> row index into RowBuffer_)
4355 Listeners_ = {} % addlistener handles ; deleted in close ()
56+ RefreshTimer_ = [] % timer driving periodic re-query (window-owned ; 260519 - bs4 patch)
57+ RefreshErrCount_ = 0 % consecutive errors in onRefreshTick_ ; auto -stops at 2
58+ end
59+
60+ properties (Constant , Access = private )
61+ RefreshPeriod_ = 1.0 % seconds between RefreshTimer_ ticks
62+ InactiveThresholdSeconds_ = 300 % >= 5 min since last sample -> Activity = "Inactive"
4463 end
4564
4665 methods (Access = public )
4766
4867 function obj = TagStatusTableWindow()
49- obj.RowBuffer_ = cell(0 , 10 );
68+ obj.RowBuffer_ = cell(0 , 11 );
5069 obj.KeyToRow_ = containers .Map(' KeyType' , ' char' , ' ValueType' , ' double' );
5170 end
5271
@@ -122,19 +141,21 @@ function openWith(obj, registry, theme, companion)
122141 stripePair = obj .stripePairFromTheme_(t );
123142
124143 % --- Center uitable. ---
144+ % 11 columns: Activity is column 9 (between Last updated and Samples).
125145 obj.hTable_ = uitable(obj .hFig_, ...
126146 ' Units' , ' normalized' , ...
127147 ' Position' , [0.01 0.06 0.98 0.86 ], ...
128148 ' ColumnName' , {' Key' , ' Name' , ' Type' , ' Criticality' , ' Units' , ...
129- ' Latest' , ' Status' , ' Last updated' , ' Samples' , ' Labels' }, ...
130- ' ColumnWidth' , {130 , 200 , 75 , 80 , 60 , 90 , 80 , 140 , 70 , ' auto' }, ...
131- ' ColumnEditable' , false(1 , 10 ), ...
149+ ' Latest' , ' Status' , ' Last updated' , ' Activity' , ...
150+ ' Samples' , ' Labels' }, ...
151+ ' ColumnWidth' , {130 , 200 , 75 , 80 , 60 , 90 , 80 , 140 , 70 , 70 , ' auto' }, ...
152+ ' ColumnEditable' , false(1 , 11 ), ...
132153 ' RowName' , {}, ...
133154 ' FontName' , ' Menlo' , ...
134155 ' FontSize' , 10 , ...
135156 ' BackgroundColor' , stripePair , ...
136157 ' ForegroundColor' , t .ForegroundColor, ...
137- ' Data' , cell(0 , 10 ));
158+ ' Data' , cell(0 , 11 ));
138159
139160 % --- Footer "N tags" label. ---
140161 obj.hStatusLbl_ = uicontrol(obj .hFig_, ...
@@ -152,6 +173,11 @@ function openWith(obj, registry, theme, companion)
152173 obj .applyFilter_();
153174
154175 obj.IsOpen = true ;
176+
177+ % --- Start the window-owned refresh timer. ---
178+ % Independent of companion Live mode so Activity / Last updated
179+ % stay accurate even when the companion is idle. 260519-bs4 patch.
180+ obj .startRefreshTimer_();
155181 end
156182
157183 function markTagsDirty(obj , keys )
@@ -163,13 +189,14 @@ function markTagsDirty(obj, keys)
163189 if ischar(keys ); keys = {keys }; end
164190 if ~iscell(keys ); return ; end
165191 try
192+ nowSec = TagStatusTableWindow .nowSeconds_();
166193 changed = false ;
167194 for k = 1 : numel(keys )
168195 key = char(keys{k });
169196 if isempty(key ); continue ; end
170197 tag = obj .resolveTag_(key );
171198 if isempty(tag ); continue ; end
172- row = TagStatusTableWindow .buildRow_(tag );
199+ row = TagStatusTableWindow .buildRow_(tag , nowSec );
173200 if obj .KeyToRow_.isKey(key )
174201 idx = obj .KeyToRow_(key );
175202 obj .RowBuffer_(idx , : ) = row ;
@@ -262,7 +289,11 @@ function delete(obj)
262289 methods (Access = private )
263290
264291 function onCloseRequest_(obj )
265- % ONCLOSEREQUEST_ Order: drop listeners -> notify DetachClosed -> delete figure.
292+ % ONCLOSEREQUEST_ Order: stop+delete timer -> drop listeners -> notify DetachClosed -> delete figure.
293+ % --- Stop and delete the refresh timer BEFORE listener cleanup. ---
294+ % stop(t) then delete(t) order is required by the project's
295+ % cross-cutting engineering constraint (Phase 1018 lock).
296+ obj .stopRefreshTimer_();
266297 try
267298 for ii = 1 : numel(obj .Listeners_)
268299 try
@@ -297,7 +328,7 @@ function onCloseRequest_(obj)
297328
298329 function rebuildAll_(obj )
299330 % REBUILDALL_ Replace RowBuffer_ with one row per registered tag (sorted).
300- obj.RowBuffer_ = cell(0 , 10 );
331+ obj.RowBuffer_ = cell(0 , 11 );
301332 obj.KeyToRow_ = containers .Map(' KeyType' , ' char' , ' ValueType' , ' double' );
302333 try
303334 tags = TagRegistry .find(@(t ) true );
@@ -321,9 +352,10 @@ function rebuildAll_(obj)
321352 tags = tags(ord );
322353 % Preallocate the buffer up front.
323354 nTags = numel(tags );
324- obj.RowBuffer_ = cell(nTags , 10 );
355+ obj.RowBuffer_ = cell(nTags , 11 );
356+ nowSec = TagStatusTableWindow .nowSeconds_();
325357 for k = 1 : nTags
326- obj .RowBuffer_(k , : ) = TagStatusTableWindow .buildRow_(tags{k });
358+ obj .RowBuffer_(k , : ) = TagStatusTableWindow .buildRow_(tags{k }, nowSec );
327359 obj .KeyToRow_(keysSorted{k }) = k ;
328360 end
329361 end
@@ -362,16 +394,124 @@ function applyFilter_(obj)
362394 end
363395 end
364396
397+ function startRefreshTimer_(obj )
398+ % STARTREFRESHTIMER_ Create and start the window-owned refresh timer.
399+ % Independent of companion Live mode -- guarantees the table
400+ % re-queries every tag every RefreshPeriod_ seconds while open,
401+ % so Activity / Last updated stay accurate even when the
402+ % companion is idle. Wrapped in try/catch; failure to construct
403+ % the timer (e.g. on a stripped-down environment) is non-fatal:
404+ % the push-on-write path from scanLiveTagUpdates_ still works.
405+ % 260519-bs4 patch.
406+ obj.RefreshErrCount_ = 0 ;
407+ try
408+ if ~isempty(obj .RefreshTimer_) && isvalid(obj .RefreshTimer_)
409+ stop(obj .RefreshTimer_);
410+ delete(obj .RefreshTimer_);
411+ end
412+ % Unique name so orphan timers from crashed tests can be
413+ % discovered via timerfindall and cleaned up.
414+ tName = sprintf(' TagStatusTable-%s ' , randomTimerSuffix_());
415+ obj.RefreshTimer_ = timer( ...
416+ ' Name' , tName , ...
417+ ' Period' , obj .RefreshPeriod_, ...
418+ ' ExecutionMode' , ' fixedSpacing' , ...
419+ ' BusyMode' , ' drop' , ...
420+ ' TimerFcn' , @(~, ~) obj .onRefreshTick_());
421+ start(obj .RefreshTimer_);
422+ catch err
423+ warning(' FastSenseCompanion:tagStatusTableTimerStart' , ...
424+ ' TagStatusTableWindow: failed to start refresh timer: %s ' , ...
425+ err .message);
426+ obj.RefreshTimer_ = [];
427+ end
428+ end
429+
430+ function stopRefreshTimer_(obj )
431+ % STOPREFRESHTIMER_ Stop and delete the refresh timer in stop+delete order.
432+ try
433+ if ~isempty(obj .RefreshTimer_) && isvalid(obj .RefreshTimer_)
434+ try
435+ stop(obj .RefreshTimer_);
436+ catch
437+ end
438+ delete(obj .RefreshTimer_);
439+ end
440+ catch
441+ % Teardown must never throw.
442+ end
443+ obj.RefreshTimer_ = [];
444+ end
445+
446+ function onRefreshTick_(obj )
447+ % ONREFRESHTICK_ Re-query every tracked tag; only repaint when data changed.
448+ % Wrapped in try/catch; logs via `warning` rather than uialert
449+ % (uialert per tick would be noise-storm). After 2 consecutive
450+ % ticks throw, the timer self-stops to prevent log flooding.
451+ if ~obj .IsOpen
452+ return ;
453+ end
454+ try
455+ nowSec = TagStatusTableWindow .nowSeconds_();
456+ changed = false ;
457+ keys = obj .KeyToRow_.keys();
458+ for k = 1 : numel(keys )
459+ key = keys{k };
460+ if ~obj .KeyToRow_.isKey(key ); continue ; end
461+ idx = obj .KeyToRow_(key );
462+ tag = obj .resolveTag_(key );
463+ if isempty(tag ); continue ; end
464+ newRow = TagStatusTableWindow .buildRow_(tag , nowSec );
465+ oldRow = obj .RowBuffer_(idx , : );
466+ if ~isequal(newRow , oldRow )
467+ obj .RowBuffer_(idx , : ) = newRow ;
468+ changed = true ;
469+ end
470+ end
471+ if changed
472+ obj .applyFilter_();
473+ end
474+ obj.RefreshErrCount_ = 0 ; % reset on a clean tick
475+ catch err
476+ obj.RefreshErrCount_ = obj .RefreshErrCount_ + 1 ;
477+ warning(' FastSenseCompanion:tagStatusTableTickFailed' , ...
478+ ' TagStatusTableWindow refresh tick failed: %s ' , err .message);
479+ if obj .RefreshErrCount_ >= 2
480+ warning(' FastSenseCompanion:tagStatusTableTickAborted' , ...
481+ [' TagStatusTableWindow refresh timer self-stopped ' ...
482+ ' after 2 consecutive failures.' ]);
483+ obj .stopRefreshTimer_();
484+ end
485+ end
486+ end
487+
365488 end
366489
367490 methods (Static , Access = public )
368491
369- function row = buildRow_(tag )
370- % BUILDROW_ Return a 1x10 cell row describing tag's current status.
492+ function row = buildRow_(tag , nowSeconds )
493+ % BUILDROW_ Return a 1x11 cell row describing tag's current status.
371494 % Columns: Key, Name, Type, Criticality, Units, Latest, Status,
372- % Last updated, Samples, Labels.
495+ % Last updated, Activity, Samples, Labels.
496+ %
497+ % Inputs:
498+ % tag -- Tag handle (any subclass; tolerant of throws)
499+ % nowSeconds -- (optional) current wall-clock time as posix
500+ % seconds, used for the Activity column. When
501+ % omitted, TagStatusTableWindow.nowSeconds_() is
502+ % queried (slightly more expensive). Tests pass
503+ % an explicit value for determinism. 260519-bs4 patch.
504+ %
505+ % The Activity column is "Live" when X(end) is within
506+ % InactiveThresholdSeconds_ (5 minutes) of nowSeconds in the same
507+ % time base, else "Inactive". Empty / unconvertible / future X
508+ % defensively renders "Inactive".
509+ %
373510 % Never throws -- a tag whose getXY/valueAt fails renders em-dash
374- % placeholders for the dynamic columns.
511+ % placeholders for the dynamic columns AND "Inactive" for Activity.
512+ if nargin < 2 || isempty(nowSeconds )
513+ nowSeconds = TagStatusTableWindow .nowSeconds_();
514+ end
375515 em = char(8212 );
376516 key = ' ' ;
377517 name = ' ' ;
@@ -396,6 +536,7 @@ function applyFilter_(obj)
396536 latestTxt = em ;
397537 statusTxt = em ;
398538 lastUpdatedTxt = em ;
539+ activityTxt = ' Inactive' ;
399540 samplesTxt = ' 0' ;
400541
401542 try
@@ -409,9 +550,11 @@ function applyFilter_(obj)
409550 elseif isnumeric(Y ) && isfinite(Y(end ))
410551 latestTxt = formatNumber_(Y(end ));
411552 end
412- % --- Last updated ---
553+ % --- Last updated + Activity ---
413554 if isnumeric(X ) && isfinite(X(end ))
414555 lastUpdatedTxt = formatLastUpdated_(X(end ));
556+ activityTxt = computeActivity_(X(end ), nowSeconds , ...
557+ TagStatusTableWindow .InactiveThresholdSeconds_);
415558 end
416559 % --- Status (kind-aware) ---
417560 switch kind
@@ -447,7 +590,27 @@ function applyFilter_(obj)
447590 end
448591
449592 row = {key , name , typeLabel , crit , units , ...
450- latestTxt , statusTxt , lastUpdatedTxt , samplesTxt , labelStr };
593+ latestTxt , statusTxt , lastUpdatedTxt , activityTxt , ...
594+ samplesTxt , labelStr };
595+ end
596+
597+ function s = nowSeconds_()
598+ % NOWSECONDS_ Return current wall-clock time as posix seconds.
599+ % Used as the reference for the Activity column. Posix-seconds
600+ % is chosen because it composes cleanly with both posixtime
601+ % (s > 1e9) and datenum (s > 7e5) X bases via computeActivity_.
602+ % Falls back to 0 if datetime/posixtime are not available, in
603+ % which case all rows render "Inactive" (defensive). 260519-bs4.
604+ try
605+ s = posixtime(datetime(' now' ));
606+ catch
607+ try
608+ % Octave fallback: compute posix from now() (datenum).
609+ s = (now - datenum(1970 , 1 , 1 )) * 86400 ;
610+ catch
611+ s = 0 ;
612+ end
613+ end
451614 end
452615
453616 function out = filterRows_(rows , query )
@@ -533,3 +696,51 @@ function applyFilter_(obj)
533696 % Keep numeric fallback.
534697 end
535698end
699+
700+ function s = computeActivity_(xLast , nowSec , thresholdSec )
701+ % COMPUTEACTIVITY_ Return 'Live' or 'Inactive' based on xLast vs nowSec.
702+ % Time-base inference mirrors InspectorPane.formatXTick_:
703+ % xLast > 1e9 -> posixtime seconds (compare directly to nowSec)
704+ % xLast > 7e5 -> MATLAB datenum days (convert to posix seconds)
705+ % else -> "seconds-since-something" we cannot anchor; Inactive.
706+ % Defensive cases: NaN / non-finite / future timestamp -> Inactive.
707+ % 260519-bs4 patch.
708+ s = ' Inactive' ;
709+ if ~isnumeric(xLast ) || ~isscalar(xLast ) || ~isfinite(xLast )
710+ return ;
711+ end
712+ if ~isnumeric(nowSec ) || ~isscalar(nowSec ) || ~isfinite(nowSec ) || nowSec <= 0
713+ return ;
714+ end
715+ xPosix = NaN ;
716+ if xLast > 1e9
717+ xPosix = xLast ;
718+ elseif xLast > 7e5
719+ % datenum days -> posix seconds.
720+ xPosix = (xLast - datenum(1970 , 1 , 1 )) * 86400 ;
721+ end
722+ if ~isfinite(xPosix )
723+ return ;
724+ end
725+ deltaSec = nowSec - xPosix ;
726+ % Negative delta = future timestamp (clock skew or test fixture);
727+ % treat defensively as Inactive.
728+ if deltaSec < 0
729+ return ;
730+ end
731+ if deltaSec < thresholdSec
732+ s = ' Live' ;
733+ end
734+ end
735+
736+ function s = randomTimerSuffix_()
737+ % RANDOMTIMERSUFFIX_ Short unique suffix for the refresh timer name.
738+ % Used so multiple concurrent windows / orphans from crashed tests can
739+ % be discovered via `timerfindall('Name','TagStatusTable-*')`.
740+ try
741+ s = char(java .util.UUID.randomUUID().toString());
742+ catch
743+ % Fallback: timestamp + random digits (no Java).
744+ s = sprintf(' %.0f -%d ' , now * 86400 , randi(1e6 ));
745+ end
746+ end
0 commit comments