diff --git a/PIDscope.m b/PIDscope.m index 03d6b29..2da22e4 100644 --- a/PIDscope.m +++ b/PIDscope.m @@ -140,8 +140,14 @@ screensz = get(0,'ScreenSize'); % Octave Qt bug: setting figure units to 'normalized' permanently breaks uipanel % Calculate pixel position manually instead -set(PSfig, 'Position', round([0 0 screensz(3) screensz(4)])); -try set(PSfig, 'WindowState', 'maximized'); catch, end +if ispc + % Position sets the client area only - on Windows the title bar ends up + % above the screen top; OuterPosition includes the frame + drawnow; + set(PSfig, 'OuterPosition', round([0 0 screensz(3) screensz(4)])); +else + set(PSfig, 'Position', round([0 0 screensz(3) screensz(4)])); +end set(PSfig, 'NumberTitle', 'off'); set(PSfig, 'Name', ['PIDscope (' PsVersion ') - Log Viewer']); drawnow; pause(0.2); @@ -264,7 +270,7 @@ set(guiHandles.clr, 'ForegroundColor', cautionCol); guiHandles.startEndButton = uicontrol(PSfig,'style','checkbox', 'string','Trim ','fontsize',fontsz,'TooltipString', [TooltipString_selectButton], 'units','normalized','Position',[posInfo.startEndButton],... - 'callback','if exist(''filenameA'',''var'') && ~isempty(filenameA) && get(guiHandles.startEndButton, ''Value''), try, [x y] = ginput(1); epoch1_A(get(guiHandles.FileNum, ''Value'')) = round(x(1)*10)/10; PSplotLogViewer; [x y] = ginput(1); epoch2_A(get(guiHandles.FileNum, ''Value'')) = round(x(1)*10)/10; PSplotLogViewer; catch, end, end'); + 'callback','PStrimSelect;'); guiHandles.plotR =uicontrol(PSfig,'Style','checkbox','String','R','fontsize',fontsz,'TooltipString', ['Plot Roll '],... 'units','normalized','BackgroundColor',bgcolor,'ForegroundColor',th.axisRoll,'Position',[posInfo.plotR_LV], 'callback','if exist(''fnameMaster'',''var'') && ~isempty(fnameMaster), PSplotLogViewer; end'); diff --git a/README.md b/README.md index 628f978..64f0cb8 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,17 @@ That said - if you find PIDscope useful and want to buy me a coffee (not a beer, Buy Me A Coffee --- +## Installing + +AppImage (Linux), Windows and macOS builds are on the [releases page](https://github.com/dzikus/PIDscope/releases). + +Arch Linux users can install from the AUR thanks to [FPVogel](https://github.com/FPVogel): + +- [pidscope-bin](https://aur.archlinux.org/packages/pidscope-bin) - the released AppImage +- [pidscope-git](https://aur.archlinux.org/packages/pidscope-git) - built from the latest git source + +--- + ## License PIDscope is licensed under the [GNU General Public License v3.0](LICENSE). diff --git a/VERSION b/VERSION index 9c9684d..bf58b94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -26.05.0 +26.08.0 diff --git a/packaging/windows/Dockerfile b/packaging/windows/Dockerfile index bf0d06c..61658e7 100644 --- a/packaging/windows/Dockerfile +++ b/packaging/windows/Dockerfile @@ -4,7 +4,7 @@ FROM ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive -ARG OCTAVE_VERSION=11.1.0 +ARG OCTAVE_VERSION=11.3.0 RUN apt-get update && apt-get install -y \ p7zip-full zip wget git curl make \ diff --git a/packaging/windows/build-windows.sh b/packaging/windows/build-windows.sh index dbf0e6f..aa60f83 100644 --- a/packaging/windows/build-windows.sh +++ b/packaging/windows/build-windows.sh @@ -7,7 +7,7 @@ set -euo pipefail SRC_DIR="${1:-/src}" DIST_DIR="${2:-/dist}" -OCTAVE_VERSION="${OCTAVE_VERSION:-11.1.0}" +OCTAVE_VERSION="${OCTAVE_VERSION:-11.3.0}" # Version: env > VERSION file > git tag > fallback if [ -n "${PIDSCOPE_VERSION:-}" ]; then VERSION="${PIDSCOPE_VERSION}" diff --git a/src/core/PSgetcsv.m b/src/core/PSgetcsv.m index cad75e8..b00df5e 100644 --- a/src/core/PSgetcsv.m +++ b/src/core/PSgetcsv.m @@ -1,8 +1,9 @@ -function [filename csvFnames] = PSgetcsv(filename, firmware_flag, outdir) -%% [filename csvFnames] = PSgetcsv(filename, firmware_flag, outdir) +function [filename csvFnames] = PSgetcsv(filename, firmware_flag, outdir, sel) +%% [filename csvFnames] = PSgetcsv(filename, firmware_flag, outdir, sel) % Converts bbl files to csv using blackbox_decode % filename: full path to BBL/BFL/TXT/BTFL/JSON/BIN file % outdir: directory for CSV output (default: same as input file) +% sel: session numbers to keep from a multi-log file; omit to ask the user % ---------------------------------------------------------------------------------- % "THE BEER-WARE LICENSE" (Revision 42): @@ -21,6 +22,8 @@ if nargin < 3 || isempty(outdir) outdir = fdir; end +if nargin < 4, sel = []; end +askUser = isempty(sel); if strcmpi(fext, '.bin') csvFnames = {filename}; @@ -36,10 +39,10 @@ decoder_path = getappdata(0, 'PSdecoderPath'); decoder_inav = getappdata(0, 'PSdecoderPathINAV'); - if firmware_flag == 3 && ~isempty(decoder_inav) - % INAV decoder has no --output-dir; copy input to workdir first + if firmware_flag == 3 && ~isempty(decoder_inav) && exist(decoder_inav, 'file') == 2 + % INAV decoder has no --output-dir; run it on a copy inside outdir tmpSrc = fullfile(outdir, [fname fext]); - copyfile(filename, tmpSrc); + if ~strcmp(tmpSrc, filename), copyfile(filename, tmpSrc); end cmd = ['"' decoder_inav '" "' tmpSrc '" 2>&1']; else cmd = ['"' decoder_path '" --output-dir "' outdir '" "' filename '" 2>&1']; @@ -52,9 +55,15 @@ end fbase = fullfile(outdir, fname); - files = dir([fbase '*.csv']); - % filter out files with .bbl or .bfl in name + % junk the decoder drops next to the csv output + for pat = {'*.event', '*.gps.gpx', '*.gps.csv'} + junk = dir([fbase pat{1}]); + for k = 1:size(junk,1), delete(fullfile(outdir, junk(k).name)); end + end + + % side outputs keep the source extension in the name + files = dir([fbase '*.csv']); valid = true(size(files,1), 1); for k = 1:size(files,1) if contains(files(k).name, '.bbl', 'IgnoreCase', true) || contains(files(k).name, '.bfl', 'IgnoreCase', true) @@ -64,50 +73,60 @@ files = files(valid, :); if isempty(files) - set(gcf, 'pointer', 'arrow'); csvFnames = {}; return; end - % clean up junk files in outdir - fevt = dir([fbase '*.event']); - for k = 1:size(fevt,1), delete(fullfile(outdir, fevt(k).name)); end - fevt = dir([fbase '*.gps.gpx']); - for k = 1:size(fevt,1), delete(fullfile(outdir, fevt(k).name)); end - fevt = dir([fbase '*.gps.csv']); - for k = 1:size(fevt,1), delete(fullfile(outdir, fevt(k).name)); end - - % refresh file list after cleanup - files = dir([fbase '*.csv']); - if size(files,1) > 1 - % remove empty subfiles (<1KB) - valid = true(size(files,1), 1); + % the decoder prints one duration per log, in the order it wrote them, + % so pair them up before anything is dropped from the list + a = strfind(result, 'duration'); + labels = cell(size(files,1), 1); for k = 1:size(files,1) - if files(k).bytes < 1000 - delete(fullfile(outdir, files(k).name)); - valid(k) = false; + if k <= length(a) + labels{k} = result(a(k):min(a(k)+filename_nchars, length(result))); + else + labels{k} = files(k).name; end end - files = files(valid, :); - a = strfind(result, 'duration'); - logDurStr = ''; - for d = 1:length(a) - logDurStr{d} = [int2str(d) ') ' result(a(d):a(d)+filename_nchars)]; + % logs under 1KB hold no flight data - drop them and their labels together + small = [files.bytes] < 1000; + for k = find(small), delete(fullfile(outdir, files(k).name)); end + files = files(~small, :); + labels = labels(~small); + + if isempty(files) + if askUser + a = errordlg(['no valid data in ' mainFname]); pause(3); close(a); + else + warning('PSgetcsv: no valid data in %s', mainFname); + end + csvFnames = {}; + return; end + end - if size(files,1) > 0 - x = size(files,1); - if x > 1 - [fnums, tf] = listdlg('ListString', logDurStr, 'ListSize', [250, round(size(logDurStr,2)*20)], 'Name', 'Select file(s): '); - for k = 1:x - if ~ismember(k, fnums), delete(fullfile(outdir, files(k).name)); end - end + if size(files,1) > 1 + if askUser + for k = 1:size(files,1) + labels{k} = [int2str(k) ') ' labels{k}]; + end + % the dialog opens behind the import waitbar on Windows and looks like a hang + wb = findall(0, 'Type', 'figure', 'Tag', 'waitbar'); + set(wb, 'Visible', 'off'); + [fnums, tf] = listdlg('ListString', labels, 'ListSize', [250, round(numel(labels)*20)], 'Name', 'Select file(s): '); + set(wb, 'Visible', 'on'); + if ~tf || isempty(fnums) + for k = 1:size(files,1), delete(fullfile(outdir, files(k).name)); end + csvFnames = {}; + return; end else - validData = 0; - a = errordlg(['no valid data in ' mainFname]); pause(3); close(a); + fnums = sel(sel >= 1 & sel <= size(files,1)); + end + for k = 1:size(files,1) + if ~ismember(k, fnums), delete(fullfile(outdir, files(k).name)); end end end end diff --git a/src/plot/PSplotLogViewer.m b/src/plot/PSplotLogViewer.m index ef269ff..4ed3ab7 100644 --- a/src/plot/PSplotLogViewer.m +++ b/src/plot/PSplotLogViewer.m @@ -414,10 +414,10 @@ % i/o keyboard trim: 'i' sets in-point, 'o' sets out-point set(PSfig, 'KeyPressFcn', [ ... 'if exist(''filenameA'',''var'') && ~isempty(filenameA), ' ... - 'kk=get(gcbo,''CurrentCharacter''); fIdx=get(guiHandles.FileNum,''Value''); ' ... + 'kk=get(gcbo,''CurrentCharacter''); fIdx=get(guiHandles.FileNum,''Value''); setappdata(0,''PSbusy'',1); ' ... 'if kk==''i'', try, [xt,~]=ginput(1); epoch1_A(fIdx)=round(xt*10)/10; PSplotLogViewer; catch, end; ' ... 'elseif kk==''o'', try, [xt,~]=ginput(1); epoch2_A(fIdx)=round(xt*10)/10; PSplotLogViewer; catch, end; ' ... - 'end, end']); + 'end, setappdata(0,''PSbusy'',0); end']); PSdatatipSetup(PSfig); try PSresizeCP(PSfig, []); catch, end diff --git a/src/plot/PSplotSpec.m b/src/plot/PSplotSpec.m index 2f81725..9de5380 100644 --- a/src/plot/PSplotSpec.m +++ b/src/plot/PSplotSpec.m @@ -115,7 +115,9 @@ if updateSpec==0 clear s dat ampmat amp2d freq a RC smat amp2d freq2d Throt p=0; - hw = waitbar(0,['please wait... ' ]); + % waitbar pumps the event queue, so hold off dashboard clicks until done + setappdata(0, 'PSbusy', 1); + hw = waitbar(0,['please wait... ' ]); tmpPSDVal = get(guiHandlesSpec.checkboxPSD, 'Value'); for k=1:length(vars) @@ -148,6 +150,7 @@ end end close(hw) + setappdata(0, 'PSbusy', 0); end else warndlg({'Dropdowns set to ''NONE''.'; 'Please select a preset or specific variables to analyze.'}); diff --git a/src/plot/PSplotSpec2D.m b/src/plot/PSplotSpec2D.m index cd7955a..4ca2787 100644 --- a/src/plot/PSplotSpec2D.m +++ b/src/plot/PSplotSpec2D.m @@ -50,6 +50,8 @@ %%% compute delay/overlay data (deferred from UI open to Run click) if ~exist('delayDataReady','var') || ~delayDataReady + % waitbar pumps the event queue, so hold off dashboard clicks until done + setappdata(0, 'PSbusy', 1); hw_delay = waitbar(0, 'computing delays...'); FilterDelayDterm={}; SPGyroDelay=[]; @@ -157,6 +159,7 @@ end delayDataReady = true; try close(hw_delay); catch, end + setappdata(0, 'PSbusy', 0); end tmpSpecVal = get(guiHandlesSpec2.SpecList, 'Value'); @@ -177,6 +180,7 @@ freq2d2 = {}; amp2d2 = {}; p=0; +setappdata(0, 'PSbusy', 1); hw_fft = waitbar(0, 'computing FFT...'); for k = 1 : length(tmpSpecVal) s = char(datSelectionString(tmpSpecVal(k))); @@ -233,6 +237,7 @@ end end try close(hw_fft); catch, end +setappdata(0, 'PSbusy', 0); prevPsdKey_ = struct('specVal', tmpSpecVal, 'fileVal', tmpFileVal, 'psdVal', tmpPSDVal, 'axes', axesOptionsSpec); end diff --git a/src/ui/PSdispSetupInfoUIcontrol.m b/src/ui/PSdispSetupInfoUIcontrol.m index 6067cc4..fa55c01 100644 --- a/src/ui/PSdispSetupInfoUIcontrol.m +++ b/src/ui/PSdispSetupInfoUIcontrol.m @@ -8,6 +8,8 @@ % ---------------------------------------------------------------------------------- +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call mid-capture (#21) + if exist('fnameMaster','var') && ~isempty(fnameMaster) if exist('PSdisp','var') && ishandle(PSdisp) diff --git a/src/ui/PSerrUIcontrol.m b/src/ui/PSerrUIcontrol.m index 3a1db07..2401224 100644 --- a/src/ui/PSerrUIcontrol.m +++ b/src/ui/PSerrUIcontrol.m @@ -8,6 +8,8 @@ % ---------------------------------------------------------------------------------- +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call mid-capture (#21) + if exist('fnameMaster','var') && ~isempty(fnameMaster) if exist('PSerrfig','var') && ishandle(PSerrfig) diff --git a/src/ui/PSfreqTimeUIcontrol.m b/src/ui/PSfreqTimeUIcontrol.m index 5349b38..7b68d43 100644 --- a/src/ui/PSfreqTimeUIcontrol.m +++ b/src/ui/PSfreqTimeUIcontrol.m @@ -6,9 +6,11 @@ % can do whatever you want with this stuff. If we meet some day, and you think % this stuff is worth it, you can buy me a beer in return. -Brian White % ---------------------------------------------------------------------------------- - + +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call from a running job + if exist('fnameMaster','var') && ~isempty(fnameMaster) - + %%% tooltips TooltipString_specRun=['Run current spectral configuration']; TooltipString_cmap=['Choose from a selection of colormaps']; @@ -228,10 +230,11 @@ try set(guiHandlesSpec3.subsampleFactor_select, 'Value', defaults.Values(find(st try set(guiHandlesSpec3.ColormapSelect, 'Value', defaults.Values(find(strcmp(defaults.Parameters, 'FreqxTime-Colormap')))), catch, set(guiHandlesSpec3.ColormapSelect, 'Value', 3), end +PSstyleControls(PSspecfig3); + else warndlg('Please select file(s)'); end -PSstyleControls(PSspecfig3); % functions function selection2(src,event) diff --git a/src/ui/PSspec2DUIcontrol.m b/src/ui/PSspec2DUIcontrol.m index c5a0bb0..224b0f7 100644 --- a/src/ui/PSspec2DUIcontrol.m +++ b/src/ui/PSspec2DUIcontrol.m @@ -7,6 +7,8 @@ % this stuff is worth it, you can buy me a beer in return. -Brian White % ---------------------------------------------------------------------------------- +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call mid-capture (#21) + if exist('fnameMaster','var') && ~isempty(fnameMaster) %%% tooltips diff --git a/src/ui/PSspecUIcontrol.m b/src/ui/PSspecUIcontrol.m index 25fdeb6..eb99089 100644 --- a/src/ui/PSspecUIcontrol.m +++ b/src/ui/PSspecUIcontrol.m @@ -6,7 +6,9 @@ % can do whatever you want with this stuff. If we meet some day, and you think % this stuff is worth it, you can buy me a beer in return. -Brian White % ---------------------------------------------------------------------------------- - + +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call from a running job + if exist('fnameMaster','var') && ~isempty(fnameMaster) %%% tooltips @@ -328,10 +330,11 @@ try set(guiHandlesSpec.smoothFactor_select, 'Value', defaults.Values(find(strcmp(defaults.Parameters, 'FreqXthr-Smoothing')))), catch, set(guiHandlesSpec.smoothFactor_select, 'Value', 3); end +PSstyleControls(PSspecfig); + else warndlg('Please select file(s)'); end -PSstyleControls(PSspecfig); % functions function selection2(src,event) diff --git a/src/ui/PSstatsUIcontrol.m b/src/ui/PSstatsUIcontrol.m index c0262ed..132dbae 100644 --- a/src/ui/PSstatsUIcontrol.m +++ b/src/ui/PSstatsUIcontrol.m @@ -7,6 +7,8 @@ % this stuff is worth it, you can buy me a beer in return. -Brian White % ---------------------------------------------------------------------------------- +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call mid-capture (#21) + if exist('fnameMaster','var') && ~isempty(fnameMaster) if exist('PSstatsfig','var') && ishandle(PSstatsfig) diff --git a/src/ui/PStuneUIcontrol.m b/src/ui/PStuneUIcontrol.m index 8f4f43d..155da4f 100644 --- a/src/ui/PStuneUIcontrol.m +++ b/src/ui/PStuneUIcontrol.m @@ -7,6 +7,8 @@ % this stuff is worth it, you can buy me a beer in return. -Brian White % ---------------------------------------------------------------------------------- +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call mid-capture (#21) + if exist('fnameMaster','var') && ~isempty(fnameMaster) th = PStheme(); @@ -28,7 +30,7 @@ newline, 'Warning: Set subsampling dropdown @ or < medium for faster processing.']; TooltipString_minRate=['Input the minimum rate of rotation for calculating the step response (lower bound must be > 0 but lower than upper bound).',... newline, 'Really low values may yield more noisy contributions to the data, whereas higher values limit the total data used.',... - newline, 'The default of 40deg/s should be sufficient in most cases, but if N is low, try setting this to lower']; + newline, 'The default of 40deg/s (20 for Rotorflight) should be sufficient in most cases, but if N is low, try setting this to lower']; TooltipString_maxRate=['Input the maximum rate of rotation for for calculating the step response (upper bound must be greater than lower bound).',... newline, 'This also marks the lower bound for step resp plots associated with the ''snap maneuvers'' selection.',... newline, 'The default of 500deg/s is sufficient in most cases']; @@ -128,7 +130,10 @@ guiHandlesTune.minRateTxt = uicontrol(PStunefig,'style','text','string','deg/s','fontsize',fontsz,... 'TooltipString', [TooltipString_minRate], 'units','normalized','BackgroundColor',bgcolor,'Position',[posInfo.minRateTxt]); -guiHandlesTune.minRateInput = uicontrol(PStunefig,'style','edit','string','40','fontsize',fontsz,... +% helis are flown with gentle cyclic rates - 40 deg/s would reject most maneuvers +minRateDef_ = '40'; +try, if any(strcmpi(fwType, 'Rotorflight')), minRateDef_ = '20'; end, catch, end +guiHandlesTune.minRateInput = uicontrol(PStunefig,'style','edit','string',minRateDef_,'fontsize',fontsz,... 'TooltipString', [TooltipString_minRate], 'units','normalized','Position',[posInfo.minRateInput],... 'callback','delete(findobj(PStunefig,''Type'',''axes'')); fcntSR = 0; updateStep = 0; PStuningParams; set(PStunefig, ''pointer'', ''arrow'');'); guiHandlesTune.maxRateInput = uicontrol(PStunefig,'style','edit','string','500','fontsize',fontsz,... @@ -221,10 +226,10 @@ try idx_=find(strcmp(defaults.Parameters,'StepResp-MinRate')); if ~isempty(idx_), set(guiHandlesTune.minRateInput,'String',num2str(defaults.Values(idx_))); end, catch, end try idx_=find(strcmp(defaults.Parameters,'StepResp-MaxRate')); if ~isempty(idx_), set(guiHandlesTune.maxRateInput,'String',num2str(defaults.Values(idx_))); end, catch, end +PSstyleControls(PStunefig); else warndlg('Please select file(s)'); end -PSstyleControls(PStunefig); % functions function textinput_call3(src,eventdata) diff --git a/src/ui/PSviewerUIcontrol.m b/src/ui/PSviewerUIcontrol.m index 8384672..29c3742 100644 --- a/src/ui/PSviewerUIcontrol.m +++ b/src/ui/PSviewerUIcontrol.m @@ -9,6 +9,9 @@ % ---------------------------------------------------------------------------------- +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call from a running job +if ~exist('PSfig','var') || ~ishandle(PSfig), return; end % called outside the main workspace + % Checkbox bar — pixel sizes (constant across resizes) chkW_px = 130; chkMotW_px = 100; chkEdtW_px = 45; chkTxtW_px = 65; figPos = get(PSfig, 'Position'); figW = figPos(3); figH = figPos(4); @@ -150,7 +153,7 @@ TooltipString_FileNum=['Select the file you wish to plot in the logviewer. ']; set(guiHandles.FileNum, 'string', fnameMaster, 'TooltipString', TooltipString_FileNum,... - 'callback','if exist(''fnameMaster'',''var'') && ~isempty(fnameMaster), try set(zoom, ''Enable'',''off''); catch, end, expandON=0; PSplotLogViewer; if exist(''filenameA'',''var'') && ~isempty(filenameA) && get(guiHandles.startEndButton, ''Value''), try, [x y] = ginput(1); epoch1_A(get(guiHandles.FileNum, ''Value'')) = round(x(1)*10)/10; PSplotLogViewer; [x y] = ginput(1); epoch2_A(get(guiHandles.FileNum, ''Value'')) = round(x(1)*10)/10; PSplotLogViewer; catch, end, end, end'); + 'callback','if exist(''fnameMaster'',''var'') && ~isempty(fnameMaster), try set(zoom, ''Enable'',''off''); catch, end, expandON=0; PSplotLogViewer; PStrimSelect; end'); maxY_textToolTip = ['+/- Scaling factor for the Y axis in degs/s']; guiHandles.maxY_text = uicontrol(PSfig,'style','text','string','y scale','fontsize',fontsz,'TooltipString', [maxY_textToolTip],'units','normalized','BackgroundColor',bgcolor,'Position',[posInfo.maxYtext]); guiHandles.maxY_input = uicontrol(PSfig,'style','edit','string',int2str(maxY),'fontsize',fontsz,'TooltipString', [maxY_textToolTip],'units','normalized','Position',[posInfo.maxYinput],... diff --git a/src/util/PSlinecmap.m b/src/util/PSlinecmap.m index 5d711ef..3e8087c 100644 --- a/src/util/PSlinecmap.m +++ b/src/util/PSlinecmap.m @@ -14,6 +14,12 @@ for i = find(multiLineCols(:,1) > .5 & multiLineCols(:,2) > .7 & multiLineCols(:,3) < .3) multiLineCols(i,:) = multiLineCols(i,:) * .78; end + % jet endpoints (dark red/blue) vanish on the dark theme - lift toward + % white instead of rescaling, so neighbors keep distinct hues + th = PStheme(); + mx = max(multiLineCols, [], 2); + dim = mx < th.lineMinBright; + multiLineCols(dim,:) = min(1, multiLineCols(dim,:) + (th.lineMinBright - mx(dim))); multiLineCols = repmat(multiLineCols, round(100/nColors),1);% repeats colormap to be 100 rows long end diff --git a/src/util/PSlogViewerPeriod.m b/src/util/PSlogViewerPeriod.m index 423800e..7f43801 100644 --- a/src/util/PSlogViewerPeriod.m +++ b/src/util/PSlogViewerPeriod.m @@ -19,6 +19,10 @@ function PSlogViewerPeriod(fig) set(fig, 'pointer', 'crosshair'); +% PSbusy lets re-entrant callbacks bail during the ginput capture (#21) +setappdata(0, 'PSbusy', 1); +restoreBusy_ = onCleanup(@() setappdata(0, 'PSbusy', 0)); + % first click try ginput(1); catch, set(fig,'pointer','arrow'); return; end figPt = get(fig, 'CurrentPoint'); diff --git a/src/util/PSresetData.m b/src/util/PSresetData.m index 3616a71..cd9ee8d 100644 --- a/src/util/PSresetData.m +++ b/src/util/PSresetData.m @@ -1,6 +1,8 @@ %% PSresetData - clear all loaded data and reset UI state % Called from Reset button and firmware-change dialog +if getappdata(0, 'PSbusy'), return; end % skip re-entrant call mid-capture (#21) + clear T dataA tta A_lograte epoch1_A epoch2_A SetupInfo; clear rollPIDF pitchPIDF yawPIDF filenameA fnameMaster loaded_firmware; clear debugmode debugIdx fwType fwMajor fwMinor gyro_debug_axis; diff --git a/src/util/PSstepPeriod.m b/src/util/PSstepPeriod.m index ddec44d..78bb7be 100644 --- a/src/util/PSstepPeriod.m +++ b/src/util/PSstepPeriod.m @@ -9,6 +9,10 @@ function PSstepPeriod(fig) allAx = findobj(fig, 'Type', 'axes', 'Visible', 'on'); if isempty(allAx), return; end +% PSbusy lets re-entrant callbacks bail during the ginput capture (#21) +setappdata(0, 'PSbusy', 1); +restoreBusy_ = onCleanup(@() setappdata(0, 'PSbusy', 0)); + % first click - determines target axes try ginput(1); catch, return; end figPt = get(fig, 'CurrentPoint'); diff --git a/src/util/PStheme.m b/src/util/PStheme.m index 0c84d42..eee4128 100644 --- a/src/util/PStheme.m +++ b/src/util/PStheme.m @@ -12,6 +12,9 @@ th.axesFg = [.75 .75 .75]; th.gridColor = [.28 .28 .30]; +% per-file line palette: minimum brightness readable on the dark background +th.lineMinBright = .85; + % text th.textPrimary = [.90 .90 .90]; th.textSecondary = [.65 .65 .65]; diff --git a/src/util/PStrimClick.m b/src/util/PStrimClick.m new file mode 100644 index 0000000..98fe48a --- /dev/null +++ b/src/util/PStrimClick.m @@ -0,0 +1,10 @@ +function PStrimClick(ax) +% Records the x-position of a left-click on a log-viewer axes. +% Armed by PStrimWaitClick only while a trim capture is running (#21). + + if ~strcmp(get(ancestor(ax, 'figure'), 'SelectionType'), 'normal') + return + end + cp = get(ax, 'CurrentPoint'); + setappdata(0, 'PStrimX', cp(1,1)); +end diff --git a/src/util/PStrimSelect.m b/src/util/PStrimSelect.m new file mode 100644 index 0000000..7f2a6a2 --- /dev/null +++ b/src/util/PStrimSelect.m @@ -0,0 +1,54 @@ +%% PStrimSelect - set the trim window for the current file by clicking two points +% Other controls grey out during capture so a stray click can't re-enter a callback +% mid-ginput (#21); the Trim box stays live, so clicking it again cancels the phase. + +if getappdata(0, 'PStrimming'), return; end % ignore re-entry while already capturing + +if ~(exist('filenameA','var') && ~isempty(filenameA) && get(guiHandles.startEndButton,'Value')) + return; +end + +fnum_ = get(guiHandles.FileNum, 'Value'); + +ctrls_ = findobj(PSfig, 'Type', 'uicontrol'); +ctrls_ = ctrls_(ctrls_ ~= guiHandles.startEndButton); +styles_ = get(ctrls_, 'Style'); +if ~iscell(styles_), styles_ = {styles_}; end +ctrls_ = ctrls_(~strcmp(styles_, 'text')); % disabled static text greys to white in Qt +prevEnable_ = get(ctrls_, 'Enable'); +if ~iscell(prevEnable_), prevEnable_ = {prevEnable_}; end +prevKey_ = get(PSfig, 'KeyPressFcn'); + +set(ctrls_, 'Enable', 'off'); +setappdata(0, 'PStrimming', 1); + +unwind_protect + for click_ = 1:2 + lvAx_ = []; + ax_ = findobj(PSfig, 'Type', 'axes'); + for ai_ = 1:numel(ax_) + if any(strcmp(get(ax_(ai_), 'Tag'), {'PSrpy', 'PSmotor', 'PScombo'})) + lvAx_(end+1) = ax_(ai_); + end + end + if isempty(lvAx_), break; end + + set(PSfig, 'KeyPressFcn', ''); % stop 'i'/'o' from nesting another capture + x_ = PStrimWaitClick(PSfig, guiHandles.startEndButton, lvAx_); + if isempty(x_), break; end % Trim un-clicked = cancel the phase + + if click_ == 1 + epoch1_A(fnum_) = round(x_*10)/10; + else + epoch2_A(fnum_) = round(x_*10)/10; + end + PSplotLogViewer; + end +unwind_protect_cleanup + setappdata(0, 'PStrimming', 0); + for i_ = 1:numel(ctrls_) + try set(ctrls_(i_), 'Enable', prevEnable_{i_}); catch, end + end + set(PSfig, 'KeyPressFcn', prevKey_); + try PSdatatipSetup(PSfig); catch, end +end_unwind_protect diff --git a/src/util/PStrimWaitClick.m b/src/util/PStrimWaitClick.m new file mode 100644 index 0000000..081e88f --- /dev/null +++ b/src/util/PStrimWaitClick.m @@ -0,0 +1,16 @@ +function x = PStrimWaitClick(fig, trimBtn, lvAx) +% Wait for one left-click on the log-viewer axes and return its x-position. +% Returns [] if the Trim box gets un-clicked (cancel) or the window closes (#21). +% A short pause loop replaces blocking ginput so the cancel click can break it. + + set(lvAx, 'ButtonDownFcn', @(src, ~) PStrimClick(src)); + setappdata(0, 'PStrimX', []); + while isempty(getappdata(0, 'PStrimX')) + if ~ishghandle(fig) || get(trimBtn, 'Value') == 0 + x = []; + return + end + pause(0.02); + end + x = getappdata(0, 'PStrimX'); +end diff --git a/tests/test_PSgetcsv.m b/tests/test_PSgetcsv.m new file mode 100644 index 0000000..c636648 --- /dev/null +++ b/tests/test_PSgetcsv.m @@ -0,0 +1,111 @@ +%% Tests for PSgetcsv - blackbox_decode driver and multi-session selection +%% The session number is always passed explicitly so no dialog is ever reached. + +%!function d = fake_decoder_(outdir, stdoutTxt, csvSpec) +%! % Shell stub standing in for blackbox_decode: writes the requested csv +%! % files into --output-dir and echoes canned decoder chatter. +%! d = fullfile(outdir, 'fake_decode.sh'); +%! fid = fopen(d, 'w'); +%! fprintf(fid, '#!/bin/sh\n'); +%! fprintf(fid, 'out=\n'); +%! fprintf(fid, 'while [ $# -gt 0 ]; do\n'); +%! fprintf(fid, ' case "$1" in --output-dir) out="$2"; shift 2;; *) src="$1"; shift;; esac\n'); +%! fprintf(fid, 'done\n'); +%! % without --output-dir the real decoders write next to the input file +%! fprintf(fid, '[ -n "$out" ] || out=$(dirname "$src")\n'); +%! fprintf(fid, 'base=$(basename "$src"); base=${base%%.*}\n'); +%! for k = 1:size(csvSpec, 1) +%! fprintf(fid, 'printf "%%s" "%s" > "$out/$base%s"\n', csvSpec{k,2}, csvSpec{k,1}); +%! end +%! fprintf(fid, 'printf "%%s" "%s"\n', stdoutTxt); +%! fclose(fid); +%! system(['chmod +x "' d '"']); +%!endfunction + +%!function src = fake_log_(wd, name) +%! src = fullfile(wd, name); +%! fid = fopen(src, 'w'); fprintf(fid, 'x'); fclose(fid); +%!endfunction + +%!test +%! % "duration" landing near the end of decoder output must not overrun the string +%! wd = tempname(); mkdir(wd); +%! bigrow = repmat('9', 1, 1200); +%! dec = fake_decoder_(wd, 'log 1 duration', {'.01.csv', ['a\n' bigrow]; '.02.csv', ['b\n' bigrow]}); +%! setappdata(0, 'PSdecoderPath', dec); +%! src = fake_log_(wd, 'LOG00001.BBL'); +%! err = ''; +%! try +%! [~, csvFnames] = PSgetcsv(src, 1, wd, 1); +%! catch e +%! err = e.message; +%! end +%! assert(isempty(err), 'PSgetcsv threw on truncated decoder output: %s', err); + +%!test +%! % the session list is built from decoder chatter but indexed against the file +%! % list - a short log dropped for being <1KB must not desync the two +%! wd = tempname(); mkdir(wd); +%! bigrow = repmat('9', 1, 1200); +%! dec = fake_decoder_(wd, ... +%! 'log 1 duration 00:10.000 log 2 duration 00:20.000 log 3 duration 00:30.000 end', ... +%! {'.01.csv', ['a\n' bigrow]; '.02.csv', 'tiny'; '.03.csv', ['c\n' bigrow]}); +%! setappdata(0, 'PSdecoderPath', dec); +%! src = fake_log_(wd, 'LOG00002.BBL'); +%! [~, csvFnames] = PSgetcsv(src, 1, wd, 2); +%! assert(numel(csvFnames) == 1, 'expected one csv, got %d', numel(csvFnames)); +%! % session 2 of the surviving logs is the .03 file, the tiny .02 is gone +%! assert(~isempty(strfind(csvFnames{1}, '.03.csv')), ... +%! 'selected the wrong session: %s', csvFnames{1}); + +%!test +%! % a selection past the end of the list must not index out of bounds +%! wd = tempname(); mkdir(wd); +%! bigrow = repmat('9', 1, 1200); +%! dec = fake_decoder_(wd, 'log 1 duration 00:10.000 log 2 duration 00:20.000 end', ... +%! {'.01.csv', ['a\n' bigrow]; '.02.csv', ['b\n' bigrow]}); +%! setappdata(0, 'PSdecoderPath', dec); +%! src = fake_log_(wd, 'LOG00005.BBL'); +%! [~, csvFnames] = PSgetcsv(src, 1, wd, [1 7]); +%! assert(numel(csvFnames) == 1, 'expected one csv, got %d', numel(csvFnames)); + +%!test +%! % .bbl.csv side output is filtered out, and must stay filtered after the +%! % junk-file cleanup refreshes the list +%! wd = tempname(); mkdir(wd); +%! bigrow = repmat('9', 1, 1200); +%! dec = fake_decoder_(wd, 'log 1 duration 00:10.000 end', ... +%! {'.01.csv', ['a\n' bigrow]; '.bbl.csv', ['junk\n' bigrow]}); +%! setappdata(0, 'PSdecoderPath', dec); +%! src = fake_log_(wd, 'LOG00003.BBL'); +%! [~, csvFnames] = PSgetcsv(src, 1, wd, 1); +%! assert(numel(csvFnames) == 1, 'expected one csv, got %d', numel(csvFnames)); +%! assert(isempty(strfind(csvFnames{1}, '.bbl.csv')), ... +%! 'returned the filtered .bbl.csv: %s', csvFnames{1}); + +%!test +%! % A missing INAV decoder must not silently produce nothing - fall back to +%! % the standard decoder rather than shelling out to a path that isn't there +%! wd = tempname(); mkdir(wd); +%! bigrow = repmat('9', 1, 1200); +%! dec = fake_decoder_(wd, 'log 1 duration 00:10.000 end', {'.01.csv', ['a\n' bigrow]}); +%! setappdata(0, 'PSdecoderPath', dec); +%! setappdata(0, 'PSdecoderPathINAV', fullfile(wd, 'does_not_exist_INAV')); +%! src = fake_log_(wd, 'LOG00004.TXT'); +%! [~, csvFnames] = PSgetcsv(src, 3, wd, 1); +%! setappdata(0, 'PSdecoderPathINAV', ''); +%! assert(numel(csvFnames) == 1, ... +%! 'INAV import produced %d csv files when the INAV decoder is absent', numel(csvFnames)); + +%!test +%! % INAV runs the decoder on a copy inside outdir - when the log already lives +%! % there, copying it onto itself must not abort the import +%! wd = tempname(); mkdir(wd); +%! bigrow = repmat('9', 1, 1200); +%! dec = fake_decoder_(wd, 'log 1 duration 00:10.000 end', {'.01.csv', ['a\n' bigrow]}); +%! setappdata(0, 'PSdecoderPath', dec); +%! setappdata(0, 'PSdecoderPathINAV', dec); +%! src = fake_log_(wd, 'LOG00006.TXT'); +%! [~, csvFnames] = PSgetcsv(src, 3, wd, 1); +%! setappdata(0, 'PSdecoderPathINAV', ''); +%! assert(numel(csvFnames) == 1, 'expected one csv, got %d', numel(csvFnames));