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 = '