diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 83368cf1..4fe65485 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -9,6 +9,7 @@
- ✅ **v2.0 Tag-Based Domain Model** — Phases 1004-1011 (shipped 2026-04-17)
- 📋 **v2.1 Tag-API Tech Debt Cleanup** — Phases 1012-1017 (carry-forward, parallel — not active)
- ✅ **v3.0 FastSense Companion** — Phases 1018-1023 + 1023.1 gap closure (shipped 2026-04-30)
+- ✅ **v3.1 Plant Log Integration** — Phases 1034-1038 (shipped 2026-05-19; phases renumbered from 1029-1033 on merge to resolve collision with parallel v4.0 development)
- 🚧 **Pending milestone** — Phases 1025-1028 (promoted from backlog 2026-05-08, awaiting milestone scoping; 1024 closed via quick task 260508-d7k; 1025/1026 substantially addressed via quick tasks 260508-d8y/260508-das)
- 🚧 **v4.0 Multi-User LAN Concurrency** — Phases 1029-1033 (active, started 2026-05-13)
@@ -25,6 +26,21 @@
+
+✅ v3.1 Plant Log Integration (Phases 1034-1038) — SHIPPED 2026-05-19
+
+- [x] Phase 1034: Plant Log Storage Foundation (3/3 plans) — completed 2026-05-13 (originally Phase 1029)
+- [x] Phase 1035: CSV/XLSX Import + Mapping Dialog (3/3 plans) — completed 2026-05-13 (originally Phase 1030)
+- [x] Phase 1036: Live Tail + Slider Preview Overlay (3/3 plans) — completed 2026-05-14 (originally Phase 1031)
+- [x] Phase 1037: Per-Widget Plant Log Overlay (3/3 plans) — completed 2026-05-19 (originally Phase 1032)
+- [x] Phase 1038: Dashboard + Companion Integration & Serialization (3/3 plans) — completed 2026-05-19 (originally Phase 1033)
+
+Note: v3.1 was developed in parallel with v4.0 in a separate worktree and chose phase numbers 1029-1033 before learning v4.0 had already claimed them on main. The phases were renumbered to 1034-1038 on merge. Original phase numbers are preserved in commit messages (`feat(1029-01): ...`, etc.) and in the milestone archive (`milestones/v3.1-ROADMAP.md`).
+
+Full details: [milestones/v3.1-ROADMAP.md](milestones/v3.1-ROADMAP.md)
+
+
+
🚧 Pending milestone (Phases 1025-1028) — promoted from backlog 2026-05-08
@@ -133,6 +149,11 @@ Full details: [milestones/v3.0-ROADMAP.md](milestones/v3.0-ROADMAP.md)
| 1031. EventLog + EventStore rollback-mode migration | v4.0 | 4/4 | Complete | 2026-05-14 |
| 1032. Single-Source MonitorTag Events + ack workflow | v4.0 | 5/5 | Complete | 2026-05-14 |
| 1033. Companion Integration + Acceptance Test | v4.0 | 4/4 | Complete | 2026-05-14 |
+| 1034. Plant Log Storage Foundation | v3.1 | 3/3 | Complete | 2026-05-13 |
+| 1035. CSV/XLSX Import + Mapping Dialog | v3.1 | 3/3 | Complete | 2026-05-13 |
+| 1036. Live Tail + Slider Preview Overlay | v3.1 | 3/3 | Complete | 2026-05-14 |
+| 1037. Per-Widget Plant Log Overlay | v3.1 | 3/3 | Complete | 2026-05-19 |
+| 1038. Dashboard + Companion Integration & Serialization | v3.1 | 3/3 | Complete | 2026-05-19 |
## Phase Details (v4.0 Multi-User LAN Concurrency)
diff --git a/demo/industrial_plant/run_demo.m b/demo/industrial_plant/run_demo.m
index db3806ce..09cf02e9 100644
--- a/demo/industrial_plant/run_demo.m
+++ b/demo/industrial_plant/run_demo.m
@@ -16,12 +16,15 @@
% ctx - struct with fields:
% writerTimer - IndustrialPlantDataGen MATLAB timer (running)
% pipeline - LiveTagPipeline (running)
-% engine - [] (plan 02 populates this with a DashboardEngine)
+% engine - DashboardEngine handle (live, populated by buildDashboard)
% companion - FastSenseCompanion handle (or [] when 'Companion'=false)
% store - EventStore wired into every MonitorTag
% plantHealthKey - 'plant.health' (top-level rollup)
% rawDir - absolute path to demo/industrial_plant/data/raw
% tagsDir - absolute path to demo/industrial_plant/data/tags
+% plantLogPath - absolute path to the generated plant_log.csv (or
+% '' if seeding/attaching failed; see warning
+% run_demo:plantLogAttachFailed)
%
% Teardown:
% teardownDemo(ctx);
@@ -40,7 +43,8 @@
% teardownDemo(ctx);
%
% See also: plantConfig, registerPlantTags, makeDataGenerator,
-% startLivePipeline, teardownDemo, TagRegistry, LiveTagPipeline.
+% startLivePipeline, seedPlantLog, teardownDemo,
+% TagRegistry, LiveTagPipeline.
here = fileparts(mfilename('fullpath'));
@@ -107,6 +111,7 @@
'plantHealthKey', plantHealthKey, ...
'rawDir', rawDir, ...
'tagsDir', tagsDir, ...
+ 'plantLogPath', '', ...
'stressMode', stressMode, ...
'fleetTagKeys', {fleetTagKeys});
@@ -124,6 +129,23 @@
ctx.companion = [];
end
+ % Phase 1033 / milestone v3.1 -- seed a synthetic plant log and
+ % attach it to the dashboard so the slider preview + per-widget
+ % overlay are exercised end-to-end. Best-effort: a failure here
+ % must not crash the demo bootstrap (dashboard + writer + pipeline
+ % keep running so the rest of the demo stays usable).
+ try
+ ctx.plantLogPath = seedPlantLog(rawDir, plantConfig());
+ if ~isempty(ctx.engine) && isvalid(ctx.engine)
+ ctx.engine.attachPlantLog(ctx.plantLogPath);
+ end
+ catch err
+ warning('run_demo:plantLogAttachFailed', ...
+ 'Seed/attach plant log failed: %s (demo continues without plant log)', ...
+ err.message);
+ ctx.plantLogPath = '';
+ end
+
% Phase 1023.1 cross-phase fix: re-bind the dashboard figure's
% CloseRequestFcn with the now-complete ctx. buildDashboard set the
% callback when ctx.companion was still [], so the closure captured
diff --git a/demo/industrial_plant/seedPlantLog.m b/demo/industrial_plant/seedPlantLog.m
new file mode 100644
index 00000000..a3133682
--- /dev/null
+++ b/demo/industrial_plant/seedPlantLog.m
@@ -0,0 +1,190 @@
+function plantLogPath = seedPlantLog(rawDir, cfg)
+%SEEDPLANTLOG Generate a synthetic plant log CSV for the industrial plant demo.
+% plantLogPath = seedPlantLog(rawDir, cfg) writes ~30 deterministic
+% operator-log entries to data/raw/plant_log.csv with timestamps spread
+% across the last 7 days plus 3 entries in the recent past (now-30s,
+% now-15s, now+0s) so the live-tail demo immediately has fresh-looking
+% entries to display.
+%
+% The CSV columns are:
+% Timestamp,Message,Unit,Shift,Operator
+%
+% - Timestamp uses 'yyyy-MM-dd HH:mm:ss' (PlantLogReader auto-detect format).
+% - Message is the free-text operator note.
+% - Unit values are drawn from cfg.Subsystems ({'FeedLine','Reactor','Cooling'})
+% plus 'ALL' for plant-wide entries.
+% - Shift is 'A' | 'B' | 'C'.
+% - Operator is a small name pool.
+%
+% The function reseeds RNG to 1015 at entry and restores the previous RNG
+% state at exit (matching seedHistory.m's idiom). Determinism + state
+% restore lets repeated run_demo() calls produce byte-identical CSVs
+% (modulo the now-relative anchor timestamp).
+%
+% Inputs:
+% rawDir - char, absolute path to demo/industrial_plant/data/raw (must exist).
+% cfg - struct returned by plantConfig() -- uses cfg.Subsystems.
+%
+% Output:
+% plantLogPath - char, absolute path to the generated CSV.
+%
+% See also: seedHistory, plantConfig, run_demo, PlantLogReader.
+
+ % --- Input validation -----------------------------------------------
+ if ~ischar(rawDir) && ~(isstring(rawDir) && isscalar(rawDir))
+ error('IndustrialPlant:invalidRawDir', ...
+ 'rawDir must be a char or scalar string.');
+ end
+ rawDir = char(rawDir);
+ if ~exist(rawDir, 'dir')
+ error('IndustrialPlant:rawDirMissing', ...
+ 'rawDir does not exist: %s', rawDir);
+ end
+ if ~isstruct(cfg)
+ error('IndustrialPlant:invalidCfg', ...
+ 'cfg must be a struct (plantConfig() output).');
+ end
+
+ % --- Seed RNG, restore on exit (matches seedHistory.m idiom) --------
+ prevRng = rng(1015, 'twister');
+ cleanup = onCleanup(@() rng(prevRng));
+
+ % --- Build entry pool ------------------------------------------------
+ % Each entry: offsetSeconds (relative to now()) + message + unit + shift + operator.
+ % First 30 entries spread across the last 7 days at shift-start times
+ % (06:00, 14:00, 22:00) and an early-morning maintenance window (02:30);
+ % final 3 entries land in the recent past so live-tail picks them up.
+ entries = buildEntries_(cfg);
+
+ % --- Write CSV via fprintf (cross-runtime, MATLAB + Octave 7+) ------
+ % writetable's 'Size'+'VariableTypes' form is MATLAB-only on some
+ % Octave builds; fprintf is the safe lowest-common-denominator.
+ plantLogPath = fullfile(rawDir, 'plant_log.csv');
+ nowRef = now();
+
+ % Sort entries by offsetSeconds ASC so timestamps land chronologically
+ % in the CSV (PlantLogStore dedup tolerates out-of-order but ordered
+ % is the canonical state we want the live-tail tail to read).
+ [~, order] = sort([entries.offsetSeconds]);
+ entries = entries(order);
+
+ fid = fopen(plantLogPath, 'w');
+ if fid == -1
+ error('IndustrialPlant:writeFailed', ...
+ 'Could not open %s for writing.', plantLogPath);
+ end
+ closer = onCleanup(@() fclose(fid));
+ fprintf(fid, 'Timestamp,Message,Unit,Shift,Operator\n');
+ for k = 1:numel(entries)
+ e = entries(k);
+ ts = datestr(nowRef + e.offsetSeconds/86400, 'yyyy-mm-dd HH:MM:SS'); %#ok
+ % Quote message field (may contain commas/colons); other fields
+ % are short alphanum so unquoted is fine.
+ fprintf(fid, '%s,"%s",%s,%s,%s\n', ts, e.message, e.unit, e.shift, e.operator);
+ end
+end
+
+function entries = buildEntries_(cfg)
+ %BUILDENTRIES_ Construct the 33-entry plant-log pool.
+ % First 30 entries: shift-pattern times spread over 7 days. Final 3
+ % entries: near-now (-30s, -15s, 0s) so the live-tail demo has fresh
+ % content as soon as the dashboard renders.
+ %
+ % Unit values use the 4-element set [{'ALL'}, cfg.Subsystems(:)']
+ % directly so the demo's subsystem nomenclature is the single source
+ % of truth (changing cfg.Subsystems propagates to seedPlantLog).
+
+ units = [{'ALL'}, cfg.Subsystems(:)']; %#ok referenced via literals below
+
+ % Shift-start anchor times within a day (HH * 3600 + MM * 60 + SS):
+ % 06:00 -> 21600s
+ % 14:00 -> 50400s
+ % 22:00 -> 79200s
+ % 02:30 -> 9000s (overnight maintenance)
+ shiftA = 21600;
+ shiftB = 50400;
+ shiftC = 79200;
+ maint = 9000;
+
+ % Helper to compute an offsetSeconds: secondsIntoDay - daysAgo * 86400.
+ % Day 0 is "today"; negative offsetSeconds = past.
+ secOf = @(daysAgo, secondsIntoDay) -(daysAgo * 86400) + (secondsIntoDay - 86400);
+ % Explanation: relative to now (= 86400s offset within today), an event
+ % at secondsIntoDay of (today - daysAgo) sits at:
+ % (secondsIntoDay) + (-daysAgo - 0) * 86400 - 86400
+ % which simplifies above. The result is strictly <= 0 for any
+ % daysAgo >= 0 and secondsIntoDay <= 86400.
+
+ % Build the 30-entry historical pool (shift-pattern times across days 0..6).
+ % Mix shift-starts with overnight maintenance entries for variety.
+ rows = { ...
+ % daysAgo secondsIntoDay message unit shift operator
+ 6, shiftA, 'Operator Mehta starting morning shift, all systems nominal', 'ALL', 'A', 'Mehta'; ...
+ 6, shiftB, 'Routine maintenance: cooling pump filter changed', 'Cooling', 'B', 'Yamamoto'; ...
+ 6, shiftC, 'Reactor heated to 160C setpoint', 'Reactor', 'A', 'Patel'; ...
+ 5, maint, 'Feedline pressure alarm cleared', 'FeedLine', 'C', 'Davis'; ...
+ 5, shiftA, 'Batch B-2381 started', 'Reactor', 'B', 'Patel'; ...
+ 5, shiftB, 'Batch B-2381 complete, 1843 L yield', 'Reactor', 'B', 'Patel'; ...
+ 5, shiftC, 'Shift handover: Davis -> Patel, no anomalies reported', 'ALL', 'A', 'Patel'; ...
+ 4, maint, 'Heat exchanger fouling suspected, cleaning scheduled', 'Cooling', 'A', 'Yamamoto'; ...
+ 4, shiftB, 'Reactor pressure spike at 14:32 acknowledged by operator Chen', 'Reactor', 'B', 'Chen'; ...
+ 4, shiftC, 'Feedline valve V-117 replaced with conditioning unit', 'FeedLine', 'C', 'Davis'; ...
+ 3, shiftA, 'Cooling loop flow rate adjusted to 95 L/min', 'Cooling', 'A', 'Yamamoto'; ...
+ 3, shiftA + 1800, 'Pre-shift safety briefing complete', 'ALL', 'A', 'Mehta'; ...
+ 3, shiftB, 'Reactor mode transition: heating -> running', 'Reactor', 'B', 'Chen'; ...
+ 3, shiftC, 'Inlet temperature sensor calibration verified', 'Cooling', 'A', 'Yamamoto'; ...
+ 2, shiftA, 'Feedline pressure transient observed during startup', 'FeedLine', 'A', 'Patel'; ...
+ 2, shiftB, 'Emergency stop test (drill) completed successfully', 'ALL', 'B', 'Mehta'; ...
+ 2, shiftC, 'Reactor RPM trending nominal, no action required', 'Reactor', 'C', 'Davis'; ...
+ 2, maint, 'Cooling tower fan cycled per maintenance schedule', 'Cooling', 'C', 'Yamamoto'; ...
+ 1, shiftA, 'Batch B-2382 started', 'Reactor', 'A', 'Patel'; ...
+ 1, shiftA + 600, 'Feedline strainer inspection: clean', 'FeedLine', 'A', 'Davis'; ...
+ 1, shiftB, 'Reactor temperature setpoint changed to 165C per recipe revision','Reactor', 'B', 'Chen'; ...
+ 1, shiftC, 'Night shift quiet period, monitoring only', 'ALL', 'C', 'Davis'; ...
+ 0, maint, 'Cooling water pH within spec (7.4)', 'Cooling', 'A', 'Yamamoto'; ...
+ 0, shiftA, 'Feedline flow stable at 122 L/min', 'FeedLine', 'B', 'Patel'; ...
+ 0, shiftA + 1200, 'Reactor agitator vibration spike investigated, within tolerance','Reactor', 'A', 'Chen'; ...
+ 0, shiftA + 2400, 'Batch B-2382 complete, 1798 L yield', 'Reactor', 'B', 'Patel'; ...
+ 0, shiftA + 3000, 'Shift handover: Patel -> Mehta, batch B-2383 queued', 'ALL', 'A', 'Mehta'; ...
+ 0, shiftA + 3600, 'Cooling out-temp briefly exceeded 50C, alarm cleared after 12s', 'Cooling', 'B', 'Yamamoto'; ...
+ 0, shiftA + 4200, 'Feedline valve V-118 actuator stroke time verified', 'FeedLine', 'A', 'Davis'; ...
+ 0, shiftA + 4800, 'Reactor pressure trending up -- operator confirms expected', 'Reactor', 'B', 'Chen' ...
+ };
+
+ nHist = size(rows, 1);
+ entries = repmat(struct( ...
+ 'offsetSeconds', 0, ...
+ 'message', '', ...
+ 'unit', '', ...
+ 'shift', '', ...
+ 'operator', ''), 1, nHist + 3);
+
+ for k = 1:nHist
+ daysAgo = rows{k, 1};
+ secondsIntoDay = rows{k, 2};
+ entries(k).offsetSeconds = secOf(daysAgo, secondsIntoDay);
+ entries(k).message = rows{k, 3};
+ entries(k).unit = rows{k, 4};
+ entries(k).shift = rows{k, 5};
+ entries(k).operator = rows{k, 6};
+ end
+
+ % --- 3 entries near now() so the live-tail demo shows fresh content --
+ entries(nHist + 1).offsetSeconds = -30;
+ entries(nHist + 1).message = 'Live-tail entry: 30s ago -- routine check, all green';
+ entries(nHist + 1).unit = 'ALL';
+ entries(nHist + 1).shift = 'A';
+ entries(nHist + 1).operator = 'Mehta';
+
+ entries(nHist + 2).offsetSeconds = -15;
+ entries(nHist + 2).message = 'Live-tail entry: 15s ago -- feedline pressure 5.1 bar nominal';
+ entries(nHist + 2).unit = 'FeedLine';
+ entries(nHist + 2).shift = 'A';
+ entries(nHist + 2).operator = 'Davis';
+
+ entries(nHist + 3).offsetSeconds = 0;
+ entries(nHist + 3).message = 'Live-tail entry: now -- beginning fresh observation window';
+ entries(nHist + 3).unit = 'ALL';
+ entries(nHist + 3).shift = 'A';
+ entries(nHist + 3).operator = 'Mehta';
+end
diff --git a/install.m b/install.m
index d9b887ae..5f55a234 100644
--- a/install.m
+++ b/install.m
@@ -22,7 +22,8 @@
% libs/Dashboard — widget-based dashboard engine
% libs/WebBridge — browser-based visualization bridge
% libs/FastSenseCompanion — companion navigator app
-% libs/Help — Wiki Browser + WikiPageIndex (Phase 1034)
+% libs/PlantLog — plant-log entry storage (CSV/XLSX import target; v3.1)
+% libs/Help — Wiki Browser + WikiPageIndex (v4.0 Phase 1034)
% examples/ — runnable example scripts
% benchmarks/ — performance benchmarks
% tests/ — test suites
@@ -56,6 +57,7 @@
addpath(fullfile(root, 'libs', 'Dashboard'));
addpath(fullfile(root, 'libs', 'WebBridge'));
addpath(fullfile(root, 'libs', 'FastSenseCompanion'));
+ addpath(fullfile(root, 'libs', 'PlantLog'));
addpath(fullfile(root, 'libs', 'Concurrency'));
addpath(fullfile(root, 'libs', 'Help'));
diff --git a/libs/Dashboard/DashboardEngine.m b/libs/Dashboard/DashboardEngine.m
index 07f54b5b..1086b497 100644
--- a/libs/Dashboard/DashboardEngine.m
+++ b/libs/Dashboard/DashboardEngine.m
@@ -102,6 +102,47 @@
EventMarkerColorsCache_ = [] % last uColors (Nx3) passed to setEventMarkers
PreviewLinesCache_ = {} % last linesList (cell of structs) passed to setPreviewLines
FigureDestroyedListener_ = [] % event.listener — fires onFigureDestroyed_ when obj.hFigure is destroyed (260511-mjb)
+ % Phase 1031 PLOG-VIZ-01..09: plant-log slider overlay test seam.
+ % These three properties are the temporary integration point used by
+ % setPlantLogStoreForTest_ / setPlantLogLiveTailForTest_; Phase 1033
+ % will replace the seam with the public attachPlantLog/detachPlantLog API.
+ PlantLogStoreInternal_ = [] % PlantLogStore handle (or [])
+ PlantLogLiveTailInternal_ = [] % PlantLogLiveTail handle (or [])
+ PlantLogTickListener_ = [] % addlistener handle for PlantLogLiveTail.PlantLogTailTick
+ % Phase 1031 PLOG-VIZ-06: hover tooltip on plant-log slider markers.
+ % Lazily constructed in setPlantLogStoreForTest_ when a non-empty
+ % store is attached AND TimeRangeSelector_ is rendered. Torn down
+ % on every store change (so stale store-handle closures cannot
+ % survive a store swap), on store-detach, and in delete().
+ PlantLogSliderHover_ = [] % PlantLogSliderHover handle (or [])
+ % Phase 1033 PLOG-INT-01/04: serializer read-through state.
+ % Populated by attachPlantLog (public API); cleared by detachPlantLog.
+ % DashboardSerializer reads these via friend access in Plan 02.
+ PlantLogSourcePath_ = '' % char -- source file path passed to attachPlantLog
+ PlantLogMapping_ = [] % struct (CONTEXT.md JSON-schema shape) or []
+ PlantLogInterval_ = [] % numeric scalar (seconds) or []
+ PlantLogStartTail_ = [] % logical scalar or []
+ end
+
+ % Phase 1032 PLOG-VIZ-07: per-widget hover tooltips. Cell of
+ % {widget, PlantLogWidgetHover} pairs. Lazily populated in
+ % attachPlantLogWidgetHover_ when widget.setShowPlantLog(true, engine)
+ % runs against a widget whose engine has a store attached. Torn down
+ % by detachPlantLogWidgetHover_ on setShowPlantLog(false, engine) AND
+ % in delete() BEFORE the TRS teardown (matching the Phase 1031
+ % hover-before-selector ordering rule).
+ %
+ % Public READ + restricted WRITE: tests + downstream consumers can
+ % observe attached hovers, but only the engine itself + FastSenseWidget
+ % (via the friend access list) can mutate the cell.
+ % SetAccess limited to engine + widget. Tests that need direct
+ % mutation use the existing setPlantLogStoreForTest_ /
+ % setPlantLogLiveTailForTest_ hooks (Hidden, Octave-safe).
+ % matlab.unittest.TestCase is intentionally NOT listed because
+ % Octave has no matlab.unittest namespace and the classdef would
+ % fail to parse entirely.
+ properties (SetAccess = {?DashboardEngine, ?FastSenseWidget})
+ WidgetHovers_ = {}
end
methods (Access = public)
@@ -119,6 +160,7 @@
obj.(key) = varargin{k+1};
end
obj.Layout = DashboardLayout();
+ obj.Layout.EngineRef = obj; % Phase 1032 PLOG-VIZ-05 — used by addPlantLogToggle callback
obj.WidgetTypeMap_ = containers.Map({ ...
'fastsense', 'number', 'status', 'text', ...
'gauge', 'table', 'rawaxes', 'timeline', ...
@@ -155,6 +197,10 @@
try obj.computeEventMarkers(); catch err
if obj.DebugPreview_, warning('DashboardEngine:eventMarkersFailed', 'computeEventMarkers: %s', err.message); end
end
+ % Phase 1031 PLOG-VIZ-01/08: refresh plant-log slider markers too.
+ try obj.computePlantLogMarkers(); catch err
+ if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end
+ end
end
function setEventMarkersVisible(obj, tf)
@@ -187,6 +233,10 @@ function setEventMarkersVisible(obj, tf)
try obj.computeEventMarkers(); catch err
if obj.DebugPreview_, warning('DashboardEngine:eventMarkersFailed', 'computeEventMarkers: %s', err.message); end
end
+ % Phase 1031 PLOG-VIZ-01/08: refresh plant-log slider markers too.
+ try obj.computePlantLogMarkers(); catch err
+ if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end
+ end
end
function switchPage(obj, pageIdx)
@@ -299,6 +349,10 @@ function switchPage(obj, pageIdx)
try obj.computeEventMarkers(); catch err
if obj.DebugPreview_, warning('DashboardEngine:eventMarkersFailed', 'computeEventMarkers: %s', err.message); end
end
+ % Phase 1031 PLOG-VIZ-01/08: refresh plant-log slider markers too.
+ try obj.computePlantLogMarkers(); catch err
+ if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end
+ end
end
function w = addWidget(obj, type, varargin)
@@ -534,6 +588,304 @@ function stopLive(obj)
end
end
+ function store = attachPlantLog(obj, filePath, varargin)
+ %ATTACHPLANTLOG Attach a plant log to this dashboard (PLOG-INT-01).
+ % store = engine.attachPlantLog(filePath) reads filePath using
+ % PlantLogReader.autoDetect for the column mapping, ingests every
+ % parseable row into a new PlantLogStore, starts a PlantLogLiveTail
+ % timer (default Interval=5s, StartTail=true), wires the slider +
+ % per-widget overlay refresh path, and returns the store handle.
+ %
+ % store = engine.attachPlantLog(filePath, ...
+ % 'Mapping', struct('timestampCol','Time','messageCol','Msg',...
+ % 'metadataCols',{{'Unit','Shift'}},'format',''), ...
+ % 'Interval', 5, ...
+ % 'StartTail', true) overrides defaults.
+ %
+ % Re-attach is idempotent: if a store is already attached, this
+ % method internally calls detachPlantLog() to release the prior
+ % store + timer + overlays + hovers, then attaches the new one.
+ % No error, no warning.
+ %
+ % Mapping struct field names accepted (CONTEXT.md JSON-schema shape):
+ % timestampCol, messageCol, metadataCols, format
+ % These are translated internally into the PlantLogReader.mapping
+ % shape (TimestampColumn, MessageColumn, TimestampFormat).
+ %
+ % Errors raised:
+ % DashboardEngine:invalidPlantLogOption - bad opt name or value
+ % PlantLogReader:* - propagated from reader
+ % PlantLogStore:* - propagated from store
+ %
+ % See also detachPlantLog, PlantLogReader.openInteractive, PlantLogStore.
+
+ % --- Validate filePath ---
+ if isstring(filePath); filePath = char(filePath); end
+ if ~ischar(filePath) || isempty(filePath)
+ error('PlantLogReader:invalidInput', ...
+ 'filePath must be a non-empty char/string.');
+ end
+
+ % --- Parse name-value opts (per CONTEXT.md D-02) ---
+ % Hidden opt ContinueOnReadError (default false): when true,
+ % PlantLogReader:fileNotFound + PlantLogReader:unknownColumn +
+ % PlantLogReader:readError are caught and re-emitted as the
+ % three new namespaced warnings instead of propagating. Used
+ % exclusively by DashboardEngine.load to honour the
+ % CONTEXT.md D-12 "degrade-to-warning" load-failure contract.
+ opts = struct('Mapping', [], 'Interval', 5, 'StartTail', true, ...
+ 'ContinueOnReadError', false);
+ if mod(numel(varargin), 2) ~= 0
+ error('DashboardEngine:invalidPlantLogOption', ...
+ 'attachPlantLog name-value args must come in pairs; got %d.', numel(varargin));
+ end
+ validKeys = fieldnames(opts);
+ for k = 1:2:numel(varargin)
+ key = varargin{k};
+ if isstring(key); key = char(key); end
+ if ~ischar(key)
+ error('DashboardEngine:invalidPlantLogOption', ...
+ 'Option key at position %d must be char.', k);
+ end
+ idx = find(strcmpi(validKeys, key), 1);
+ if isempty(idx)
+ error('DashboardEngine:invalidPlantLogOption', ...
+ 'Unknown attachPlantLog option ''%s''. Valid: %s.', ...
+ key, strjoin(validKeys, ', '));
+ end
+ opts.(validKeys{idx}) = varargin{k + 1};
+ end
+
+ % --- Validate Interval ---
+ if ~isnumeric(opts.Interval) || ~isscalar(opts.Interval) || ...
+ ~isfinite(opts.Interval) || opts.Interval <= 0
+ error('DashboardEngine:invalidPlantLogOption', ...
+ 'Interval must be a positive finite numeric scalar (seconds).');
+ end
+
+ % --- Validate StartTail ---
+ if ~islogical(opts.StartTail) && ~isnumeric(opts.StartTail)
+ error('DashboardEngine:invalidPlantLogOption', ...
+ 'StartTail must be logical scalar.');
+ end
+ if ~isscalar(opts.StartTail)
+ error('DashboardEngine:invalidPlantLogOption', ...
+ 'StartTail must be logical scalar.');
+ end
+ startTail = logical(opts.StartTail);
+
+ % --- Idempotent re-attach: detach any prior store FIRST ---
+ % Per CONTEXT.md D-04: "first call detachPlantLog() internally
+ % to clean up the prior store + tail + listeners + overlays,
+ % then attach new. No error, no user prompt."
+ if ~isempty(obj.PlantLogStoreInternal_) || ...
+ ~isempty(obj.PlantLogLiveTailInternal_)
+ obj.detachPlantLog();
+ end
+
+ % --- Translate mapping from JSON-schema shape -> PlantLogReader shape ---
+ if isstruct(opts.Mapping)
+ readerMapping = obj.plantLogMappingToReaderShape_(opts.Mapping);
+ else
+ % No mapping supplied -> autoDetect via the public helper
+ % (DashboardEngine cannot reach libs/PlantLog/private, so
+ % PlantLogReader.autoDetectFromFile is the integration point).
+ try
+ readerMapping = PlantLogReader.autoDetectFromFile(filePath);
+ catch autoME
+ if opts.ContinueOnReadError
+ [recovered, store] = obj.surfacePlantLogLoadFailure_(autoME, filePath);
+ if ~recovered, return; end
+ % If recovery succeeded inside surfacePlantLogLoadFailure_,
+ % store is set and we should NOT continue with the
+ % normal attach path. surfacePlantLogLoadFailure_
+ % returns recovered=false for fileNotFound /
+ % readError; recovered=true with store=[] never
+ % happens (it always returns store=[] when
+ % recovered=false). Defensive return below.
+ return;
+ else
+ rethrow(autoME);
+ end
+ end
+ end
+
+ % --- Ingest via headless reader ---
+ try
+ entries = PlantLogReader.openInteractive(filePath, ...
+ 'Headless', true, ...
+ 'Mapping', readerMapping);
+ catch readME
+ if opts.ContinueOnReadError
+ if strcmp(readME.identifier, 'PlantLogReader:unknownColumn')
+ % Mapping mismatch -- re-run autoDetect, warn, retry once.
+ try
+ newMapping = PlantLogReader.autoDetectFromFile(filePath);
+ warning('DashboardEngine:plantLogMappingMismatch', ...
+ ['Saved plant-log mapping (timestamp=%s, ' ...
+ 'message=%s) no longer matches file columns; ' ...
+ 'using auto-detected mapping (timestamp=%s, ' ...
+ 'message=%s) instead.'], ...
+ readerMapping.TimestampColumn, readerMapping.MessageColumn, ...
+ newMapping.TimestampColumn, newMapping.MessageColumn);
+ readerMapping = newMapping;
+ entries = PlantLogReader.openInteractive(filePath, ...
+ 'Headless', true, ...
+ 'Mapping', readerMapping);
+ catch retryME
+ warning('DashboardEngine:plantLogReadFailed', ...
+ ['Saved plant-log re-import failed after ' ...
+ 'mapping-mismatch recovery: %s; ' ...
+ 'dashboard loaded without overlay.'], ...
+ retryME.message);
+ store = [];
+ return;
+ end
+ else
+ [~, ~] = obj.surfacePlantLogLoadFailure_(readME, filePath);
+ store = [];
+ return;
+ end
+ else
+ rethrow(readME);
+ end
+ end
+
+ % --- Build store + populate ---
+ store = PlantLogStore(filePath);
+ if ~isempty(entries)
+ store.addEntries(entries);
+ end
+
+ % --- Persist serialization-state properties (PLOG-INT-04 prep) ---
+ % Set BEFORE setPlantLogStoreForTest_ so any tick callback that
+ % fires during wire-up sees the populated state.
+ obj.PlantLogSourcePath_ = filePath;
+ obj.PlantLogMapping_ = obj.readerMappingToJsonShape_(readerMapping);
+ obj.PlantLogInterval_ = double(opts.Interval);
+ obj.PlantLogStartTail_ = startTail;
+
+ % --- Wire slider overlay + slider hover (existing seam) ---
+ % setPlantLogStoreForTest_ tears down + rebuilds PlantLogSliderHover_
+ % and runs computePlantLogMarkers; we reuse it here so the
+ % production path goes through the same wire-up code.
+ obj.setPlantLogStoreForTest_(store);
+
+ % --- Start live tail (PLOG-LT-01..04) when requested ---
+ if startTail
+ tail = PlantLogLiveTail(store, filePath, readerMapping, ...
+ 'Interval', opts.Interval, ...
+ 'StartImmediately', true);
+ obj.setPlantLogLiveTailForTest_(tail); % wires PlantLogTickListener_
+ end
+
+ % --- Re-wire ShowPlantLog=true widgets so overlay/hover attach ---
+ % Per CONTEXT.md D-09: after attachPlantLog runs, the engine
+ % iterates Widgets and calls setShowPlantLog(true, engine) on
+ % every ShowPlantLog=true FastSenseWidget so the engine ref +
+ % XLim listener + hover are rewired. fromStruct alone only
+ % flips the boolean; this triggers the engine-side draw wire-up.
+ ws = obj.allPageWidgets();
+ for i = 1:numel(ws)
+ w = ws{i};
+ if isa(w, 'FastSenseWidget') && w.ShowPlantLog
+ try
+ w.setShowPlantLog(true, obj);
+ catch ME
+ warning('DashboardEngine:plantLogOverlayFailed', ...
+ 'attachPlantLog: setShowPlantLog on widget "%s" failed: %s', ...
+ w.Title, ME.message);
+ end
+ end
+ end
+ end
+
+ function detachPlantLog(obj)
+ %DETACHPLANTLOG Remove the attached plant log + all overlays + live tail (PLOG-INT-02).
+ % Idempotent: calling on an engine with no plant log attached is a no-op.
+ %
+ % Teardown order (per CONTEXT.md D-04, all guarded by isvalid checks):
+ % 1. Stop + delete the PlantLogLiveTail timer (if running).
+ % 2. Tear down the PlantLogTickListener_ (live-tail listener).
+ % 3. Clear slider overlay markers via setPlantLogStoreForTest_([])
+ % (this also tears down the PlantLogSliderHover_).
+ % 4. Clear widget overlays via clearPlantLogOverlaysOnAllWidgets_
+ % + tear down WidgetHovers_.
+ % 5. Null PlantLogStoreInternal_ + PlantLogLiveTailInternal_.
+ % 6. Clear PlantLogSourcePath_, PlantLogMapping_, PlantLogInterval_,
+ % PlantLogStartTail_.
+ %
+ % See also attachPlantLog, PlantLogStore, PlantLogLiveTail.
+
+ % Idempotent guard -- already detached, return silently after
+ % wiping any partial state from a failed attach.
+ if isempty(obj.PlantLogStoreInternal_) && isempty(obj.PlantLogLiveTailInternal_) && ...
+ isempty(obj.PlantLogTickListener_) && isempty(obj.PlantLogSliderHover_) && ...
+ isempty(obj.WidgetHovers_)
+ % Clear the serialization-state props in case attach failed mid-way.
+ obj.PlantLogSourcePath_ = '';
+ obj.PlantLogMapping_ = [];
+ obj.PlantLogInterval_ = [];
+ obj.PlantLogStartTail_ = [];
+ return;
+ end
+
+ % Step 1 -- stop + delete the live-tail timer.
+ if ~isempty(obj.PlantLogLiveTailInternal_)
+ try
+ if isvalid(obj.PlantLogLiveTailInternal_)
+ % Prefer the class's own stop(); fall back to direct delete().
+ if ismethod(obj.PlantLogLiveTailInternal_, 'stop')
+ try obj.PlantLogLiveTailInternal_.stop(); catch, end
+ end
+ try delete(obj.PlantLogLiveTailInternal_); catch, end
+ end
+ catch
+ end
+ end
+ obj.PlantLogLiveTailInternal_ = [];
+
+ % Step 2 -- tear down the tick listener.
+ try
+ if ~isempty(obj.PlantLogTickListener_) && isvalid(obj.PlantLogTickListener_)
+ delete(obj.PlantLogTickListener_);
+ end
+ catch
+ end
+ obj.PlantLogTickListener_ = [];
+
+ % Step 3 -- clear slider overlay + tear down slider hover.
+ % setPlantLogStoreForTest_([]) tears down PlantLogSliderHover_
+ % unconditionally and runs computePlantLogMarkers which clears
+ % xlines when store is empty.
+ try obj.setPlantLogStoreForTest_([]); catch, end
+
+ % Step 4 -- clear per-widget overlays + tear down WidgetHovers_.
+ try obj.clearPlantLogOverlaysOnAllWidgets_(); catch, end
+ for i = 1:numel(obj.WidgetHovers_)
+ pair = obj.WidgetHovers_{i};
+ if iscell(pair) && numel(pair) >= 2
+ try
+ if isa(pair{2}, 'handle') && isvalid(pair{2})
+ delete(pair{2});
+ end
+ catch
+ end
+ end
+ end
+ obj.WidgetHovers_ = {};
+
+ % Step 5 -- null the store (already implicitly done by step 3,
+ % but the explicit null is the contract for the success criterion).
+ obj.PlantLogStoreInternal_ = [];
+
+ % Step 6 -- clear serialization-state properties.
+ obj.PlantLogSourcePath_ = '';
+ obj.PlantLogMapping_ = [];
+ obj.PlantLogInterval_ = [];
+ obj.PlantLogStartTail_ = [];
+ end
+
function save(obj, filepath)
[~, ~, ext] = fileparts(filepath);
isMultiPage = numel(obj.Pages) > 1;
@@ -543,6 +895,7 @@ function save(obj, filepath)
activePageName = obj.Pages{obj.ActivePage}.Name;
cfg = DashboardSerializer.widgetsPagesToConfig( ...
obj.Name, obj.Theme, obj.LiveInterval, obj.Pages, activePageName, obj.InfoFile);
+ cfg = obj.stampPlantLogIntoConfig_(cfg); % Phase 1033 PLOG-INT-04
if strcmp(ext, '.json')
DashboardSerializer.saveJSON(cfg, filepath);
else
@@ -556,6 +909,7 @@ function save(obj, filepath)
cfg = DashboardSerializer.widgetsToConfig( ...
obj.Name, obj.Theme, obj.LiveInterval, obj.Widgets, obj.InfoFile);
end
+ cfg = obj.stampPlantLogIntoConfig_(cfg); % Phase 1033 PLOG-INT-04
if strcmp(ext, '.json')
DashboardSerializer.saveJSON(cfg, filepath);
else
@@ -565,6 +919,47 @@ function save(obj, filepath)
obj.FilePath = filepath;
end
+ function cfg = stampPlantLogIntoConfig_(obj, cfg)
+ %STAMPPLANTLOGINTOCONFIG_ Phase 1033 PLOG-INT-04: add plantLog key when attached.
+ % When no plant log is attached, cfg is returned unchanged
+ % (omit-when-empty contract -- byte-identical back-compat for
+ % v1.0-v3.0 dashboards).
+ %
+ % Also skipped when only the test seam (setPlantLogStoreForTest_)
+ % populated the store -- that path does not set PlantLogSourcePath_
+ % and is by design NOT serialized.
+ if isempty(obj.PlantLogStoreInternal_)
+ return;
+ end
+ if isempty(obj.PlantLogSourcePath_)
+ % Test-seam injection (setPlantLogStoreForTest_) did not
+ % populate SourcePath_ -- that path is NOT serialized.
+ return;
+ end
+ pl = struct();
+ pl.sourcePath = obj.PlantLogSourcePath_;
+ if isstruct(obj.PlantLogMapping_)
+ pl.mapping = obj.PlantLogMapping_;
+ else
+ pl.mapping = struct( ...
+ 'timestampCol', '', ...
+ 'messageCol', '', ...
+ 'metadataCols', {{}}, ...
+ 'format', '');
+ end
+ if isempty(obj.PlantLogInterval_)
+ pl.interval = 5;
+ else
+ pl.interval = double(obj.PlantLogInterval_);
+ end
+ if isempty(obj.PlantLogStartTail_)
+ pl.startTail = true;
+ else
+ pl.startTail = logical(obj.PlantLogStartTail_);
+ end
+ cfg.plantLog = pl;
+ end
+
function exportScript(obj, filepath)
if numel(obj.Pages) > 1
% Multi-page: emit addPage() calls before each page's widgets
@@ -1127,6 +1522,22 @@ function detachWidget(obj, widget)
mirror = DetachedMirror(widget, themeStruct, removeCallback);
mirrorHolder('mirror') = mirror;
obj.DetachedMirrors{end+1} = mirror;
+ % Phase 1032 PLOG-VIZ-03/04/07 — re-attach plant-log wire-up on
+ % the mirror's cloned widget so the standalone figure draws lines
+ % + has a hover. The cloned widget has ShowPlantLog already set
+ % (via DetachedMirror.restoreLiveRefs); calling
+ % setShowPlantLog(currentValue, obj) is a no-op for the property
+ % itself but triggers the listener attach + hover construct +
+ % marker draw on the mirror's axes. CONTEXT.md Decision G.
+ try
+ cw = mirror.Widget;
+ if isa(cw, 'FastSenseWidget') && cw.ShowPlantLog
+ cw.setShowPlantLog(true, obj);
+ end
+ catch err
+ warning('DashboardEngine:plantLogOverlayFailed', ...
+ 'detachWidget plant-log re-attach failed: %s', err.message);
+ end
end
function removeDetached(obj)
@@ -1139,8 +1550,14 @@ function removeDetached(obj)
keep = true(1, numel(obj.DetachedMirrors));
for i = 1:numel(obj.DetachedMirrors)
- if obj.DetachedMirrors{i}.isStale()
+ m = obj.DetachedMirrors{i};
+ if m.isStale()
keep(i) = false;
+ % Phase 1032 PLOG-VIZ-07 — sweep the mirror's hover from
+ % WidgetHovers_ when the mirror's figure goes stale. Without
+ % this, closed-but-not-deregistered mirrors leave dangling
+ % PlantLogWidgetHover instances + stale closures.
+ try obj.detachPlantLogWidgetHover_(m.Widget); catch, end
end
end
obj.DetachedMirrors = obj.DetachedMirrors(keep);
@@ -1387,6 +1804,10 @@ function updateGlobalTimeRange(obj)
try obj.computeEventMarkers(); catch err
if obj.DebugPreview_, warning('DashboardEngine:eventMarkersFailed', 'computeEventMarkers: %s', err.message); end
end
+ % Phase 1031 PLOG-VIZ-01/08: refresh plant-log slider markers too.
+ try obj.computePlantLogMarkers(); catch err
+ if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end
+ end
end
function updateLiveTimeRange(obj)
@@ -1784,6 +2205,10 @@ function onLiveTick(obj)
try obj.computeEventMarkers(); catch err
if obj.DebugPreview_, warning('DashboardEngine:eventMarkersFailed', 'computeEventMarkers: %s', err.message); end
end
+ % Phase 1031 PLOG-VIZ-01/08: refresh plant-log slider markers too.
+ try obj.computePlantLogMarkers(); catch err
+ if obj.DebugPreview_, warning('DashboardEngine:plantLogMarkersFailed', 'computePlantLogMarkers: %s', err.message); end
+ end
end
function markAllDirty(obj)
@@ -2116,6 +2541,27 @@ function delete(obj)
try delete(obj.FigureDestroyedListener_); catch, end
obj.FigureDestroyedListener_ = [];
end
+ % Phase 1031 PLOG-VIZ-06: tear down hover BEFORE the selector.
+ % Hover saved the selector's chained WindowButtonMotionFcn at
+ % construction; restoring it must happen while the selector is
+ % still alive (otherwise the restored callback handle refers to
+ % a deleted TimeRangeSelector and the figure ends up with a
+ % stale closure). teardownPlantLogSliderHover_ is idempotent
+ % (safe to call again at the end of delete()).
+ obj.teardownPlantLogSliderHover_();
+ % Phase 1032 PLOG-VIZ-07: tear down per-widget hovers BEFORE TRS
+ % so any chained WBMFcn restore lands on a still-alive figure
+ % and selector. Mirrors the slider-hover ordering rule.
+ for hi = 1:numel(obj.WidgetHovers_)
+ try
+ pair = obj.WidgetHovers_{hi};
+ if numel(pair) == 2 && ~isempty(pair{2}) && isvalid(pair{2})
+ delete(pair{2});
+ end
+ catch
+ end
+ end
+ obj.WidgetHovers_ = {};
% Tear down the selector first so its figure-level callback
% restore happens before the figure/panel potentially go away.
if ~isempty(obj.TimeRangeSelector_) && ...
@@ -2161,6 +2607,20 @@ function delete(obj)
try delete(obj.InfoModalFigure_); catch, end
end
obj.InfoModalFigure_ = [];
+ % Phase 1031 PLOG-VIZ-08: tear down plant-log live-tail listener.
+ try
+ if ~isempty(obj.PlantLogTickListener_) && isvalid(obj.PlantLogTickListener_)
+ delete(obj.PlantLogTickListener_);
+ end
+ catch
+ end
+ obj.PlantLogTickListener_ = [];
+ % Phase 1031 PLOG-VIZ-06: tear down plant-log slider hover.
+ % delete() restores prior WindowButtonMotionFcn unconditionally.
+ obj.teardownPlantLogSliderHover_();
+ % Phase 1033 PLOG-INT-02: full teardown of plant-log API surface
+ % (idempotent -- no-op if everything already torn down above).
+ try obj.detachPlantLog(); catch, end
end
end
@@ -2194,6 +2654,117 @@ function broadcastTimeRangeNow(obj, tStart, tEnd)
if nargin < 2, nBuckets = []; end
env = obj.computePreviewEnvelopeReturning_(nBuckets);
end
+
+ function setPlantLogStoreForTest_(obj, store)
+ %SETPLANTLOGSTOREFORTEST_ Phase 1031 test seam — replaced by attachPlantLog in Phase 1033.
+ % Inject a PlantLogStore (or [] to detach) and immediately recompute
+ % plant-log slider markers so callers can assert on the slider state
+ % right after attach without waiting for a refresh hook.
+ %
+ % Phase 1031 PLOG-VIZ-06: every store change ALWAYS tears down +
+ % (re-)builds the PlantLogSliderHover_ helper. Tearing down first
+ % ensures stale closures (capturing an old store handle) cannot
+ % survive a store swap; rebuilding requires a non-empty store AND
+ % a rendered TimeRangeSelector_. The hover closure goes through
+ % obj.lookupPlantLogEntries_ (NOT a captured-by-value store ref),
+ % so subsequent store swaps reflect immediately even if the
+ % rebuild branch is bypassed.
+ if ~isempty(store) && ~isa(store, 'PlantLogStore')
+ error('DashboardEngine:invalidPlantLogStore', ...
+ 'store must be empty or a PlantLogStore; got %s.', class(store));
+ end
+ obj.PlantLogStoreInternal_ = store;
+ obj.computePlantLogMarkers();
+ % Phase 1031 PLOG-VIZ-06: always tear down any prior hover so
+ % closures capturing the previous store handle cannot survive.
+ obj.teardownPlantLogSliderHover_();
+ if ~isempty(store) && ...
+ ~isempty(obj.TimeRangeSelector_) && ...
+ isa(obj.TimeRangeSelector_, 'TimeRangeSelector')
+ % Lazy-construct hover when the slider is rendered AND a
+ % store is attached. The lookup goes through the engine's
+ % helper (indirect indirection) so future store swaps are
+ % picked up without needing to rebuild the closure.
+ try
+ ax = obj.TimeRangeSelector_.hAxes;
+ fig = ancestor(ax, 'figure');
+ if ~isempty(fig) && ishandle(fig)
+ obj.PlantLogSliderHover_ = PlantLogSliderHover( ...
+ fig, ax, ...
+ @(t0, t1) obj.lookupPlantLogEntries_(t0, t1));
+ end
+ catch err
+ if obj.DebugPreview_
+ warning('DashboardEngine:plantLogHoverFailed', ...
+ 'PlantLogSliderHover construction failed: %s', err.message);
+ end
+ end
+ end
+ end
+
+ function setPlantLogLiveTailForTest_(obj, tail)
+ %SETPLANTLOGLIVETAILFORTEST_ Phase 1031 test seam — wires PlantLogTailTick to refresh.
+ % Inject a PlantLogLiveTail (or [] to detach + tear down listener).
+ % When non-empty, installs an addlistener that calls
+ % computePlantLogMarkers on every PlantLogTailTick so the slider
+ % refreshes without a full dashboard re-render (PLOG-VIZ-08).
+ if ~isempty(tail) && ~isa(tail, 'PlantLogLiveTail')
+ error('DashboardEngine:invalidPlantLogLiveTail', ...
+ 'tail must be empty or a PlantLogLiveTail; got %s.', class(tail));
+ end
+ try
+ if ~isempty(obj.PlantLogTickListener_) && isvalid(obj.PlantLogTickListener_)
+ delete(obj.PlantLogTickListener_);
+ end
+ catch
+ end
+ obj.PlantLogTickListener_ = [];
+ obj.PlantLogLiveTailInternal_ = tail;
+ if ~isempty(tail)
+ % Phase 1032 PLOG-VIZ-08: route ticks through
+ % onPlantLogTailTick_ so slider AND per-widget overlays
+ % refresh on every tail tick (fan-out covers Pages,
+ % single-page Widgets, and DetachedMirrors).
+ obj.PlantLogTickListener_ = addlistener(tail, 'PlantLogTailTick', ...
+ @(~,~) obj.onPlantLogTailTick_());
+ end
+ end
+
+ function refreshPlantLogOverlayForWidgetForTest_(obj, widget)
+ %REFRESHPLANTLOGOVERLAYFORWIDGETFORTEST_ Phase 1032 test seam.
+ % Routes to refreshPlantLogOverlayForWidget_ from function-style
+ % tests (which can't satisfy the {?FastSenseWidget, ?matlab.unittest.TestCase}
+ % access list). Hidden so it doesn't show up in methods(obj).
+ obj.refreshPlantLogOverlayForWidget_(widget);
+ end
+
+ function clearPlantLogOverlaysOnAllWidgetsForTest_(obj)
+ %CLEARPLANTLOGOVERLAYSONALLWIDGETSFORTEST_ Phase 1032 test seam.
+ % Routes to clearPlantLogOverlaysOnAllWidgets_ from function-style
+ % tests. Hidden test seam mirroring the Phase 1031 idiom.
+ obj.clearPlantLogOverlaysOnAllWidgets_();
+ end
+
+ function attachPlantLogXLimListenerForTest_(obj, widget)
+ %ATTACHPLANTLOGXLIMLISTENERFORTEST_ Phase 1032 test seam.
+ % Routes to attachPlantLogXLimListener_ from function-style tests.
+ obj.attachPlantLogXLimListener_(widget);
+ end
+
+ function setTimeRangeSelectorForTest_(obj, sel)
+ %SETTIMERANGESELECTORFORTEST_ Phase 1031 test seam — inject a
+ % TimeRangeSelector handle without going through render(). Used by
+ % TestPlantLogSliderOverlay to assert hPlantLogMarkers state without
+ % paying full-dashboard render cost. The TimeRangeSelector_ property
+ % is Access = private, so direct assignment from a test is impossible
+ % — this hidden setter is the documented seam. Phase 1033's review
+ % may remove it once render() pathways cover the new test cases.
+ if ~isempty(sel) && ~isa(sel, 'TimeRangeSelector')
+ error('DashboardEngine:invalidTimeRangeSelector', ...
+ 'sel must be empty or a TimeRangeSelector; got %s.', class(sel));
+ end
+ obj.TimeRangeSelector_ = sel;
+ end
end
% Public page/widget accessors — moved out of the private block in
@@ -2288,6 +2859,227 @@ function notifyEventsChanged(obj)
end
end
+ % Phase 1032 PLOG-VIZ-03 + PLOG-VIZ-04: per-widget plant-log overlay
+ % helpers. Access restricted to FastSenseWidget so the widget's
+ % setShowPlantLog setter can call these without exposing them as
+ % public API.
+ %
+ % Hidden (not Access = {?FastSenseWidget, ?matlab.unittest.TestCase})
+ % so Octave parsing survives — Octave has no matlab.unittest namespace.
+ % Same Octave-safe idiom FastSenseDataStore.ensureOpenForTest uses.
+ % These remain "internal" — not in tab-complete or methods() — while
+ % still being callable from FastSenseWidget (consumer of all four),
+ % the engine's own helpers, and class-based or function-style tests
+ % across MATLAB + Octave.
+ methods (Hidden)
+
+ function refreshPlantLogOverlayForWidget_(obj, widget)
+ %REFRESHPLANTLOGOVERLAYFORWIDGET_ Recompute plant-log overlay for one widget (Phase 1032 PLOG-VIZ-04 + PLOG-VIZ-08).
+ % Idempotent: safe to call when widget.ShowPlantLog=false (clears
+ % markers), when the engine has no store (clears markers), or
+ % when the widget's FastSenseObj is not rendered (no-op).
+ %
+ % 1. Validate widget and inner FastSense are rendered.
+ % 2. Clear all WidgetPlantLogMarker handles on the widget's axes.
+ % 3. Early return when ShowPlantLog=false (clear-only path).
+ % 4. Early return when store is empty / not a PlantLogStore.
+ % 5. Read XLim from the widget's axes.
+ % 6. getEntriesInRange(t0, t1) from PlantLogStoreInternal_.
+ % 7. Sub-pixel coalesce: bucket entries by
+ % floor(t * pixelsPerDataUnit) and keep one entry per
+ % unique bucket (stable). pixelsPerDataUnit derived from
+ % getpixelposition(ax, true).
+ % 8. setPlantLogMarkers(coalescedTimes, coalescedEntries).
+ %
+ % On failure, fires DashboardEngine:plantLogOverlayFailed warning.
+ try
+ if isempty(widget) || ~isa(widget, 'FastSenseWidget'), return; end
+ if isempty(widget.FastSenseObj) || ...
+ ~isa(widget.FastSenseObj, 'FastSense') || ...
+ ~widget.FastSenseObj.IsRendered
+ return;
+ end
+ ax = widget.FastSenseObj.hAxes;
+ if isempty(ax) || ~ishandle(ax), return; end
+ % Clear stale markers first (idempotent).
+ delete(findobj(ax, 'Tag', 'WidgetPlantLogMarker'));
+ if ~widget.ShowPlantLog, return; end
+ if isempty(obj.PlantLogStoreInternal_) || ...
+ ~isa(obj.PlantLogStoreInternal_, 'PlantLogStore')
+ return;
+ end
+ xl = get(ax, 'XLim');
+ t0 = xl(1);
+ t1 = xl(2);
+ entries = obj.PlantLogStoreInternal_.getEntriesInRange(t0, t1);
+ if isempty(entries), return; end
+ times = [entries.Timestamp];
+ % Sub-pixel coalesce (decision D): bucket entries by their
+ % floored pixel index so two timestamps that land in the
+ % same screen pixel render a single line. Hover lookup
+ % uses the full store, not these coalesced timestamps.
+ try
+ axPosPx = getpixelposition(ax, true);
+ ax_width_px = max(axPosPx(3), 1);
+ catch
+ ax_width_px = 600; % conservative default
+ end
+ pixelsPerDataUnit = ax_width_px / max(t1 - t0, eps);
+ buckets = floor(double(times) * pixelsPerDataUnit);
+ [~, ia] = unique(buckets, 'stable');
+ coalescedTimes = times(ia);
+ coalescedEntries = entries(ia);
+ widget.setPlantLogMarkers(coalescedTimes, coalescedEntries);
+ catch err
+ warning('DashboardEngine:plantLogOverlayFailed', ...
+ 'refreshPlantLogOverlayForWidget_ failed: %s', err.message);
+ end
+ end
+
+ function clearPlantLogOverlaysOnAllWidgets_(obj)
+ %CLEARPLANTLOGOVERLAYSONALLWIDGETS_ Wipe markers on every widget + every detached mirror (Phase 1032).
+ % Does NOT flip ShowPlantLog on any widget — user state is
+ % preserved for re-attach. Called from Phase 1033's
+ % detachPlantLog() entry point and from store swaps that need
+ % to nuke stale per-widget markers.
+ ws = obj.allPageWidgets();
+ for i = 1:numel(ws)
+ w = ws{i};
+ if isa(w, 'FastSenseWidget') && ~isempty(w.FastSenseObj) && ...
+ isa(w.FastSenseObj, 'FastSense') && w.FastSenseObj.IsRendered
+ ax = w.FastSenseObj.hAxes;
+ if ~isempty(ax) && ishandle(ax)
+ try delete(findobj(ax, 'Tag', 'WidgetPlantLogMarker')); catch, end
+ end
+ end
+ end
+ for k = 1:numel(obj.DetachedMirrors)
+ m = obj.DetachedMirrors{k};
+ if isempty(m) || ~isvalid(m), continue; end
+ w = m.Widget;
+ if isa(w, 'FastSenseWidget') && ~isempty(w.FastSenseObj) && ...
+ isa(w.FastSenseObj, 'FastSense') && w.FastSenseObj.IsRendered
+ ax = w.FastSenseObj.hAxes;
+ if ~isempty(ax) && ishandle(ax)
+ try delete(findobj(ax, 'Tag', 'WidgetPlantLogMarker')); catch, end
+ end
+ end
+ end
+ end
+
+ function attachPlantLogXLimListener_(obj, widget)
+ %ATTACHPLANTLOGXLIMLISTENER_ Wire an XLim PostSet listener on the widget's axes (Phase 1032).
+ % Stored in widget.PlantLogXLimListener_; deleted by
+ % setShowPlantLog(false) AND by widget.delete(). Idempotent:
+ % replaces any prior listener.
+ if isempty(widget) || ~isa(widget, 'FastSenseWidget'), return; end
+ if ~isempty(widget.PlantLogXLimListener_)
+ try delete(widget.PlantLogXLimListener_); catch, end
+ widget.setPlantLogXLimListenerForEngine_([]);
+ end
+ if isempty(widget.FastSenseObj) || ~widget.FastSenseObj.IsRendered
+ return;
+ end
+ ax = widget.FastSenseObj.hAxes;
+ if isempty(ax) || ~ishandle(ax), return; end
+ % Octave's addlistener does not support the 4-arg
+ % (object, propName, 'PostSet', callback) form — third arg
+ % must be a callback. Skip the listener on Octave; the
+ % engine's PlantLogTickListener_ + the slider redraw on
+ % render still provide refresh on Octave.
+ if exist('OCTAVE_VERSION', 'builtin')
+ return;
+ end
+ try
+ lis = addlistener(ax, 'XLim', 'PostSet', ...
+ @(~,~) obj.refreshPlantLogOverlayForWidget_(widget));
+ widget.setPlantLogXLimListenerForEngine_(lis);
+ catch err
+ warning('DashboardEngine:plantLogOverlayFailed', ...
+ 'attachPlantLogXLimListener_ failed: %s', err.message);
+ end
+ end
+
+ function attachPlantLogWidgetHover_(obj, widget)
+ %ATTACHPLANTLOGWIDGETHOVER_ Lazy-construct a PlantLogWidgetHover for one widget (Phase 1032 PLOG-VIZ-07).
+ % Tears down any prior hover for this widget first (idempotent),
+ % then builds a new PlantLogWidgetHover parented to the widget's
+ % uifigure ancestor and storing the lookup closure that routes
+ % through obj.lookupPlantLogEntries_ (re-reads the store at call
+ % time so subsequent swaps are reflected immediately).
+ %
+ % Early returns:
+ % - widget is empty or not a FastSenseWidget
+ % - widget.FastSenseObj is empty / not rendered
+ % - engine has no PlantLogStoreInternal_
+ % - widget.FastSenseObj.hAxes is missing / invalid
+ %
+ % On failure, fires DashboardEngine:plantLogOverlayFailed warning.
+ if isempty(widget) || ~isa(widget, 'FastSenseWidget'), return; end
+ if isempty(widget.FastSenseObj) || ~widget.FastSenseObj.IsRendered
+ return;
+ end
+ % Tear down any prior hover for this widget.
+ obj.detachPlantLogWidgetHover_(widget);
+ % Require a store attached.
+ if isempty(obj.PlantLogStoreInternal_) || ...
+ ~isa(obj.PlantLogStoreInternal_, 'PlantLogStore')
+ return;
+ end
+ try
+ ax = widget.FastSenseObj.hAxes;
+ if isempty(ax) || ~ishandle(ax), return; end
+ fig = ancestor(ax, 'figure');
+ if isempty(fig) || ~ishandle(fig), return; end
+ hover = PlantLogWidgetHover(fig, ax, ...
+ @(t0, t1) obj.lookupPlantLogEntries_(t0, t1));
+ obj.WidgetHovers_{end+1} = {widget, hover};
+ catch err
+ warning('DashboardEngine:plantLogOverlayFailed', ...
+ 'attachPlantLogWidgetHover_ failed: %s', err.message);
+ end
+ end
+
+ function detachPlantLogWidgetHover_(obj, widget)
+ %DETACHPLANTLOGWIDGETHOVER_ Tear down + remove a widget's hover (Phase 1032 PLOG-VIZ-07).
+ % Idempotent: safe when widget has no hover currently registered.
+ % Also sweeps stale-widget pairs (widget already destroyed) so the
+ % WidgetHovers_ list stays compact.
+ if isempty(widget), return; end
+ keep = true(1, numel(obj.WidgetHovers_));
+ for i = 1:numel(obj.WidgetHovers_)
+ pair = obj.WidgetHovers_{i};
+ if isempty(pair) || numel(pair) ~= 2
+ keep(i) = false;
+ continue;
+ end
+ pairWidget = pair{1};
+ pairHover = pair{2};
+ if isempty(pairWidget) || ~isvalid(pairWidget)
+ try
+ if ~isempty(pairHover) && isvalid(pairHover)
+ delete(pairHover);
+ end
+ catch
+ end
+ keep(i) = false;
+ continue;
+ end
+ if pairWidget == widget
+ try
+ if ~isempty(pairHover) && isvalid(pairHover)
+ delete(pairHover);
+ end
+ catch
+ end
+ keep(i) = false;
+ end
+ end
+ obj.WidgetHovers_ = obj.WidgetHovers_(keep);
+ end
+
+ end
+
methods (Access = private)
function tf = isObjValid_(obj)
@@ -2376,6 +3168,18 @@ function removeDetachedByRef(obj, mirrorHolder)
if isempty(target)
return;
end
+ % Phase 1032 PLOG-VIZ-07 — sweep the closing mirror's hover from
+ % WidgetHovers_ before removing it from DetachedMirrors. We do this
+ % up front (inside the isvalid check) so that even if the
+ % keep-filter loop encounters a stale handle, the cleanup already
+ % ran. The detach helper is idempotent on missing widgets.
+ try
+ if isa(target, 'DetachedMirror') && ...
+ isa(target.Widget, 'FastSenseWidget')
+ obj.detachPlantLogWidgetHover_(target.Widget);
+ end
+ catch
+ end
keep = true(1, numel(obj.DetachedMirrors));
for i = 1:numel(obj.DetachedMirrors)
if obj.DetachedMirrors{i} == target
@@ -3009,6 +3813,180 @@ function computeEventMarkers(obj)
obj.TimeRangeSelector_.setEventMarkers(uTimes, uColors);
end
+ function computePlantLogMarkers(obj)
+ %COMPUTEPLANTLOGMARKERS Push current plant-log entry timestamps onto the slider.
+ % Phase 1031 PLOG-VIZ-01..02 + 08: plant-log overlay on TimeRangeSelector.
+ % Mirrors computeEventMarkers' guard pattern (no-op before render or when
+ % no store is attached). When PlantLogStoreInternal_ is empty the markers
+ % are explicitly cleared so detach takes effect immediately.
+ %
+ % Called at the same hook sites as computeEventMarkers (addPage,
+ % setEventMarkersVisible, rerenderWidgets, both live-tick paths) plus
+ % from setPlantLogStoreForTest_ and from the PlantLogTickListener_
+ % callback installed by setPlantLogLiveTailForTest_.
+ if isempty(obj.TimeRangeSelector_) || ...
+ ~isa(obj.TimeRangeSelector_, 'TimeRangeSelector')
+ return;
+ end
+ if isempty(obj.PlantLogStoreInternal_) || ...
+ ~isa(obj.PlantLogStoreInternal_, 'PlantLogStore')
+ try
+ obj.TimeRangeSelector_.setPlantLogMarkers([]);
+ catch
+ end
+ return;
+ end
+ try
+ t0 = obj.TimeRangeSelector_.DataRange(1);
+ t1 = obj.TimeRangeSelector_.DataRange(2);
+ entries = obj.PlantLogStoreInternal_.getEntriesInRange(t0, t1);
+ if isempty(entries)
+ obj.TimeRangeSelector_.setPlantLogMarkers([]);
+ return;
+ end
+ times = [entries.Timestamp];
+ obj.TimeRangeSelector_.setPlantLogMarkers(times);
+ catch err
+ fprintf('[ENGINE WARN] computePlantLogMarkers: %s\n', err.message);
+ end
+ end
+
+ function onPlantLogTailTick_(obj)
+ %ONPLANTLOGTAILTICK_ PlantLogTailTick callback — fan out slider + widgets + mirrors (Phase 1032 PLOG-VIZ-08).
+ % Wraps the existing computePlantLogMarkers (slider path) and
+ % adds the per-widget refresh fan-out for every ShowPlantLog=true
+ % widget across pages AND every DetachedMirror (decision G —
+ % full parity).
+ try
+ obj.computePlantLogMarkers();
+ catch err
+ warning('DashboardEngine:plantLogOverlayFailed', ...
+ 'computePlantLogMarkers (tick): %s', err.message);
+ end
+ % Fan out to attached widgets.
+ ws = obj.allPageWidgets();
+ for i = 1:numel(ws)
+ w = ws{i};
+ if isa(w, 'FastSenseWidget') && w.ShowPlantLog
+ try obj.refreshPlantLogOverlayForWidget_(w); catch, end
+ end
+ end
+ % Fan out to detached mirrors (decision G — full parity).
+ for k = 1:numel(obj.DetachedMirrors)
+ m = obj.DetachedMirrors{k};
+ if isempty(m) || ~isvalid(m), continue; end
+ w = m.Widget;
+ if isa(w, 'FastSenseWidget') && w.ShowPlantLog
+ try obj.refreshPlantLogOverlayForWidget_(w); catch, end
+ end
+ end
+ end
+
+ function entries = lookupPlantLogEntries_(obj, t0, t1)
+ %LOOKUPPLANTLOGENTRIES_ Phase 1031 PLOG-VIZ-06 indirect store lookup.
+ % Helper consumed by the PlantLogSliderHover closure. Re-reads
+ % obj.PlantLogStoreInternal_ AT CALL TIME so subsequent store swaps
+ % (via setPlantLogStoreForTest_(other)) are reflected immediately
+ % without rebuilding the hover closure. Returns [] when no store
+ % is attached or when the lookup throws.
+ entries = [];
+ if isempty(obj.PlantLogStoreInternal_) || ...
+ ~isa(obj.PlantLogStoreInternal_, 'PlantLogStore')
+ return;
+ end
+ try
+ entries = obj.PlantLogStoreInternal_.getEntriesInRange(t0, t1);
+ catch
+ entries = [];
+ end
+ end
+
+ function teardownPlantLogSliderHover_(obj)
+ %TEARDOWNPLANTLOGSLIDERHOVER_ Phase 1031 PLOG-VIZ-06 hover teardown.
+ % Idempotent: safe to call when PlantLogSliderHover_ is empty,
+ % already-deleted, or constructed but never installed. delete()
+ % restores the prior WindowButtonMotionFcn.
+ try
+ if ~isempty(obj.PlantLogSliderHover_) && ...
+ isvalid(obj.PlantLogSliderHover_)
+ delete(obj.PlantLogSliderHover_);
+ end
+ catch
+ end
+ obj.PlantLogSliderHover_ = [];
+ end
+
+ function [recovered, store] = surfacePlantLogLoadFailure_(~, ME, filePath)
+ %SURFACEPLANTLOGLOADFAILURE_ Phase 1033 PLOG-INT-05 load-failure warning router.
+ % Inspects ME.identifier and emits the appropriate namespaced
+ % warning per CONTEXT.md D-12:
+ % PlantLogReader:fileNotFound -> DashboardEngine:plantLogPathMissing
+ % PlantLogReader:readError -> DashboardEngine:plantLogReadFailed
+ % PlantLogReader:unsupportedFormat -> DashboardEngine:plantLogReadFailed
+ % PlantLogReader:xlsxUnavailable -> DashboardEngine:plantLogReadFailed
+ % other -> DashboardEngine:plantLogReadFailed
+ %
+ % Returns recovered=false + store=[] in every case. The caller
+ % is responsible for returning from attachPlantLog so the
+ % dashboard load proceeds without an overlay.
+ recovered = false;
+ store = [];
+ switch ME.identifier
+ case 'PlantLogReader:fileNotFound'
+ warning('DashboardEngine:plantLogPathMissing', ...
+ ['Saved plant-log path %s no longer exists; ' ...
+ 'dashboard loaded without overlay. Re-attach via ' ...
+ 'DashboardEngine.attachPlantLog or the FastSenseCompanion toolbar.'], ...
+ filePath);
+ otherwise
+ warning('DashboardEngine:plantLogReadFailed', ...
+ ['Saved plant-log re-import failed: %s; ' ...
+ 'dashboard loaded without overlay.'], ...
+ ME.message);
+ end
+ end
+
+ function readerMapping = plantLogMappingToReaderShape_(~, jsonMapping)
+ %PLANTLOGMAPPINGTOREADERSHAPE_ Convert CONTEXT.md JSON-schema mapping to PlantLogReader shape.
+ % jsonMapping fields: timestampCol, messageCol, metadataCols, format
+ % readerMapping fields: TimestampColumn, MessageColumn, TimestampFormat
+ % metadataCols is informational only -- PlantLogReader infers metadata
+ % columns as "every non-timestamp/non-message column" at read time.
+ readerMapping = struct('TimestampColumn', '', 'MessageColumn', '', 'TimestampFormat', '');
+ if isfield(jsonMapping, 'timestampCol')
+ readerMapping.TimestampColumn = char(jsonMapping.timestampCol);
+ end
+ if isfield(jsonMapping, 'messageCol')
+ readerMapping.MessageColumn = char(jsonMapping.messageCol);
+ end
+ if isfield(jsonMapping, 'format')
+ readerMapping.TimestampFormat = char(jsonMapping.format);
+ end
+ % Back-compat: accept PascalCase if caller passed reader-shape directly.
+ if isfield(jsonMapping, 'TimestampColumn')
+ readerMapping.TimestampColumn = char(jsonMapping.TimestampColumn);
+ end
+ if isfield(jsonMapping, 'MessageColumn')
+ readerMapping.MessageColumn = char(jsonMapping.MessageColumn);
+ end
+ if isfield(jsonMapping, 'TimestampFormat')
+ readerMapping.TimestampFormat = char(jsonMapping.TimestampFormat);
+ end
+ end
+
+ function jsonMapping = readerMappingToJsonShape_(~, readerMapping)
+ %READERMAPPINGTOJSONSHAPE_ Convert PlantLogReader mapping shape to JSON-schema for serialization.
+ % metadataCols is computed from the source file at read time but
+ % stored as a cell array on the engine -- read from a freshly
+ % parsed file or left empty (Plan 02 serializer also persists it
+ % as a cellstr; empty is acceptable).
+ jsonMapping = struct( ...
+ 'timestampCol', readerMapping.TimestampColumn, ...
+ 'messageCol', readerMapping.MessageColumn, ...
+ 'metadataCols', {{}}, ...
+ 'format', readerMapping.TimestampFormat);
+ end
+
end
methods (Access = public)
@@ -3252,6 +4230,65 @@ function onFigureDestroyed_(obj)
end
end
end
+
+ % --- Phase 1033 PLOG-INT-05: re-attach plant log when present ---
+ % Per CONTEXT.md D-10..D-13: when config.plantLog is
+ % present, call attachPlantLog with ContinueOnReadError=true
+ % so any saved-path/mapping/read failure degrades to a
+ % warning and the dashboard load completes. When absent,
+ % v1.0-v3.0 dashboards load cleanly with zero warnings.
+ if isfield(config, 'plantLog') && ~isempty(config.plantLog)
+ pl = config.plantLog;
+ % Schema validation: sourcePath is required.
+ if ~isfield(pl, 'sourcePath') || isempty(pl.sourcePath)
+ error('DashboardSerializer:plantLogSchemaInvalid', ...
+ 'plantLog block must contain a non-empty sourcePath.');
+ end
+ sourcePath = char(pl.sourcePath);
+ mapping = [];
+ if isfield(pl, 'mapping') && ~isempty(pl.mapping)
+ mapping = pl.mapping;
+ end
+ interval = 5;
+ if isfield(pl, 'interval') && ~isempty(pl.interval)
+ interval = double(pl.interval);
+ end
+ startTail = true;
+ if isfield(pl, 'startTail') && ~isempty(pl.startTail)
+ startTail = logical(pl.startTail);
+ end
+ % Pre-flight: if file is missing, surface the warning
+ % BEFORE attachPlantLog so callers see the warn even
+ % when the autoDetect path is bypassed (i.e. caller
+ % supplied a mapping).
+ if exist(sourcePath, 'file') ~= 2
+ warning('DashboardEngine:plantLogPathMissing', ...
+ ['Saved plant-log path %s no longer exists; ' ...
+ 'dashboard loaded without overlay. Re-attach via ' ...
+ 'DashboardEngine.attachPlantLog or the FastSenseCompanion toolbar.'], ...
+ sourcePath);
+ else
+ attachArgs = {};
+ if isstruct(mapping)
+ attachArgs{end+1} = 'Mapping';
+ attachArgs{end+1} = mapping;
+ end
+ attachArgs{end+1} = 'Interval';
+ attachArgs{end+1} = interval;
+ attachArgs{end+1} = 'StartTail';
+ attachArgs{end+1} = startTail;
+ attachArgs{end+1} = 'ContinueOnReadError';
+ attachArgs{end+1} = true;
+ try
+ obj.attachPlantLog(sourcePath, attachArgs{:});
+ catch attachErr
+ warning('DashboardEngine:plantLogReadFailed', ...
+ ['Saved plant-log re-import failed: %s; ' ...
+ 'dashboard loaded without overlay.'], ...
+ attachErr.message);
+ end
+ end
+ end
end
end
end
diff --git a/libs/Dashboard/DashboardLayout.m b/libs/Dashboard/DashboardLayout.m
index 5e1c72e4..d3e0bca9 100644
--- a/libs/Dashboard/DashboardLayout.m
+++ b/libs/Dashboard/DashboardLayout.m
@@ -25,6 +25,7 @@
CreateEventCallback = [] % function handle: @(widget) — set by DashboardEngine
% (260513-snt). Only invoked for FastSenseWidget.
VisibleRows = [1 Inf] % [topRow bottomRow] currently visible
+ EngineRef = [] % Phase 1032 PLOG-VIZ-05 — back-reference to DashboardEngine for chrome callbacks (addPlantLogToggle)
end
properties (SetAccess = private)
@@ -393,18 +394,29 @@ function realizeWidget(obj, widget)
if ~isempty(obj.DetachCallback) && ~isa(widget, 'DividerWidget')
obj.addDetachButton(widget);
end
- % 260513-sfp — Y-limit-mode buttons. Duck-typed: only
+ % v4.0 260513-sfp — Y-limit-mode buttons. Duck-typed: only
% widgets that implement setYLimitMode opt in (today
% only FastSenseWidget). Lives strictly under needsBar
% because the cluster requires the WidgetButtonBar host.
if ismethod(widget, 'setYLimitMode')
obj.addYLimitButtons_(widget);
end
- % 260513-snt — settle final right-anchored button positions.
+ % v3.1 Phase 1032 PLOG-VIZ-05: plant-log toggle on FastSenseWidget only.
+ if isa(widget, 'FastSenseWidget')
+ try
+ engineRef = obj.EngineRef;
+ obj.addPlantLogToggle(widget, engineRef);
+ catch ME
+ warning('DashboardLayout:plantLogToggleParentMissing', ...
+ 'addPlantLogToggle failed during realizeWidget: %s', ME.message);
+ end
+ end
+ % v4.0 260513-snt — settle final right-anchored button positions.
% addInfoIcon runs BEFORE addCreateEventButton, so Info's
% initial X collides with Create's slot. reflowChrome_ knows
- % the full layout (3-button vs 2-button right cluster + V/A
- % left cluster) and re-anchors everything in one pass.
+ % the full layout (V/A left cluster + Info/Create/Detach right
+ % cluster + Plant Log toggle) and re-anchors everything in one
+ % pass.
DashboardLayout.reflowChrome_(widget.hCellPanel, 28, 2);
else
% No chrome — render directly into the cell panel as before.
@@ -612,6 +624,123 @@ function onKeyPressForDismiss(obj, eventData)
end
end
+ function addPlantLogToggle(obj, widget, engine)
+ %ADDPLANTLOGTOGGLE Add the per-widget plant-log overlay toggle (Phase 1032 PLOG-VIZ-05).
+ % The toggle is always created (Decision B: always render, disable
+ % when no store); clicking it calls
+ % widget.setShowPlantLog(~widget.ShowPlantLog, engine).
+ % The engine handle is captured by the callback closure.
+ %
+ % Idempotent: any prior PlantLogToggleButton on the same bar is
+ % deleted before the new uicontrol is created.
+ %
+ % Visibility / pressed-state colors:
+ % - No store attached: Enable='off', tooltip 'No plant log attached'
+ % - Store, ShowPlantLog=false: Enable='on', tooltip 'Show plant log lines',
+ % bg=theme.ToolbarBackground, fg=theme.ToolbarFontColor
+ % - Store, ShowPlantLog=true: Enable='on', tooltip 'Hide plant log lines',
+ % bg=theme.MarkerPlantLog ([0 0 0]), fg=[1 1 1]
+ %
+ % Errors namespaced 'DashboardLayout:plantLogToggleParentMissing'
+ % for callback-time parent-missing failures.
+ if isempty(widget.ParentTheme) || ~isstruct(widget.ParentTheme)
+ theme = DashboardTheme('light');
+ else
+ theme = widget.ParentTheme;
+ end
+ bar = obj.getOrCreateButtonBar_(widget);
+ % Idempotent: clear any prior PlantLogToggleButton on this bar.
+ prior = findobj(bar, 'Tag', 'PlantLogToggleButton', '-depth', 1);
+ if ~isempty(prior)
+ try delete(prior); catch, end
+ end
+ barPos = get(bar, 'Position');
+ % Position from right edge: Detach (offset 4 + 24-wide) + 4 gap +
+ % Info (24-wide) + 4 gap + PlantLog (24-wide). LeftMost button x:
+ % x = barW - 24 - 4 - 24 - 4 - 24 - 4 = barW - 84
+ xPL = barPos(3) - 24 - 4 - 24 - 4 - 24 - 4;
+ % Resolve enabled/disabled state from the engine store.
+ storeAttached = false;
+ if ~isempty(engine) && isa(engine, 'DashboardEngine')
+ try
+ storeAttached = ~isempty(engine.PlantLogStoreInternal_) && ...
+ isa(engine.PlantLogStoreInternal_, 'PlantLogStore');
+ catch
+ storeAttached = false;
+ end
+ end
+ if storeAttached
+ enableState = 'on';
+ if isa(widget, 'FastSenseWidget') && widget.ShowPlantLog
+ tipStr = 'Hide plant log lines';
+ bgColor = [0 0 0];
+ if isfield(theme, 'MarkerPlantLog')
+ bgColor = theme.MarkerPlantLog;
+ end
+ fgColor = [1 1 1];
+ else
+ tipStr = 'Show plant log lines';
+ bgColor = theme.ToolbarBackground;
+ fgColor = theme.ToolbarFontColor;
+ end
+ else
+ enableState = 'off';
+ tipStr = 'No plant log attached';
+ bgColor = theme.ToolbarBackground;
+ fgColor = theme.ToolbarFontColor;
+ end
+ uicontrol('Parent', bar, ...
+ 'Style', 'pushbutton', ...
+ 'String', 'L', ...
+ 'Units', 'pixels', ...
+ 'Position', [xPL 2 24 24], ...
+ 'FontSize', 9, ...
+ 'FontWeight', 'bold', ...
+ 'ForegroundColor', fgColor, ...
+ 'BackgroundColor', bgColor, ...
+ 'Enable', enableState, ...
+ 'Tag', 'PlantLogToggleButton', ...
+ 'TooltipString', tipStr, ...
+ 'Callback', @(s, ~) obj.onPlantLogTogglePressed_(s, widget, engine));
+ end
+
+ function onPlantLogTogglePressed_(obj, src, widget, engine)
+ %ONPLANTLOGTOGGLEPRESSED_ Toggle button callback — wraps setShowPlantLog with try/catch (Phase 1032 PLOG-VIZ-05).
+ % Programmatic force-call paths (tests, automation) need a
+ % software-level guard for Enable='off' because uicontrols only
+ % honor Enable natively for user-driven mouse clicks.
+ try
+ % Software-level Enable guard: if the button was constructed
+ % with Enable='off' (no store), force-calls must be no-ops.
+ if ~isempty(src) && ishandle(src)
+ try
+ if strcmp(get(src, 'Enable'), 'off')
+ return;
+ end
+ catch
+ end
+ end
+ if ~isa(widget, 'FastSenseWidget')
+ error('DashboardLayout:plantLogToggleParentMissing', ...
+ 'PlantLog toggle requires a FastSenseWidget parent.');
+ end
+ widget.setShowPlantLog(~widget.ShowPlantLog, engine);
+ % Rebuild the button look (pressed-state colors + tooltip).
+ obj.addPlantLogToggle(widget, engine);
+ catch ME
+ warning('DashboardLayout:plantLogToggleParentMissing', ...
+ 'Plant-log toggle callback failed: %s', ME.message);
+ % Best-effort: non-blocking uialert if a uifigure ancestor exists.
+ try
+ fig = ancestor(src, 'figure');
+ if ~isempty(fig) && ishandle(fig) && isa(fig, 'matlab.ui.Figure')
+ uialert(fig, ME.message, 'Plant log toggle failed', 'Icon', 'error');
+ end
+ catch
+ end
+ end
+ end
+
end
methods (Access = private)
@@ -962,19 +1091,35 @@ function reflowChrome_(hCell, barH, inset)
set(info(1), 'Position', [barW - 24 - 24 - 4 - 4, 2, 24, 24]);
end
end
- % Re-anchor the V/A cluster. Math must match
- % addYLimitButtons_ exactly so resize does not introduce
- % drift. When the '+' button is present, the right cluster
- % widens by one button (Info + Create + Detach instead of
- % Info + Detach), so the V/A cluster shifts left by (bw+gap).
+ % Re-anchor the v3.1 PlantLogToggleButton + the v4.0 V/A
+ % cluster. The PlantLog button sits LEFTMOST in the
+ % right-anchored cluster (Detach + Create + Info + PlantLog),
+ % then the V/A cluster sits to the LEFT of PlantLog.
bw = 24; gap = 4;
allBtn = findobj(bar(1), 'Tag', 'YLimitAllBtn', '-depth', 1);
visibleBtn = findobj(bar(1), 'Tag', 'YLimitVisibleBtn', '-depth', 1);
+ pl = findobj(bar(1), 'Tag', 'PlantLogToggleButton', '-depth', 1);
hasCreate = ~isempty(create) && ishandle(create(1));
- if hasCreate
- xAll = barW - bw - gap - bw - gap - bw - gap - gap - bw;
+ hasPlantLog = ~isempty(pl) && ishandle(pl(1));
+ if hasPlantLog
+ if hasCreate
+ % 4-button right cluster (Detach + Create + Info + PlantLog).
+ xPl = barW - 4*bw - 4*gap;
+ else
+ % 3-button right cluster (Detach + Info + PlantLog).
+ xPl = barW - 3*bw - 3*gap;
+ end
+ set(pl(1), 'Position', [xPl, 2, bw, bw]);
+ end
+ % V/A cluster: sit immediately LEFT of the leftmost right-
+ % cluster button (PlantLog when present, else Info). Same
+ % gap convention (4px between right cluster and A).
+ if hasPlantLog
+ xAll = xPl - gap - bw;
+ elseif hasCreate
+ xAll = barW - 3*bw - 3*gap - gap - bw; % left of Info
else
- xAll = barW - bw - gap - bw - gap - gap - bw;
+ xAll = barW - 2*bw - 2*gap - gap - bw; % left of Info (no Create)
end
xVisible = xAll - bw;
if ~isempty(allBtn) && ishandle(allBtn(1))
diff --git a/libs/Dashboard/DashboardSerializer.m b/libs/Dashboard/DashboardSerializer.m
index 4fe76e77..151559f6 100644
--- a/libs/Dashboard/DashboardSerializer.m
+++ b/libs/Dashboard/DashboardSerializer.m
@@ -34,27 +34,57 @@ function save(config, filepath)
switch ws.type
case 'fastsense'
+ showPl = isfield(ws, 'showPlantLog') && ws.showPlantLog;
if isfield(ws, 'source')
switch ws.source.type
case 'sensor'
lines{end+1} = sprintf(' w = d.addWidget(''fastsense'', ''Title'', ''%s'', ...', ws.title);
lines{end+1} = sprintf(' ''Position'', %s, ...', pos);
- lines{end+1} = sprintf(' ''Tag'', TagRegistry.get(''%s''));', ws.source.name);
+ if showPl
+ lines{end+1} = sprintf(' ''Tag'', TagRegistry.get(''%s''), ...', ws.source.name);
+ lines{end+1} = sprintf(' ''ShowPlantLog'', true);');
+ else
+ lines{end+1} = sprintf(' ''Tag'', TagRegistry.get(''%s''));', ws.source.name);
+ end
case 'file'
lines{end+1} = sprintf(' w = d.addWidget(''fastsense'', ''Title'', ''%s'', ...', ws.title);
lines{end+1} = sprintf(' ''Position'', %s, ...', pos);
- lines{end+1} = sprintf(' ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'');', ...
- ws.source.path, ws.source.xVar, ws.source.yVar);
+ if showPl
+ lines{end+1} = sprintf(' ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'', ...', ...
+ ws.source.path, ws.source.xVar, ws.source.yVar);
+ lines{end+1} = sprintf(' ''ShowPlantLog'', true);');
+ else
+ lines{end+1} = sprintf(' ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'');', ...
+ ws.source.path, ws.source.xVar, ws.source.yVar);
+ end
case 'data'
lines{end+1} = sprintf(' w = d.addWidget(''fastsense'', ''Title'', ''%s'', ...', ws.title);
lines{end+1} = sprintf(' ''Position'', %s, ...', pos);
- lines{end+1} = sprintf(' ''XData'', %s, ''YData'', %s);', ...
- mat2str(ws.source.x), mat2str(ws.source.y));
+ if showPl
+ lines{end+1} = sprintf(' ''XData'', %s, ''YData'', %s, ...', ...
+ mat2str(ws.source.x), mat2str(ws.source.y));
+ lines{end+1} = sprintf(' ''ShowPlantLog'', true);');
+ else
+ lines{end+1} = sprintf(' ''XData'', %s, ''YData'', %s);', ...
+ mat2str(ws.source.x), mat2str(ws.source.y));
+ end
otherwise
- lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
+ if showPl
+ lines{end+1} = sprintf( ...
+ ' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s, ''ShowPlantLog'', true);', ...
+ ws.title, pos);
+ else
+ lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
+ end
end
else
- lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
+ if showPl
+ lines{end+1} = sprintf( ...
+ ' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s, ''ShowPlantLog'', true);', ...
+ ws.title, pos);
+ else
+ lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
+ end
end
case 'number'
line = sprintf(' d.addWidget(''number'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
@@ -129,6 +159,10 @@ function save(config, filepath)
lines{end+1} = '';
end
+ % Phase 1033 PLOG-INT-04 -- emit attachPlantLog block when present
+ plantLogLines = DashboardSerializer.linesForPlantLog_(config, ' ');
+ lines = [lines, plantLogLines];
+
lines{end+1} = 'end';
fid = fopen(filepath, 'w');
@@ -144,6 +178,23 @@ function saveJSON(config, filepath)
% Handles both single-page (widgets field) and multi-page (pages field).
% Widgets/pages may have heterogeneous fields, so encode each entry
% individually and assemble the JSON array by hand.
+ %
+ % Phase 1033 PLOG-INT-04: when config.plantLog is present,
+ % the plant-log block is hand-encoded and spliced in so the
+ % metadataCols cell-array preserves its [...] JSON shape.
+ % Omitted entirely when config has no plantLog field
+ % (byte-identical back-compat for v1.0-v3.0 dashboards).
+
+ % --- Strip plantLog from the top-level struct BEFORE jsonencode
+ % so jsonencode never sees the cell-of-cells shape (which can
+ % be ambiguous across MATLAB versions). We splice it back in
+ % below.
+ hasPlantLog = isfield(config, 'plantLog');
+ if hasPlantLog
+ plantLogBlock = config.plantLog;
+ config = rmfield(config, 'plantLog');
+ end
+
if isfield(config, 'pages')
% Multi-page path: encode each page individually
pageParts = cell(1, numel(config.pages));
@@ -177,6 +228,13 @@ function saveJSON(config, filepath)
topJson = [topJson(1:end-1), ',"widgets":', widgetsJson, '}'];
end
+ % --- Phase 1033 PLOG-INT-04: splice plantLog block at the end
+ % of topJson (just before the closing brace).
+ if hasPlantLog
+ plantLogJson = DashboardSerializer.encodePlantLogBlock_(plantLogBlock);
+ topJson = [topJson(1:end-1), ',"plantLog":', plantLogJson, '}'];
+ end
+
fid = fopen(filepath, 'w');
if fid == -1
error('DashboardSerializer:fileError', 'Cannot open file: %s', filepath);
@@ -185,6 +243,59 @@ function saveJSON(config, filepath)
fclose(fid);
end
+ function jsonStr = encodePlantLogBlock_(pl)
+ %ENCODEPLANTLOGBLOCK_ Hand-encode the plantLog block as a JSON object.
+ % Used by saveJSON to preserve the metadataCols cell-array
+ % shape (jsonencode of {} is ambiguous across MATLAB versions).
+ % Returns a JSON object string with sourcePath, mapping,
+ % interval, and startTail keys in stable order.
+ tsCol = '';
+ msgCol = '';
+ fmt = '';
+ mc = {};
+ if isfield(pl, 'mapping') && isstruct(pl.mapping)
+ m = pl.mapping;
+ if isfield(m, 'timestampCol'); tsCol = char(m.timestampCol); end
+ if isfield(m, 'messageCol'); msgCol = char(m.messageCol); end
+ if isfield(m, 'format'); fmt = char(m.format); end
+ if isfield(m, 'metadataCols') && iscell(m.metadataCols)
+ mc = m.metadataCols;
+ end
+ end
+ if isempty(mc)
+ metaJson = '[]';
+ else
+ mcParts = cell(1, numel(mc));
+ for mci = 1:numel(mc)
+ mcParts{mci} = ['"', strrep(char(mc{mci}), '"', '\"'), '"'];
+ end
+ metaJson = ['[', strjoin(mcParts, ','), ']'];
+ end
+ mappingJson = sprintf( ...
+ '{"timestampCol":"%s","messageCol":"%s","metadataCols":%s,"format":"%s"}', ...
+ strrep(tsCol, '"', '\"'), ...
+ strrep(msgCol, '"', '\"'), ...
+ metaJson, ...
+ strrep(fmt, '"', '\"'));
+ sourcePath = '';
+ if isfield(pl, 'sourcePath'); sourcePath = char(pl.sourcePath); end
+ interval = 5;
+ if isfield(pl, 'interval'); interval = double(pl.interval); end
+ startTail = true;
+ if isfield(pl, 'startTail'); startTail = logical(pl.startTail); end
+ if startTail
+ startTailStr = 'true';
+ else
+ startTailStr = 'false';
+ end
+ jsonStr = sprintf( ...
+ '{"sourcePath":"%s","mapping":%s,"interval":%g,"startTail":%s}', ...
+ strrep(sourcePath, '"', '\"'), ...
+ mappingJson, ...
+ interval, ...
+ startTailStr);
+ end
+
function result = load(filepath)
%LOAD Load dashboard config from file.
% For .m files: uses feval to execute the function and return the engine.
@@ -382,6 +493,10 @@ function exportScript(config, filepath)
lines{end+1} = '';
end
+ % Phase 1033 PLOG-INT-04 -- emit attachPlantLog block when present
+ plantLogLines = DashboardSerializer.linesForPlantLog_(config, '');
+ lines = [lines, plantLogLines];
+
lines{end+1} = 'd.render();';
fid = fopen(filepath, 'w');
@@ -443,6 +558,10 @@ function exportScriptPages(config, filepath)
lines{end+1} = '';
end
+ % Phase 1033 PLOG-INT-04 -- emit attachPlantLog block when present
+ plantLogLines = DashboardSerializer.linesForPlantLog_(config, ' ');
+ lines = [lines, plantLogLines];
+
lines{end+1} = ' d.render();';
lines{end+1} = 'end';
@@ -585,6 +704,74 @@ function exportScriptPages(config, filepath)
end
methods (Static, Access = private)
+ function plLines = linesForPlantLog_(config, indent)
+ %LINESFORPLANTLOG_ Phase 1033 PLOG-INT-04 .m-script attachPlantLog emitter.
+ % Returns the cell array of lines to insert before the
+ % closing d.render() / end of a .m-script export. When
+ % config.plantLog is absent or empty, returns an empty cell
+ % array so the .m-script output stays byte-identical to
+ % pre-1033 for v1.0-v3.0 dashboards.
+ %
+ % The metadataCols field uses double-brace {{...}} syntax so
+ % struct() preserves the cell-array shape when the .m-script
+ % re-creates the mapping at load time.
+ plLines = {};
+ if ~isfield(config, 'plantLog') || isempty(config.plantLog)
+ return;
+ end
+ pl = config.plantLog;
+ if ~isfield(pl, 'sourcePath') || isempty(pl.sourcePath)
+ return;
+ end
+ mc = {};
+ if isfield(pl, 'mapping') && isstruct(pl.mapping) && ...
+ isfield(pl.mapping, 'metadataCols') && ...
+ iscell(pl.mapping.metadataCols)
+ mc = pl.mapping.metadataCols;
+ end
+ if isempty(mc)
+ metaCellLit = '{{}}';
+ else
+ mcQuoted = cell(1, numel(mc));
+ for mci = 1:numel(mc)
+ mcQuoted{mci} = ['''', strrep(char(mc{mci}), '''', ''''''), ''''];
+ end
+ metaCellLit = ['{{', strjoin(mcQuoted, ','), '}}'];
+ end
+ tsCol = '';
+ msgCol = '';
+ fmt = '';
+ if isfield(pl, 'mapping') && isstruct(pl.mapping)
+ m = pl.mapping;
+ if isfield(m, 'timestampCol'); tsCol = char(m.timestampCol); end
+ if isfield(m, 'messageCol'); msgCol = char(m.messageCol); end
+ if isfield(m, 'format'); fmt = char(m.format); end
+ end
+ interval = 5;
+ if isfield(pl, 'interval'); interval = double(pl.interval); end
+ startTail = true;
+ if isfield(pl, 'startTail'); startTail = logical(pl.startTail); end
+ if startTail
+ startTailStr = 'true';
+ else
+ startTailStr = 'false';
+ end
+ plLines{end+1} = '';
+ plLines{end+1} = sprintf('%s%% Phase 1033 PLOG-INT-04 -- plant log attachment', indent);
+ plLines{end+1} = sprintf('%sd.attachPlantLog(''%s'', ...', indent, ...
+ strrep(char(pl.sourcePath), '''', ''''''));
+ plLines{end+1} = sprintf('%s ''Mapping'', struct( ...', indent);
+ plLines{end+1} = sprintf('%s ''timestampCol'', ''%s'', ...', indent, ...
+ strrep(tsCol, '''', ''''''));
+ plLines{end+1} = sprintf('%s ''messageCol'', ''%s'', ...', indent, ...
+ strrep(msgCol, '''', ''''''));
+ plLines{end+1} = sprintf('%s ''metadataCols'', %s, ...', indent, metaCellLit);
+ plLines{end+1} = sprintf('%s ''format'', ''%s''), ...', indent, ...
+ strrep(fmt, '''', ''''''));
+ plLines{end+1} = sprintf('%s ''Interval'', %g, ...', indent, interval);
+ plLines{end+1} = sprintf('%s ''StartTail'', %s);', indent, startTailStr);
+ end
+
function wLines = linesForWidget(ws, pos, indent)
%LINESFORWIDGET Generate addWidget code lines for a single widget struct.
% ws - widget config struct
@@ -594,27 +781,59 @@ function exportScriptPages(config, filepath)
wLines = {};
switch ws.type
case 'fastsense'
+ % Phase 1033 PLOG-INT-04: emit ShowPlantLog NV pair on
+ % widgets whose toStruct returned showPlantLog=true.
+ showPl = isfield(ws, 'showPlantLog') && ws.showPlantLog;
if isfield(ws, 'source')
switch ws.source.type
case 'sensor'
wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ...', indent, ws.title);
wLines{end+1} = sprintf('%s ''Position'', %s, ...', indent, pos);
- wLines{end+1} = sprintf('%s ''Tag'', TagRegistry.get(''%s''));', indent, ws.source.name);
+ if showPl
+ wLines{end+1} = sprintf('%s ''Tag'', TagRegistry.get(''%s''), ...', indent, ws.source.name);
+ wLines{end+1} = sprintf('%s ''ShowPlantLog'', true);', indent);
+ else
+ wLines{end+1} = sprintf('%s ''Tag'', TagRegistry.get(''%s''));', indent, ws.source.name);
+ end
case 'file'
wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ...', indent, ws.title);
wLines{end+1} = sprintf('%s ''Position'', %s, ...', indent, pos);
- wLines{end+1} = sprintf('%s ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'');', ...
- indent, ws.source.path, ws.source.xVar, ws.source.yVar);
+ if showPl
+ wLines{end+1} = sprintf('%s ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'', ...', ...
+ indent, ws.source.path, ws.source.xVar, ws.source.yVar);
+ wLines{end+1} = sprintf('%s ''ShowPlantLog'', true);', indent);
+ else
+ wLines{end+1} = sprintf('%s ''File'', ''%s'', ''XVar'', ''%s'', ''YVar'', ''%s'');', ...
+ indent, ws.source.path, ws.source.xVar, ws.source.yVar);
+ end
case 'data'
wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ...', indent, ws.title);
wLines{end+1} = sprintf('%s ''Position'', %s, ...', indent, pos);
- wLines{end+1} = sprintf('%s ''XData'', %s, ''YData'', %s);', ...
- indent, mat2str(ws.source.x), mat2str(ws.source.y));
+ if showPl
+ wLines{end+1} = sprintf('%s ''XData'', %s, ''YData'', %s, ...', ...
+ indent, mat2str(ws.source.x), mat2str(ws.source.y));
+ wLines{end+1} = sprintf('%s ''ShowPlantLog'', true);', indent);
+ else
+ wLines{end+1} = sprintf('%s ''XData'', %s, ''YData'', %s);', ...
+ indent, mat2str(ws.source.x), mat2str(ws.source.y));
+ end
otherwise
- wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', indent, ws.title, pos);
+ if showPl
+ wLines{end+1} = sprintf( ...
+ '%sd.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s, ''ShowPlantLog'', true);', ...
+ indent, ws.title, pos);
+ else
+ wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', indent, ws.title, pos);
+ end
end
else
- wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', indent, ws.title, pos);
+ if showPl
+ wLines{end+1} = sprintf( ...
+ '%sd.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s, ''ShowPlantLog'', true);', ...
+ indent, ws.title, pos);
+ else
+ wLines{end+1} = sprintf('%sd.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', indent, ws.title, pos);
+ end
end
case 'number'
line = sprintf('%sd.addWidget(''number'', ''Title'', ''%s'', ''Position'', %s', indent, ws.title, pos);
diff --git a/libs/Dashboard/DashboardTheme.m b/libs/Dashboard/DashboardTheme.m
index e728b576..be2217ee 100644
--- a/libs/Dashboard/DashboardTheme.m
+++ b/libs/Dashboard/DashboardTheme.m
@@ -67,6 +67,7 @@
d.GroupBorderColor = [0.25 0.30 0.40];
d.TabActiveBg = [0.16 0.22 0.34];
d.TabInactiveBg = [0.10 0.12 0.18];
+ d.MarkerPlantLog = [0 0 0]; % Phase 1031 PLOG-VIZ-09: black plant-log slider markers
otherwise % 'light' (also: legacy aliases default/industrial/scientific/ocean)
d.DashboardBackground = [0.96 0.96 0.97];
d.WidgetBackground = [1.00 1.00 1.00];
@@ -81,6 +82,7 @@
d.GroupBorderColor = [0.80 0.82 0.85];
d.TabActiveBg = [0.90 0.92 0.95];
d.TabInactiveBg = [0.82 0.84 0.88];
+ d.MarkerPlantLog = [0 0 0]; % Phase 1031 PLOG-VIZ-09: black plant-log slider markers
end
% Axis label/tick color — derive from toolbar font (readable on widget bg)
diff --git a/libs/Dashboard/DashboardWidget.m b/libs/Dashboard/DashboardWidget.m
index a6895b8e..999379a5 100644
--- a/libs/Dashboard/DashboardWidget.m
+++ b/libs/Dashboard/DashboardWidget.m
@@ -139,11 +139,12 @@ function clearPanelControls(hPanel)
% since 260508 — but the legacy tags are kept in case any pre-bar
% widgets still parent the buttons directly to hPanel.
if isempty(hPanel) || ~ishandle(hPanel), return; end
- % 260513-snt — preserve the per-FastSenseWidget '+Event' button
- % injected by DashboardLayout.addCreateEventButton (Tag='CreateEventButton').
- % 260513-sfp — preserve the V/A Y-limit cluster (YLimitVisibleBtn, YLimitAllBtn).
+ % v3.1 Phase 1032 PLOG-VIZ-05 — protect plant-log toggle from re-render sweeps.
+ % v4.0 — '+Event' button (Tag='CreateEventButton') + V/A Y-limit cluster
+ % (Tags 'YLimitVisibleBtn', 'YLimitAllBtn') also preserved.
protectedTags = {'InfoIconButton', 'DetachButton', 'WidgetButtonBar', ...
- 'YLimitVisibleBtn', 'YLimitAllBtn', 'CreateEventButton'};
+ 'YLimitVisibleBtn', 'YLimitAllBtn', 'CreateEventButton', ...
+ 'PlantLogToggleButton'};
% Sweep depth-1 uicontrols (legacy-positioned buttons).
kids = findobj(hPanel, '-depth', 1, 'Type', 'uicontrol');
for i = 1:numel(kids)
diff --git a/libs/Dashboard/DetachedMirror.m b/libs/Dashboard/DetachedMirror.m
index bf37ece0..4f816ea9 100644
--- a/libs/Dashboard/DetachedMirror.m
+++ b/libs/Dashboard/DetachedMirror.m
@@ -268,6 +268,15 @@ function restoreLiveRefs(cloned, original)
if isprop(cloned, 'EventStore') && ~isempty(original.EventStore)
cloned.EventStore = original.EventStore;
end
+ % Phase 1032 PLOG-VIZ-03 — copy ShowPlantLog boolean from original
+ % to clone. toStruct/fromStruct round-trip (Plan 01) already
+ % preserves the key, but this explicit copy is a belt-and-
+ % suspenders so an accidental future regression in serialization
+ % doesn't silently break detach parity (CONTEXT.md Decision G).
+ if isa(cloned, 'FastSenseWidget') && isa(original, 'FastSenseWidget') && ...
+ isprop(original, 'ShowPlantLog')
+ cloned.ShowPlantLog = original.ShowPlantLog;
+ end
end
function s = stripSensorRefs(s)
diff --git a/libs/Dashboard/FastSenseWidget.m b/libs/Dashboard/FastSenseWidget.m
index 50d644ce..e3ac614e 100644
--- a/libs/Dashboard/FastSenseWidget.m
+++ b/libs/Dashboard/FastSenseWidget.m
@@ -21,6 +21,7 @@
ShowThresholdLabels = false % show inline name labels on threshold lines
ShowEventMarkers = false % Phase 1012 — toggle event round-marker overlay
EventStore = [] % Phase 1012 — EventStore handle forwarded to inner FastSense
+ ShowPlantLog = false % Phase 1032 PLOG-VIZ-03 — opt-in per-widget plant-log vertical-line overlay
% Forwarded to FastSense.LiveViewMode on render:
% 'preserve' — DEFAULT (260513-ovt). Frozen at the initial X
% range: live ticks append data without changing
@@ -81,6 +82,16 @@
PreviewCacheKey_ = [] % [numel(x), x(1), x(end), nBucketsEff] sentinel
end
+ % Phase 1032 — XLim listener slot. Public READ (tests + engine
+ % observe); WRITE via the Hidden setPlantLogXLimListenerForEngine_
+ % setter just below. Plain SetAccess = private avoids the
+ % friend-access classdef syntax that Octave's parser is fussy with
+ % (mirrors the FastSenseDataStore Octave-safe idiom — Hidden over
+ % {?ClassName}).
+ properties (SetAccess = private)
+ PlantLogXLimListener_ = [] % Phase 1032 — addlistener handle for XLim PostSet refresh; non-empty when ShowPlantLog=true and widget is rendered
+ end
+
properties (Access = private, Constant)
% PREVIEWRAWTHRESHOLD_ Sample-count threshold below which
% getPreviewSeries skips downsampling and renders one bucket
@@ -371,6 +382,137 @@ function setEventMarkersVisible(obj, tf)
end
end
+ % Phase 1032 PLOG-VIZ-04
+ function setPlantLogMarkers(obj, times, entries) %#ok
+ %SETPLANTLOGMARKERS Draw or clear per-widget plant-log vertical lines.
+ % Phase 1032 PLOG-VIZ-04. Draws one xline per finite timestamp
+ % on the widget's inner FastSense axes (Tag = 'WidgetPlantLogMarker',
+ % 1 px solid line with theme.MarkerPlantLog color, default
+ % [0 0 0]). Empty / no-arg input clears every existing marker
+ % via tag-based delete. Non-finite timestamps are silently
+ % dropped (mirrors TimeRangeSelector.setPlantLogMarkers shape).
+ %
+ % `entries` is currently unused at the draw layer (hover
+ % lookup goes through the live store, not this snapshot —
+ % see Plan 02). Accepted in the signature for forward-compat
+ % with the engine's refresh helper call site and the Plan 02
+ % hover wiring.
+ %
+ % Z-order: after drawing, plant-log lines are pushed to the
+ % BOTTOM (above sensor trace via FastSense draw-order, below
+ % any FastSenseEventMarker which is re-stacked to the top).
+ % Net stack: sensor trace (back) -> plant-log lines (middle)
+ % -> event badges (front).
+ %
+ % On failure, fires the namespaced warning
+ % FastSenseWidget:plantLogToggleFailed (mirrors the
+ % setEventMarkersVisible error-handling style) and returns.
+ try
+ if isempty(obj.FastSenseObj) || ...
+ ~isa(obj.FastSenseObj, 'FastSense') || ...
+ ~obj.FastSenseObj.IsRendered
+ return;
+ end
+ ax = obj.FastSenseObj.hAxes;
+ if isempty(ax) || ~ishandle(ax)
+ return;
+ end
+ % Tag-based delete of stale markers (mirrors FastSense
+ % renderEventLayer_'s FastSenseEventMarker pattern).
+ delete(findobj(ax, 'Tag', 'WidgetPlantLogMarker'));
+ if nargin < 2 || isempty(times)
+ return;
+ end
+ times = times(:).';
+ times = times(isfinite(times));
+ if isempty(times)
+ return;
+ end
+ % Resolve marker color from theme; default black per
+ % CONTEXT.md decision C ("crisp dividers, not subtle
+ % highlights" — full opacity, no dashing).
+ theme = obj.getTheme();
+ markerColor = [0 0 0];
+ if isstruct(theme) && isfield(theme, 'MarkerPlantLog')
+ markerColor = theme.MarkerPlantLog;
+ end
+ % Draw one xline per timestamp. HitTest='on' +
+ % PickableParts='all' so Plan 02's hover helper can pick
+ % the line.
+ for i = 1:numel(times)
+ xline(ax, times(i), '-', ...
+ 'Color', markerColor, ...
+ 'LineWidth', 1, ...
+ 'Tag', 'WidgetPlantLogMarker', ...
+ 'HitTest', 'on', ...
+ 'PickableParts', 'all');
+ end
+ % Z-order: send plant-log lines below event badges (CONTEXT
+ % decision H). uistack('bottom') puts them behind everything
+ % drawn afterwards; explicit uistack('top') on
+ % FastSenseEventMarker keeps badges visible above plant-log
+ % lines for every (entry, badge) crossing.
+ h = findobj(ax, 'Tag', 'WidgetPlantLogMarker');
+ if ~isempty(h)
+ uistack(h, 'bottom');
+ evt = findobj(ax, 'Tag', 'FastSenseEventMarker');
+ if ~isempty(evt)
+ uistack(evt, 'top');
+ end
+ end
+ catch ME
+ warning('FastSenseWidget:plantLogToggleFailed', ...
+ 'setPlantLogMarkers failed: %s', ME.message);
+ end
+ end
+
+ % Hidden — DashboardEngine writes PlantLogXLimListener_ via this
+ % seam since the property is SetAccess=private. Hidden methods
+ % are callable from anywhere (Octave-safe idiom from
+ % FastSenseDataStore). The listener handle is opaque to the
+ % widget; the engine owns its lifecycle.
+ function setPlantLogXLimListenerForEngine_(obj, lis)
+ obj.PlantLogXLimListener_ = lis;
+ end
+
+ function setShowPlantLog(obj, tf, engine)
+ %SETSHOWPLANTLOG Toggle the per-widget plant-log overlay (Phase 1032 PLOG-VIZ-03).
+ % tf — boolean; true enables overlay + attaches XLim listener,
+ % false disables overlay + tears down listener + clears markers.
+ % engine — DashboardEngine handle; required so refresh + listener
+ % wiring can route through engine.refreshPlantLogOverlayForWidget_
+ % and engine.attachPlantLogXLimListener_.
+ %
+ % On failure, ShowPlantLog is REVERTED to its prior value and a
+ % non-blocking warning fires with namespace
+ % FastSenseWidget:plantLogToggleFailed (matches existing
+ % setEventMarkersVisible error-handling style).
+ priorState = obj.ShowPlantLog;
+ try
+ if isempty(engine) || ~isa(engine, 'DashboardEngine')
+ error('FastSenseWidget:plantLogToggleFailed', ...
+ 'engine must be a DashboardEngine handle.');
+ end
+ obj.ShowPlantLog = logical(tf);
+ if obj.ShowPlantLog
+ engine.attachPlantLogXLimListener_(obj);
+ engine.refreshPlantLogOverlayForWidget_(obj);
+ engine.attachPlantLogWidgetHover_(obj); % Phase 1032 PLOG-VIZ-07
+ else
+ if ~isempty(obj.PlantLogXLimListener_)
+ try delete(obj.PlantLogXLimListener_); catch, end
+ obj.PlantLogXLimListener_ = [];
+ end
+ engine.detachPlantLogWidgetHover_(obj); % Phase 1032 PLOG-VIZ-07
+ obj.setPlantLogMarkers([], []); % clear without engine round-trip
+ end
+ catch ME
+ obj.ShowPlantLog = priorState;
+ warning('FastSenseWidget:plantLogToggleFailed', ...
+ 'setShowPlantLog(%s) failed: %s', mat2str(logical(tf)), ME.message);
+ end
+ end
+
function setYLimitMode(obj, mode)
%SETYLIMITMODE Set the Y-axis rescale strategy and re-fit if rendered.
% mode is one of:
@@ -989,7 +1131,8 @@ function invalidatePreviewCache_(obj)
if ~isempty(obj.YLimits), s.yLimits = obj.YLimits; end
if obj.ShowThresholdLabels, s.showThresholdLabels = true; end
if obj.ShowEventMarkers, s.showEventMarkers = true; end
- % Emit yLimitMode only when non-default so pre-260513-sfp JSON
+ if obj.ShowPlantLog, s.showPlantLog = true; end % v3.1 Phase 1032 PLOG-VIZ-03
+ % v4.0 — emit yLimitMode only when non-default so pre-260513-sfp JSON
% stays byte-identical (keeps diffs invisible for old
% dashboards that never opted into a mode).
if ~strcmp(obj.YLimitMode, 'auto-visible')
@@ -1009,6 +1152,12 @@ function invalidatePreviewCache_(obj)
end
function delete(obj)
+ % Phase 1032 — release XLim PostSet listener before FastSenseObj
+ % teardown deletes the axes the listener is bound to.
+ if ~isempty(obj.PlantLogXLimListener_)
+ try delete(obj.PlantLogXLimListener_); catch, end
+ obj.PlantLogXLimListener_ = [];
+ end
% Explicitly stop FastSense timers (hRefineTimer, LiveTimer,
% DeferredTimer) before the base-class delete() destroys hPanel.
% Without this, an errored singleShot hRefineTimer can survive
@@ -1330,7 +1479,10 @@ function rebuildForTag_(obj)
if isfield(s, 'showEventMarkers')
obj.ShowEventMarkers = s.showEventMarkers;
end
- % 260513-sfp — restore YLimitMode if serialized. Absent means
+ if isfield(s, 'showPlantLog') % v3.1 Phase 1032 PLOG-VIZ-03
+ obj.ShowPlantLog = s.showPlantLog;
+ end
+ % v4.0 260513-sfp — restore YLimitMode if serialized. Absent means
% "legacy dashboard, default to 'auto-visible'" so behaviour
% is byte-identical for old configs.
if isfield(s, 'yLimitMode')
diff --git a/libs/Dashboard/TimeRangeSelector.m b/libs/Dashboard/TimeRangeSelector.m
index cf52ca68..07abdf74 100644
--- a/libs/Dashboard/TimeRangeSelector.m
+++ b/libs/Dashboard/TimeRangeSelector.m
@@ -62,6 +62,10 @@
hEnvelope = [] % single patch for aggregate min/max envelope (legacy)
hPreviewLines = [] % array of line handles, one per widget preview
hEventMarkers = [] % array of line handles, one per event marker
+ hPlantLogMarkers = [] % Phase 1031 PLOG-VIZ-01/02: array of line handles
+ % (NaN-separated polyline) created by setPlantLogMarkers;
+ % SEPARATE from hEventMarkers so the two methods do
+ % not clobber each other.
hSelection = [] % patch for selection rectangle
hEdgeLeft = [] % line: left drag handle
hEdgeRight = [] % line: right drag handle
@@ -618,6 +622,99 @@ function setEventBands(obj, starts, ends, colors)
end
end
+ function setPlantLogMarkers(obj, times)
+ %setPlantLogMarkers Draw a 1px full-opacity vertical line per plant-log entry time.
+ % Phase 1031 PLOG-VIZ-01/02/09. Parallel to setEventMarkers but uses
+ % SEPARATE storage (hPlantLogMarkers) so plant-log markers and the
+ % sev1/2/3 event markers can coexist without clobbering each other.
+ %
+ % setPlantLogMarkers(times) clears any existing plant-log markers
+ % and draws one black vertical 1px line per finite entry in `times`.
+ % Non-finite values (NaN, ±Inf) are silently dropped (mirrors
+ % setEventMarkers behavior). Empty input simply clears the markers.
+ %
+ % Color is sourced from obj.Theme.MarkerPlantLog (with [0 0 0]
+ % fallback when the theme is missing or doesn't carry the token).
+ % Unlike setEventMarkers, NO translucent blend is applied — plant-log
+ % markers should read as crisp dividers, not subtle highlights
+ % (CONTEXT.md: "crisp dividers, not subtle highlights").
+ %
+ % The N-marker draw uses the SAME NaN-separator polyline strategy
+ % as setEventMarkers' uniform path (260508-slider-stuck): one
+ % single line() handle whose XData = [t1 t1 NaN t2 t2 NaN ...] and
+ % YData = [0 1 NaN 0 1 NaN ...] renders N disconnected vertical
+ % marks with constant graphics-object cost. Live-tail ticks therefore
+ % stay O(1) in handle count regardless of how many plant-log
+ % entries are in the slider's visible range.
+ %
+ % Markers have HitTest='off' and PickableParts='none' so they
+ % never intercept selection-rectangle drag/pan/resize. Hover
+ % handling for plant-log markers is owned by Plan 03's
+ % PlantLogSliderHover helper (chained WindowButtonMotionFcn).
+ %
+ % Z-order: this method sends the marker handle to the BACK after
+ % creation. Combined with the existing pipeline (setPreviewLines
+ % also sends preview lines to the BACK), and with computeEventMarkers
+ % running BEFORE computePlantLogMarkers at every hook site, the
+ % plant-log line ends up between preview lines (further back) and
+ % the selection patch / edges / labels (in front). See
+ % DashboardEngine.computePlantLogMarkers for the call ordering.
+ %
+ % Storage: result handle is stored in obj.hPlantLogMarkers.
+ % obj.hEventMarkers is NEVER touched by this method.
+ % Clear previous plant-log marker handles.
+ for k = 1:numel(obj.hPlantLogMarkers)
+ if ishandle(obj.hPlantLogMarkers(k))
+ delete(obj.hPlantLogMarkers(k));
+ end
+ end
+ obj.hPlantLogMarkers = [];
+ if nargin < 2 || isempty(times)
+ return;
+ end
+ times = times(:).';
+ times = times(isfinite(times));
+ if isempty(times)
+ return;
+ end
+
+ % Resolve color from theme (PLOG-VIZ-09); default black.
+ markerColor = [0 0 0];
+ if isstruct(obj.Theme) && isfield(obj.Theme, 'MarkerPlantLog')
+ markerColor = obj.Theme.MarkerPlantLog;
+ end
+
+ % NaN-separator polyline strategy — single line handle, N segments.
+ nT = numel(times);
+ xv = nan(1, 3 * nT);
+ yv = nan(1, 3 * nT);
+ for i = 1:nT
+ idx3 = (i - 1) * 3;
+ xv(idx3 + 1) = times(i);
+ xv(idx3 + 2) = times(i);
+ % xv(idx3+3) stays NaN (separator)
+ yv(idx3 + 1) = 0;
+ yv(idx3 + 2) = 1;
+ % yv(idx3+3) stays NaN (separator)
+ end
+ h = line(obj.hAxes, xv, yv, ...
+ 'Color', markerColor, 'LineWidth', 1, ...
+ 'HitTest', 'off', 'PickableParts', 'none');
+ obj.hPlantLogMarkers = h;
+
+ % Z-order: send plant-log marker to the BACK. Because preview lines
+ % were already pushed to the back (by setPreviewLines), the plant-log
+ % line ends up BETWEEN preview (further back) and the selection
+ % patch + edges + labels (in front). (Phase 1031 PLOG-VIZ-02)
+ if ~isempty(h) && ishandle(obj.hAxes)
+ ch = get(obj.hAxes, 'Children');
+ mask = true(size(ch));
+ mask(ch == h) = false;
+ others = ch(mask);
+ set(obj.hAxes, 'Children', [others(:); h]);
+ end
+ end
+
function reinstallCallbacks(obj)
%reinstallCallbacks Re-install the figure WindowButton* handlers.
% Public wrapper around the private installCallbacks_ used by
diff --git a/libs/FastSenseCompanion/FastSenseCompanion.m b/libs/FastSenseCompanion/FastSenseCompanion.m
index 91d44e71..b8e08b05 100644
--- a/libs/FastSenseCompanion/FastSenseCompanion.m
+++ b/libs/FastSenseCompanion/FastSenseCompanion.m
@@ -71,6 +71,7 @@
hToolbarPanel_ = [] % top toolbar uipanel (row 1, spans cols [1 3])
hSettingsBtn_ = [] % gear button inside hToolbarPanel_ (right-aligned)
hEventsBtn_ = [] % toolbar uibutton: Events viewer launch
+ hPlantLogBtn_ = [] % Phase 1033 PLOG-INT-03: Plant Log… toolbar button (col 3 in the 1x5 grid)
hLeftPanel_ = [] % left pane uipanel
hMidPanel_ = [] % middle pane uipanel
hRightPanel_ = [] % right pane uipanel
@@ -309,17 +310,18 @@
obj.hToolbarPanel_.Layout.Column = [1 3];
obj.hToolbarPanel_.BorderType = 'none';
obj.hToolbarPanel_.BackgroundColor = obj.Theme_.WidgetBackground;
- % Inner 1x8 grid (Phase 1034 — Wiki button added at col 6):
- % col 1 = Events viewer button (Task 13) (110)
- % col 2 = Live: ON/OFF button (110)
- % col 3 = Tags table launch (quick task 260519-bs4) (110)
- % col 4 = Tile windows (S0Y-01) ( 70)
- % col 5 = Close all (S0Y-02) ( 90)
- % col 6 = Wiki / Help launch (Phase 1034) ( 70)
- % col 7 = flex spacer ('1x')
- % col 8 = Settings gear ( 36)
- hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 8]);
- hToolbarGrid.ColumnWidth = {110, 110, 110, 70, 90, 70, '1x', 36};
+ % Inner 1x9 grid (v3.1 Plant Log + v4.0 Wiki Browser merged):
+ % col 1 = Events viewer button (Task 13) (110)
+ % col 2 = Live: ON/OFF button (110)
+ % col 3 = Tags table launch (quick task 260519-bs4) (110)
+ % col 4 = Plant Log… button (v3.1 Phase 1033 PLOG-INT-03) (130)
+ % col 5 = Tile windows (v4.0 S0Y-01) ( 70)
+ % col 6 = Close all (v4.0 S0Y-02) ( 90)
+ % col 7 = Wiki / Help launch (v4.0 Phase 1034) ( 70)
+ % col 8 = flex spacer ('1x')
+ % col 9 = Settings gear ( 36)
+ hToolbarGrid = uigridlayout(obj.hToolbarPanel_, [1 9]);
+ hToolbarGrid.ColumnWidth = {110, 110, 110, 130, 70, 90, 70, '1x', 36};
hToolbarGrid.RowHeight = {'1x'};
hToolbarGrid.Padding = [4 0 4 0];
hToolbarGrid.ColumnSpacing = 8;
@@ -350,7 +352,7 @@
obj.hLiveBtn_.Tooltip = 'Toggle live refresh of the inspector';
obj.hLiveBtn_.ButtonPushedFcn = @(~,~) obj.toggleLiveMode();
- % Col 3 — Tag Status table launch (quick task 260519-bs4).
+ % Col 3 — v4.0 Tag Status table launch (quick task 260519-bs4).
obj.hTagStatusBtn_ = uibutton(hToolbarGrid, 'push');
obj.hTagStatusBtn_.Layout.Row = 1;
obj.hTagStatusBtn_.Layout.Column = 3;
@@ -361,10 +363,25 @@
obj.hTagStatusBtn_.Tooltip = 'Open the tag status table';
obj.hTagStatusBtn_.ButtonPushedFcn = @(~,~) obj.openTagStatusTable();
- % Col 4 — Tile windows (S0Y-01).
+ % Col 4 — v3.1 Phase 1033 PLOG-INT-03: Plant Log… toolbar button.
+ obj.hPlantLogBtn_ = uibutton(hToolbarGrid, 'push');
+ obj.hPlantLogBtn_.Layout.Row = 1;
+ obj.hPlantLogBtn_.Layout.Column = 4;
+ obj.hPlantLogBtn_.Text = ['Plant Log', char(8230)]; % "Plant Log…"
+ obj.hPlantLogBtn_.FontSize = 11;
+ obj.hPlantLogBtn_.FontWeight = 'bold';
+ obj.hPlantLogBtn_.Tag = 'CompanionPlantLogBtn';
+ obj.hPlantLogBtn_.Tooltip = 'Attach a plant log to every open dashboard';
+ obj.hPlantLogBtn_.ButtonPushedFcn = @(~,~) obj.openPlantLogDialog_();
+ if isempty(obj.Engines_)
+ obj.hPlantLogBtn_.Enable = 'off';
+ obj.hPlantLogBtn_.Tooltip = 'No dashboards open';
+ end
+
+ % Col 5 — v4.0 Tile windows (S0Y-01).
obj.hTileBtn_ = uibutton(hToolbarGrid, 'push');
obj.hTileBtn_.Layout.Row = 1;
- obj.hTileBtn_.Layout.Column = 4;
+ obj.hTileBtn_.Layout.Column = 5;
obj.hTileBtn_.Text = 'Tile';
obj.hTileBtn_.FontSize = 11;
obj.hTileBtn_.FontWeight = 'bold';
@@ -373,10 +390,10 @@
obj.hTileBtn_.FontColor = obj.Theme_.ForegroundColor;
obj.hTileBtn_.ButtonPushedFcn = @(~,~) obj.tileOpenedWindows();
- % Col 5 — Close all (S0Y-02). Uses Accent color to signal destructive action.
+ % Col 6 — v4.0 Close all (S0Y-02). Uses Accent color to signal destructive action.
obj.hCloseAllBtn_ = uibutton(hToolbarGrid, 'push');
obj.hCloseAllBtn_.Layout.Row = 1;
- obj.hCloseAllBtn_.Layout.Column = 5;
+ obj.hCloseAllBtn_.Layout.Column = 6;
obj.hCloseAllBtn_.Text = 'Close all';
obj.hCloseAllBtn_.FontSize = 11;
obj.hCloseAllBtn_.FontWeight = 'bold';
@@ -385,12 +402,12 @@
obj.hCloseAllBtn_.FontColor = obj.Theme_.ForegroundColor;
obj.hCloseAllBtn_.ButtonPushedFcn = @(~,~) obj.closeAllOpenedWindows();
- % Col 6 — Wiki / Help launch (Phase 1034). Opens the shared WikiBrowser
- % to Companion-Overview.md. Re-clicks focus + re-navigate the existing
- % window per CONTEXT.md D-06.
+ % Col 7 — v4.0 Wiki / Help launch (Phase 1034). Opens the shared
+ % WikiBrowser to Companion-Overview.md. Re-clicks focus + re-navigate
+ % the existing window per CONTEXT.md D-06.
obj.hWikiBtn_ = uibutton(hToolbarGrid, 'push');
obj.hWikiBtn_.Layout.Row = 1;
- obj.hWikiBtn_.Layout.Column = 6;
+ obj.hWikiBtn_.Layout.Column = 7;
obj.hWikiBtn_.Text = ['Wiki ', char(8689)]; % up-arrow with bar (pop-out)
obj.hWikiBtn_.FontSize = 11;
obj.hWikiBtn_.FontWeight = 'bold';
@@ -400,10 +417,10 @@
obj.hWikiBtn_.FontColor = obj.Theme_.ForegroundColor;
obj.hWikiBtn_.ButtonPushedFcn = @(~,~) obj.openWiki_('Companion-Overview');
- % Col 8 — Settings gear (was col 7 before Phase 1034).
+ % Col 9 — Settings gear (moved as new buttons accumulated).
obj.hSettingsBtn_ = uibutton(hToolbarGrid, 'push');
obj.hSettingsBtn_.Layout.Row = 1;
- obj.hSettingsBtn_.Layout.Column = 8;
+ obj.hSettingsBtn_.Layout.Column = 9;
obj.hSettingsBtn_.Text = char(9881); % gear glyph
obj.hSettingsBtn_.FontSize = 14;
obj.hSettingsBtn_.Tooltip = 'Companion settings';
@@ -1292,6 +1309,19 @@ function openEventViewer_internalForTest(obj)
obj.openEventViewer_();
end
+ function openPlantLogDialogInternalForTest(obj)
+ %OPENPLANTLOGDIALOGINTERNALFORTEST Test shim: call openPlantLogDialog_ directly.
+ % Phase 1033 PLOG-INT-03: mirrors the openEventViewer_internalForTest
+ % idiom so test files can invoke the toolbar callback without
+ % simulating a uibutton click.
+ obj.openPlantLogDialog_();
+ end
+
+ function b = getPlantLogBtnForTest_(obj)
+ %GETPLANTLOGBTNFORTEST_ Test helper: return the Plant Log button handle.
+ b = obj.hPlantLogBtn_;
+ end
+
function v = getEventViewerForTest_(obj)
%GETEVENTVIEWERFORTEST_ Test helper: return the EventViewer_ handle or [].
v = obj.EventViewer_;
@@ -1820,6 +1850,106 @@ function openEventViewer_(obj)
end
end
+ function openPlantLogDialog_(obj)
+ %OPENPLANTLOGDIALOG_ Phase 1033 PLOG-INT-03: Companion "Plant Log…" toolbar callback.
+ % 1. Calls PlantLogReader.openInteractive('') — the empty path triggers
+ % the existing native uigetfile in the reader (Phase 1030 Plan 03
+ % behavior).
+ % 2. On user cancel, returns silently (no error).
+ % 3. On confirm, iterates obj.Engines_ and calls each engine's
+ % attachPlantLog with the confirmed mapping. Best-effort fan-out:
+ % if any engine fails, surfaces a uialert listing the failures
+ % but continues with the rest.
+ % 4. Wraps the entire body in try/catch + uialert(obj.hFig_, ...) so
+ % no exception ever reaches the MATLAB console (CONTEXT.md D-17).
+ %
+ % Per CONTEXT.md decision (line 244-247): "Per Companion session, one
+ % shared plant log across all managed dashboards. Re-clicking 'Plant
+ % Log…' with a different file detaches the prior shared store from
+ % every dashboard and attaches the new one (matches the engine-level
+ % idempotent attachPlantLog contract)."
+ try
+ if isempty(obj.Engines_)
+ uialert(obj.hFig_, ...
+ ['No dashboards are open. Register at least one ', ...
+ 'DashboardEngine before attaching a plant log.'], ...
+ 'Plant Log');
+ return;
+ end
+
+ % Step 1 — open the file picker + mapping dialog.
+ % Empty path triggers native uigetfile in PlantLogReader.openInteractive.
+ [entries, confirmedMapping] = PlantLogReader.openInteractive('');
+
+ % Step 2 — cancel branch (entries empty AND mapping empty).
+ if isempty(entries) && (isempty(confirmedMapping) || ~isstruct(confirmedMapping))
+ return;
+ end
+
+ % Step 3 — empty file branch: surface a uialert and bail.
+ if isempty(entries)
+ uialert(obj.hFig_, ...
+ ['Selected plant-log file contains no parseable rows. ', ...
+ 'Nothing was attached.'], ...
+ 'Plant Log');
+ return;
+ end
+
+ % Harvest the resolved file path from the first entry; the reader
+ % normalizes filePath into every entry's SourceFile property.
+ filePath = entries(1).SourceFile;
+
+ % Step 4 — fan out attachPlantLog across every managed engine.
+ failedNames = {};
+ for i = 1:numel(obj.Engines_)
+ eng = obj.Engines_{i};
+ if ~isa(eng, 'DashboardEngine')
+ continue;
+ end
+ if ~isvalid(eng)
+ failedNames{end+1} = sprintf('engine %d (invalid handle)', i); %#ok
+ continue;
+ end
+ try
+ eng.attachPlantLog(filePath, ...
+ 'Mapping', confirmedMapping, ...
+ 'Interval', 5, ...
+ 'StartTail', true);
+ catch ME
+ warning('FastSenseCompanion:plantLogAttachFailed', ...
+ 'attachPlantLog on dashboard "%s" failed: %s', ...
+ eng.Name, ME.message);
+ failedNames{end+1} = sprintf('%s (%s)', eng.Name, ME.message); %#ok
+ end
+ end
+
+ % Step 5 — report partial failure via uialert (success path is silent).
+ if ~isempty(failedNames)
+ uialert(obj.hFig_, ...
+ sprintf(['Plant log attached to %d/%d dashboards. ', ...
+ 'Failures:\n %s'], ...
+ numel(obj.Engines_) - numel(failedNames), ...
+ numel(obj.Engines_), ...
+ strjoin(failedNames, sprintf('\n '))), ...
+ 'Plant Log — Partial Failure', 'Icon', 'warning');
+ end
+ catch ME
+ % Final safety net — should not normally reach here because every
+ % inner call is already guarded. Belt-and-suspenders per
+ % CONTEXT.md D-17 success criterion 5.
+ if ~isempty(obj.hFig_) && isvalid(obj.hFig_)
+ try
+ uialert(obj.hFig_, ME.message, ...
+ 'Plant Log — Unexpected Error');
+ catch
+ end
+ else
+ warning('FastSenseCompanion:plantLogAttachFailed', ...
+ 'openPlantLogDialog_ failed: %s', ME.message);
+ end
+ end
+ end
+
function openWiki_(obj, pageName)
%OPENWIKI_ Open or focus the shared WikiBrowser; navigate to pageName.
% Phase 1034 — canonical implementation for the Wiki toolbar button
diff --git a/libs/Help/WikiPageIndex.m b/libs/Help/WikiPageIndex.m
index 60211cdb..e38d9fa0 100644
--- a/libs/Help/WikiPageIndex.m
+++ b/libs/Help/WikiPageIndex.m
@@ -81,8 +81,8 @@
if strcmpi(filename, '_Sidebar.md')
grp = 'Sidebar';
- elseif numel(filename) >= numel('API-Reference:-') ...
- && strncmp(filename, 'API-Reference:-', numel('API-Reference:-'))
+ elseif numel(filename) >= numel('API-Reference:-') && ...
+ strncmp(filename, 'API-Reference:-', numel('API-Reference:-'))
grp = 'API Reference';
else
grp = 'Pages';
@@ -381,8 +381,8 @@
continue;
end
marker = '