Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 33 additions & 21 deletions libs/Dashboard/DashboardEngine.m
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
obj.Layout = DashboardLayout();
end

function addWidget(obj, type, varargin)
function w = addWidget(obj, type, varargin)
switch type
case 'fastsense'
w = FastSenseWidget(varargin{:});
Expand Down Expand Up @@ -794,28 +794,40 @@ function onClose(obj)
end
end

config = DashboardSerializer.load(filepath);
obj = DashboardEngine(config.name);
if isfield(config, 'theme')
obj.Theme = config.theme;
end
if isfield(config, 'liveInterval')
obj.LiveInterval = config.liveInterval;
end
obj.FilePath = filepath;
if isfield(config, 'infoFile')
obj.InfoFile = config.infoFile;
end
[~, ~, ext] = fileparts(filepath);

if strcmp(ext, '.m')
% .m function file returns a DashboardEngine directly
[fdir, funcname] = fileparts(filepath);
addpath(fdir);
cleanupPath = onCleanup(@() rmpath(fdir));
obj = feval(funcname);
obj.FilePath = filepath;
else
% Legacy JSON path
config = DashboardSerializer.load(filepath);
obj = DashboardEngine(config.name);
if isfield(config, 'theme')
obj.Theme = config.theme;
end
if isfield(config, 'liveInterval')
obj.LiveInterval = config.liveInterval;
end
obj.FilePath = filepath;
if isfield(config, 'infoFile')
obj.InfoFile = config.infoFile;
end

widgets = DashboardSerializer.configToWidgets(config, resolver);
for i = 1:numel(widgets)
w = widgets{i};
existingPositions = cell(1, numel(obj.Widgets));
for j = 1:numel(obj.Widgets)
existingPositions{j} = obj.Widgets{j}.Position;
widgets = DashboardSerializer.configToWidgets(config, resolver);
for i = 1:numel(widgets)
w = widgets{i};
existingPositions = cell(1, numel(obj.Widgets));
for j = 1:numel(obj.Widgets)
existingPositions{j} = obj.Widgets{j}.Position;
end
w.Position = obj.Layout.resolveOverlap(w.Position, existingPositions);
obj.Widgets{end+1} = w;
end
w.Position = obj.Layout.resolveOverlap(w.Position, existingPositions);
obj.Widgets{end+1} = w;
end
end
end
Expand Down
125 changes: 119 additions & 6 deletions libs/Dashboard/DashboardSerializer.m
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,106 @@

methods (Static)
function save(config, filepath)
%SAVE Write dashboard config struct to JSON file.
%SAVE Write dashboard config as a MATLAB function file.
% The output is a function returning a DashboardEngine.
[~, funcname] = fileparts(filepath);

% Generate the script body (reuse exportScript logic)
lines = {};
lines{end+1} = sprintf('function d = %s()', funcname);
lines{end+1} = sprintf('%%%s Recreate dashboard.', upper(funcname));
lines{end+1} = sprintf('%% d = %s() returns a DashboardEngine.', funcname);
lines{end+1} = '';
lines{end+1} = sprintf(' d = DashboardEngine(''%s'');', strrep(config.name, '''', ''''''));
if isfield(config, 'theme')
lines{end+1} = sprintf(' d.Theme = ''%s'';', config.theme);
end
if isfield(config, 'liveInterval')
lines{end+1} = sprintf(' d.LiveInterval = %g;', config.liveInterval);
end
if isfield(config, 'infoFile') && ~isempty(config.infoFile)
lines{end+1} = sprintf(' d.InfoFile = ''%s'';', strrep(config.infoFile, '''', ''''''));
end
lines{end+1} = '';

% Write widget calls (indented, with return value)
for i = 1:numel(config.widgets)
ws = config.widgets{i};
pos = sprintf('[%d %d %d %d]', ws.position.col, ws.position.row, ...
ws.position.width, ws.position.height);

switch ws.type
case 'fastsense'
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(' ''Sensor'', SensorRegistry.get(''%s''));', ws.source.name);
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);
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));
otherwise
lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
end
else
lines{end+1} = sprintf(' d.addWidget(''fastsense'', ''Title'', ''%s'', ''Position'', %s);', ws.title, pos);
end
case 'number'
line = sprintf(' d.addWidget(''number'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
if isfield(ws, 'units') && ~isempty(ws.units)
line = [line, sprintf(', ...\n ''Units'', ''%s''', ws.units)];
end
lines{end+1} = [line, ');'];
case 'status'
line = sprintf(' d.addWidget(''status'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
lines{end+1} = [line, ');'];
case 'text'
line = sprintf(' d.addWidget(''text'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
if isfield(ws, 'content') && ~isempty(ws.content)
line = [line, sprintf(', ...\n ''Content'', ''%s''', ws.content)];
end
lines{end+1} = [line, ');'];
case 'gauge'
line = sprintf(' d.addWidget(''gauge'', ''Title'', ''%s'', ''Position'', %s', ws.title, pos);
if isfield(ws, 'range')
line = [line, sprintf(', ...\n ''Range'', [%g %g]', ws.range(1), ws.range(2))];
end
if isfield(ws, 'units') && ~isempty(ws.units)
line = [line, sprintf(', ...\n ''Units'', ''%s''', ws.units)];
end
lines{end+1} = [line, ');'];
case 'group'
line = sprintf(' d.addWidget(''group'', ''Label'', ''%s'', ''Position'', %s', ws.label, pos);
if isfield(ws, 'mode') && ~isempty(ws.mode)
line = [line, sprintf(', ...\n ''Mode'', ''%s''', ws.mode)];
end
lines{end+1} = [line, ');'];
otherwise
lines{end+1} = sprintf(' d.addWidget(''%s'', ''Title'', ''%s'', ''Position'', %s);', ws.type, ws.title, pos);
end
lines{end+1} = '';
end

lines{end+1} = 'end';

fid = fopen(filepath, 'w');
if fid == -1
error('DashboardSerializer:fileError', 'Cannot open file: %s', filepath);
end
fprintf(fid, '%s\n', lines{:});
fclose(fid);
end

function saveJSON(config, filepath)
%SAVEJSON Legacy: write dashboard config struct to JSON file.
% Widgets may have heterogeneous fields, so encode each
% widget individually and assemble the JSON array by hand.
parts = cell(1, numel(config.widgets));
Expand All @@ -26,19 +125,33 @@ function save(config, filepath)
fclose(fid);
end

function config = load(filepath)
%LOAD Read dashboard config from JSON file.
function result = load(filepath)
%LOAD Load dashboard config from file.
% For .m files: uses feval to execute the function and return the engine.
% For .json files: uses legacy JSON parsing.
if ~exist(filepath, 'file')
error('DashboardSerializer:fileNotFound', 'File not found: %s', filepath);
end

[fdir, funcname, ext] = fileparts(filepath);

if strcmp(ext, '.json')
result = DashboardSerializer.loadJSON(filepath);
return;
end

% .m function file
addpath(fdir);
cleanupPath = onCleanup(@() rmpath(fdir));
result = feval(funcname);
end

function config = loadJSON(filepath)
%LOADJSON Legacy: read dashboard config from JSON file.
fid = fopen(filepath, 'r');
jsonStr = fread(fid, '*char')';
fclose(fid);

config = jsondecode(jsonStr);

% Ensure widgets is a cell array
if isstruct(config.widgets)
wa = config.widgets;
config.widgets = cell(1, numel(wa));
Expand Down
6 changes: 3 additions & 3 deletions tests/suite/TestDashboardEngine.m
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ function testSaveAndLoad(testCase)
d.addWidget('fastsense', 'Title', 'Temp', ...
'Position', [1 1 12 3], 'XData', 1:10, 'YData', [1:10]);

filepath = fullfile(tempdir, 'test_save_dashboard.json');
filepath = fullfile(tempdir, 'test_save_dashboard.m');
testCase.addTeardown(@() delete(filepath));
d.save(filepath);

Expand All @@ -87,8 +87,8 @@ function testExportScript(testCase)
d.exportScript(filepath);

content = fileread(filepath);
testCase.verifyTrue(contains(content, 'DashboardEngine'));
testCase.verifyTrue(contains(content, 'Pressure'));
testCase.verifyFalse(isempty(strfind(content, 'DashboardEngine')));
testCase.verifyFalse(isempty(strfind(content, 'Pressure')));
end

function testLiveStartStop(testCase)
Expand Down
53 changes: 53 additions & 0 deletions tests/suite/TestDashboardMSerializer.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
classdef TestDashboardMSerializer < matlab.unittest.TestCase
methods (TestClassSetup)
function addPaths(testCase)
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..'));
install();
end
end

methods (Test)
function testSaveProducesMFile(testCase)
d = DashboardEngine('SaveTest');
d.Theme = 'dark';
d.LiveInterval = 3;
d.addWidget('fastsense', 'Title', 'Temp', ...
'Position', [1 1 12 3], 'XData', 1:10, 'YData', 1:10);

filepath = fullfile(tempdir, 'test_save_dash.m');
testCase.addTeardown(@() delete(filepath));
d.save(filepath);

testCase.verifyTrue(exist(filepath, 'file') == 2);
content = fileread(filepath);
testCase.verifyFalse(isempty(strfind(content, 'DashboardEngine')));
testCase.verifyFalse(isempty(strfind(content, 'function')));
end

function testLoadFromMFile(testCase)
d = DashboardEngine('LoadTest');
d.Theme = 'dark';
d.LiveInterval = 3;
d.addWidget('fastsense', 'Title', 'Temp', ...
'Position', [1 1 12 3], 'XData', 1:10, 'YData', 1:10);

filepath = fullfile(tempdir, 'test_load_dash.m');
testCase.addTeardown(@() delete(filepath));
d.save(filepath);

d2 = DashboardEngine.load(filepath);
testCase.verifyEqual(d2.Name, 'LoadTest');
testCase.verifyEqual(d2.Theme, 'dark');
testCase.verifyEqual(d2.LiveInterval, 3);
testCase.verifyEqual(numel(d2.Widgets), 1);
end

function testAddWidgetReturnsHandle(testCase)
d = DashboardEngine('ReturnTest');
w = d.addWidget('number', 'Title', 'RPM', ...
'Position', [1 1 6 1]);
testCase.verifyClass(w, 'NumberWidget');
testCase.verifyEqual(w.Title, 'RPM');
end
end
end
86 changes: 86 additions & 0 deletions tests/suite/TestDashboardPerformance.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
classdef TestDashboardPerformance < matlab.unittest.TestCase
methods (TestClassSetup)
function addPaths(testCase)
addpath(fullfile(fileparts(mfilename('fullpath')), '..', '..'));
install();
end
end

methods (Test)
function testLiveTickOnlyRefreshesDirtyWidgets(testCase)
d = DashboardEngine('PerfTest');
for k = 1:10
d.addWidget('number', 'Title', sprintf('N%d', k), ...
'Position', [mod((k-1)*6, 24)+1, ceil(k*6/24), 6, 1], ...
'ValueFcn', @() k);
end
d.render();
testCase.addTeardown(@() close(d.hFigure));

% Clear all dirty flags
for i = 1:numel(d.Widgets)
d.Widgets{i}.Dirty = false;
end

% Mark only 2 of 10 dirty
d.Widgets{1}.markDirty();
d.Widgets{5}.markDirty();

% Live tick should only refresh dirty widgets
d.onLiveTick();

% All should be clean after tick
for i = 1:numel(d.Widgets)
testCase.verifyFalse(d.Widgets{i}.Dirty);
end
end

function testSaveLoadRoundTripWithMFile(testCase)
d = DashboardEngine('RoundTrip');
d.Theme = 'dark';
d.LiveInterval = 2;
d.addWidget('fastsense', 'Title', 'Temp', ...
'Position', [1 1 12 3], 'XData', 1:100, 'YData', rand(1,100));
d.addWidget('number', 'Title', 'RPM', ...
'Position', [13 1 6 1]);

filepath = fullfile(tempdir, 'perf_roundtrip.m');
testCase.addTeardown(@() delete(filepath));
d.save(filepath);

d2 = DashboardEngine.load(filepath);
testCase.verifyEqual(d2.Name, 'RoundTrip');
testCase.verifyEqual(d2.Theme, 'dark');
testCase.verifyEqual(numel(d2.Widgets), 2);
end

function testWidgetsRealizedAfterRender(testCase)
d = DashboardEngine('RealizeTest');
d.addWidget('number', 'Title', 'N1', ...
'Position', [1 1 12 1]);
d.addWidget('number', 'Title', 'N2', ...
'Position', [13 1 12 1]);
d.render();
testCase.addTeardown(@() close(d.hFigure));

for i = 1:numel(d.Widgets)
testCase.verifyTrue(d.Widgets{i}.Realized);
end
end

function testResizeMarksDirtyAndRealizeBatch(testCase)
d = DashboardEngine('ResizePerfTest');
d.addWidget('number', 'Title', 'N1', ...
'Position', [1 1 24 1]);
d.render();
testCase.addTeardown(@() close(d.hFigure));

for i = 1:numel(d.Widgets)
d.Widgets{i}.Dirty = false;
end

d.onResize();
testCase.verifyTrue(d.Widgets{1}.Dirty);
end
end
end
4 changes: 2 additions & 2 deletions tests/suite/TestDashboardSerializer.m
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ function testSaveAndLoadRoundTrip(testCase)
'source', struct('type', 'data', 'x', 1:10, 'y', rand(1,10)));

filepath = fullfile(testCase.TempDir, 'test_dashboard.json');
DashboardSerializer.save(config, filepath);
DashboardSerializer.saveJSON(config, filepath);

testCase.verifyTrue(exist(filepath, 'file') == 2, ...
'JSON file should exist');

loaded = DashboardSerializer.load(filepath);
loaded = DashboardSerializer.loadJSON(filepath);
testCase.verifyEqual(loaded.name, 'Test Dashboard');
testCase.verifyEqual(loaded.theme, 'dark');
testCase.verifyEqual(loaded.liveInterval, 5);
Expand Down
Loading
Loading