Skip to content

Commit 43d2d3b

Browse files
committed
feat(quick-260519-bs4-03): add Activity column + window-owned refresh timer (user feedback)
User feedback after first verification: the Tag Status window must keep "Latest" / "Last updated" accurate even when companion is NOT in Live mode, and tags with no update in the last 5 minutes should render as Inactive. Changes (all scoped to TagStatusTableWindow and its two test files; no changes to FastSenseCompanion.m or the existing push-on-write hook): - New "Activity" column at index 9 (between "Last updated" and "Samples"). Values: "Live" if (now - X(end)) < 300s in posixtime, else "Inactive". Empty / NaN / unanchored / future X defensively renders "Inactive". Time-base inference mirrors InspectorPane.formatXTick_ (posixtime >1e9, datenum >7e5). - New window-owned RefreshTimer_ (1s period, fixedSpacing, BusyMode='drop', unique name "TagStatusTable-<UUID>"). Starts in openWith after IsOpen=true; stopped+deleted in onCloseRequest_ BEFORE Listeners cleanup. Callback in try/catch; logs via warning (not uialert); self-stops after 2 consecutive failures to prevent log noise storms. Independent of companion Live mode -- guarantees Activity/Last updated stay accurate even when companion is idle. - buildRow_(tag) -> buildRow_(tag, nowSeconds) -- the nowSeconds parameter makes the static helper pure/unit-testable for the Activity column. Backward-compatible: nargin<2 falls back to TagStatusTableWindow.nowSeconds_. - RowBuffer_ / table data widened to 11 cols. All existing test assertions updated for the new column positions (Samples now at idx 10, Labels at 11). - The original FastSenseCompanion.scanLiveTagUpdates_ -> markStatusTableDirty_ push path is unchanged; both mechanisms now run in parallel. Tests: - test_companion_tag_status_table.m: 11 existing + 5 new (Activity Live / Inactive across posix/datenum/empty/future/filter regression) = 16/16 pass. - TestTagStatusTableWindow.m: 7 existing + 2 new (Activity flip without Live mode, RefreshTimer_ stopped+deleted on close via timerfindall sweep) = 9/9 pass. - TestFastSenseCompanion regression: 64/64 pass. - checkcode / mh_lint / mh_metric: clean (informational notices on now/datenum match codebase convention in InspectorPane and LiveLogPane).
1 parent e8a1be5 commit 43d2d3b

3 files changed

Lines changed: 437 additions & 45 deletions

File tree

libs/FastSenseCompanion/TagStatusTableWindow.m

Lines changed: 232 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,27 @@
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,
@@ -38,15 +50,22 @@
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
535698
end
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

Comments
 (0)