Add DSLogic capture, signal conversion and DSView decoders
This commit is contained in:
173
tools/dsview/README.md
Normal file
173
tools/dsview/README.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# DSView as an embedded Windows instrument panel
|
||||
|
||||
Upstream: https://github.com/DreamSourceLab/DSView, revision `2e9e2c8e`.
|
||||
The GPL-3.0-or-later application and its private Qt 5/Python runtime stay in
|
||||
a separate process. `setgui-host.patch` adds the Windows host protocol and
|
||||
fixes the missing Windows CMake inputs. No DSView DLL is loaded into Qt 6.
|
||||
|
||||
`python/logic_analyzer/dsview_host.py` contains the reusable environment,
|
||||
process/window ownership and close-message helpers. The consuming GUI owns
|
||||
the stylesheet, window surface and lifecycle.
|
||||
|
||||
## Build
|
||||
|
||||
MSYS2 UCRT64 packages (install with `pacman -S --needed`):
|
||||
|
||||
```text
|
||||
mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake
|
||||
mingw-w64-ucrt-x86_64-ninja mingw-w64-ucrt-x86_64-pkgconf
|
||||
mingw-w64-ucrt-x86_64-qt5-base mingw-w64-ucrt-x86_64-qt5-svg
|
||||
mingw-w64-ucrt-x86_64-qt5-winextras mingw-w64-ucrt-x86_64-glib2
|
||||
mingw-w64-ucrt-x86_64-libusb mingw-w64-ucrt-x86_64-fftw
|
||||
mingw-w64-ucrt-x86_64-boost mingw-w64-ucrt-x86_64-python
|
||||
```
|
||||
|
||||
```powershell
|
||||
python tools/dsview/build.py --source C:/path/to/DSView --output C:/path/to/runtime --msys-root C:/msys64
|
||||
```
|
||||
|
||||
The builder checks the revision, applies the patch only after `git apply
|
||||
--check`, and accepts an already applied patch. It never resets the source
|
||||
checkout. Close the built DSView before rebuilding.
|
||||
|
||||
Successful builds write `host-build.json`, recording the recipe digest (patch,
|
||||
packager, adapters and shared cores) and the native EXE digest. Consumers can
|
||||
call `runtime_is_current(output)` before packaging to detect an old component
|
||||
even if the host-protocol version is unchanged. SETGUI's normal EXE builder
|
||||
automatically rebuilds an installed stale runtime and refuses to package it if
|
||||
the update fails. A runtime built before this stamp was added is rebuilt once.
|
||||
|
||||
## Host protocol 1
|
||||
|
||||
- Create a native child surface owned by the host process.
|
||||
- Start `DSView.exe --embed-parent <decimal HWND>` with its private runtime
|
||||
as working directory and a sanitized environment.
|
||||
- `DSVIEW_HOST_STYLE` points to the UTF-8 Qt stylesheet; `DSVIEW_CAPTURE_DIR`
|
||||
supplies the initial open/save/export directory.
|
||||
- Wait for `DSVIEW_READY <HWND>` on stdout, then validate both the window PID
|
||||
and its parent before accepting it.
|
||||
- The child fits the surface, inherits visibility, and exits if the parent
|
||||
window disappears. It uses separate settings under `SET/SETGUI-DSView`.
|
||||
- Write `CLOSE\n` to the child's stdin to close normally. Keep the host
|
||||
alive while DSView's save dialog is open. Forced termination is only a
|
||||
fallback for host shutdown or a failed start.
|
||||
- Write `ADD_DECODER <id>\n` to open the normal decoder configuration dialog.
|
||||
The host must be ready, in digital mode and not capturing. Stacked decoders
|
||||
automatically include the matching UART/CAN base decoder.
|
||||
- New hosts announce `DSVIEW_CAPABILITIES OPEN_FILE` after the ready message.
|
||||
`OPEN_FILE <base64 UTF-8 path>\n` opens a DSL recording without restarting
|
||||
the child. It returns `DSVIEW_FILE_RESULT ok|busy|save|error`. A capture,
|
||||
save operation or modal dialog prevents replacement. Unsaved hardware data
|
||||
retains the normal save prompt.
|
||||
- In embedded mode File в†’ Open accepts CSV and emits
|
||||
`DSVIEW_IMPORT_CSV <base64 UTF-8 path>` for conversion by SETGUI. The shared
|
||||
`python/logic_analyzer/csv_import.py` streams CSV into temporary DSL v3
|
||||
blocks in two passes, with progress/cancellation and bounded memory.
|
||||
Times are read as decimal picoseconds; front-to-front intervals are exact.
|
||||
The initial state may extend by less than one sample to align the grid;
|
||||
the final state pads to 64 samples. Original offset metadata is retained.
|
||||
- Embedded mode disables Qt's quit-on-last-window policy: the child is a
|
||||
`Qt::Tool`, so accepting/rejecting a modal dialog must not quit the process.
|
||||
- `--host-version` returns `DSView host protocol 1`. An optional
|
||||
`DSVIEW_HOST_SCREENSHOT` environment variable saves one render artifact
|
||||
after startup for native integration tests.
|
||||
|
||||
Tested SDK: GCC 16.2, Qt 5.15.19, Python 3.14.7, Windows x64. This runtime
|
||||
requires Windows 10/11; it does not add Windows 7 support.
|
||||
|
||||
## SET decoders
|
||||
|
||||
`decoders/` contains the sigrok front ends for `gate_driver_timing` (1SP0635 /
|
||||
1SD536F2), `set_uart` (SET v1/v2, ProtoCAN bridge), `set_can` (ProtoCAN, SET v2,
|
||||
Balsam), and `pm35_uart` (TMS320F28335 MODBUS 03/06).
|
||||
Their parsing cores are maintained only in `python/logic_analyzer/decoders`.
|
||||
The builder copies those files verbatim to `decoders/common/setgui_decoders`
|
||||
inside the runtime. No GUI imports or external Python installation are needed.
|
||||
The adapters originate from `DSLogic_Logic_2/dslogic_script/dsview_decoders`.
|
||||
|
||||
IGBT offers `ORPHAN minimum pulse width (ns, 0 = off)` for all profiles.
|
||||
Only uncorrelated pulses strictly shorter than this duration are omitted from
|
||||
annotations; zero preserves the original behavior. ACK measurements/errors,
|
||||
FAULT and lone edges with unknown pulse duration remain visible. This does
|
||||
not modify the captured signal or debounce Vin1/Vstat.
|
||||
|
||||
The independent **Transistor pair** decoder measures mintime, OFF time,
|
||||
deadtime in both directions, overlap, frequency and duty. It requires only
|
||||
Vin1/Vin2. **IGBT 1SP/1SD** retains Vin1/Vstat ACK and full-cycle checks.
|
||||
See [pair settings](decoders/transistor_pair/README.md) and
|
||||
[driver settings](decoders/gate_driver_timing/README.md).
|
||||
|
||||
Run `python -m unittest discover -s tools/dsview/tests` for packet, CRC,
|
||||
segmentation, timing-profile and sigrok API boundary tests. SETGUI's
|
||||
`--check-dsview` sets `DSVIEW_HOST_CHECK` to a report file and exercises native
|
||||
decoder dialogs, single/all removal with Yes/No, stacking and child lifetime.
|
||||
The check saves decoder dialog screenshots alongside the report.
|
||||
|
||||
## Annotation search
|
||||
|
||||
The patch searches every enabled result column of the selected decoder and
|
||||
every annotation label, including short waveform labels such as `ORPHAN`.
|
||||
Matching is case insensitive; warning columns are enabled by default. Explicitly
|
||||
hidden columns remain excluded until enabled in Decoding Results settings.
|
||||
Enter (including the Windows popup editor), the magnifier and the next arrow
|
||||
navigate forward; the previous arrow navigates backward. Matches are counted
|
||||
per annotation, ordered by sample time, and wrap around. Tooltips show all
|
||||
label variants. A nonempty query displays a compact match-only table: one
|
||||
annotation per row, with its row title and matching label. Clearing the query
|
||||
restores the source table; a query with no matches displays an empty list.
|
||||
Click/arrow navigation maps each result back to the original annotation.
|
||||
Numeric `AA-BB-CC` sequences are still supported; other hyphens
|
||||
are literal. Search runs in short GUI-thread timer steps, invalidates indices
|
||||
on model resets, and refreshes when decoding reaches 100 percent.
|
||||
|
||||
After rebuilding, verify with an IGBT recording containing an uncorrelated
|
||||
Vstat pulse: search `ORPHAN`, `orphan`, and `Unexpected Vstat`; each should
|
||||
select the same warning and show its waveform region. Also check Enter from
|
||||
the popup editor, magnifier/arrow wraparound, a missing query, clearing/changing
|
||||
the query during a long search, changing columns/decoders, and removing the
|
||||
decoder while searching. Confirm numeric sequences and literal `CAN-FD` labels
|
||||
still work. Source syntax checks do not replace this native GUI check.
|
||||
|
||||
## Install analyzers into standalone DSView (Windows)
|
||||
|
||||
The adapters in `tools/dsview/decoders` and portable cores in
|
||||
`python/logic_analyzer/decoders` are shared with the embedded build.
|
||||
No analyzer implementation is maintained in the consuming GUI.
|
||||
|
||||
```powershell
|
||||
.\tools\dsview\install_decoders.ps1 -DsViewPath 'C:\Program Files\DSView'
|
||||
```
|
||||
|
||||
Close DSView first. An elevated PowerShell is needed if the installation
|
||||
folder is protected. Add `-CheckOnly` to validate source files and the target
|
||||
layout without writing. Python/MSYS2 and a DSView rebuild are not required.
|
||||
The target must contain DSView.exe and decoders/common.
|
||||
|
||||
Installs gate_driver_timing, set_uart, set_can and pm35_uart, with unchanged
|
||||
cores under decoders/common/setgui_decoders. Existing unrelated files are
|
||||
preserved. Replaced files are backed up under set-decoder-backups next to
|
||||
DSView.exe; failed copies trigger rollback. Keep backups for manual recovery.
|
||||
To restore, close DSView and copy the backup contents over decoders.
|
||||
|
||||
Restart DSView, add the UART or CAN base decoder and stack SET/PM35 on top.
|
||||
Gate timing consumes logic channels directly. This installs decoders only;
|
||||
it does not install the SETGUI host patch, menus or CSV import.
|
||||
|
||||
Installer tests run under Windows PowerShell against temporary targets and
|
||||
check isolated imports, updates, backups, copy-failure rollback and validation:
|
||||
|
||||
```powershell
|
||||
python -m unittest discover -s tools/dsview/tests -p test_install_decoders.py -v
|
||||
```
|
||||
|
||||
## Decoder panel and selector
|
||||
|
||||
Embedded DSView does not restore standalone toolbar/dock layout. The Decoders
|
||||
panel can extend to the top row of the DSView controls; its upper splitter
|
||||
still allows reducing its height. SETGUI's own folder and tab rows remain
|
||||
outside the native component.
|
||||
|
||||
The decoder selector starts with **Пользовательские**: IGBT 1SP/1SD,
|
||||
Transistor pair, SET UART, SET CAN and PM35 UART. **Стандартные** contains the remaining
|
||||
decoders. Search matches names and IDs in both sections and hides empty
|
||||
section headings. The changes are packaged in `decoder-panel.patch`.
|
||||
137
tools/dsview/build.py
Normal file
137
tools/dsview/build.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Build and package DSView host protocol 1 using an existing MSYS2 UCRT64 SDK."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
REVISION = '2e9e2c8e'
|
||||
|
||||
|
||||
def recipe_digest():
|
||||
"""Identify the patch, packaging recipe, adapters and their shared cores."""
|
||||
recipe = Path(__file__).resolve().parent
|
||||
core = recipe.parents[1] / 'python/logic_analyzer/decoders'
|
||||
inputs = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h']
|
||||
inputs += sorted((recipe / 'decoders').rglob('*.py'))
|
||||
inputs += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')]
|
||||
digest = hashlib.sha256()
|
||||
for item in inputs:
|
||||
digest.update(item.relative_to(recipe.parents[1]).as_posix().encode('utf-8') + b'\0')
|
||||
digest.update(hashlib.sha256(item.read_bytes()).digest())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def runtime_is_current(output):
|
||||
output = Path(output)
|
||||
try:
|
||||
stamp = json.loads((output / 'host-build.json').read_text(encoding='utf-8'))
|
||||
return (stamp.get('recipe') == recipe_digest()
|
||||
and stamp.get('executable') == hashlib.sha256((output / 'DSView.exe').read_bytes()).hexdigest()
|
||||
and (output / 'host-protocol-1').read_text(encoding='utf-8').strip() == 'DSView host protocol 1')
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def package_decoders(output):
|
||||
"""Stage sigrok adapters and the canonical parsing cores without forks."""
|
||||
recipe = Path(__file__).resolve().parent
|
||||
decoders = Path(output) / 'decoders'
|
||||
shutil.copytree(recipe / 'decoders', decoders, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns('__pycache__'))
|
||||
common = decoders / 'common/setgui_decoders'
|
||||
common.mkdir(parents=True, exist_ok=True)
|
||||
(common / '__init__.py').write_text('', encoding='utf-8')
|
||||
core = recipe.parents[1] / 'python/logic_analyzer/decoders'
|
||||
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
shutil.copy2(core / (name + '.py'), common / (name + '.py'))
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('--source', type=Path, required=True)
|
||||
parser.add_argument('--output', type=Path, required=True)
|
||||
parser.add_argument('--msys-root', type=Path, default=Path(r'C:\setcorp\tools\msys64'))
|
||||
args = parser.parse_args()
|
||||
source, output = args.source.resolve(), args.output.resolve()
|
||||
prefix = args.msys_root.resolve() / 'ucrt64'
|
||||
if not (prefix / 'bin/cmake.exe').is_file():
|
||||
raise SystemExit('Install the MSYS2 UCRT64 dependencies listed in README.md first.')
|
||||
revision = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=source, text=True).strip()
|
||||
if not revision.startswith(REVISION):
|
||||
raise SystemExit('Expected DSView revision ' + REVISION + ', got ' + revision)
|
||||
for retired_name in ('driver-summary.patch', 'gate-pair-checkbox.patch'):
|
||||
retired = Path(__file__).with_name(retired_name)
|
||||
if subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', '--check', str(retired)],
|
||||
cwd=source, capture_output=True).returncode == 0:
|
||||
subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', str(retired)], cwd=source, check=True)
|
||||
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch'):
|
||||
patch = Path(__file__).with_name(patch_name)
|
||||
applied = subprocess.run(['git', 'apply', '--ignore-space-change', '--reverse', '--check', str(patch)], cwd=source, capture_output=True)
|
||||
if applied.returncode:
|
||||
subprocess.run(['git', 'apply', '--ignore-space-change', '--check', str(patch)], cwd=source, check=True)
|
||||
subprocess.run(['git', 'apply', '--ignore-space-change', str(patch)], cwd=source, check=True)
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = os.pathsep.join([str(prefix/'bin'), str(args.msys_root/'usr/bin'), env.get('PATH', '')])
|
||||
env['PKG_CONFIG_PATH'] = str(prefix/'lib/pkgconfig')
|
||||
build = source/'build-win64'
|
||||
subprocess.run([str(prefix/'bin/cmake.exe'), '-S', str(source), '-B', str(build), '-G', 'Ninja',
|
||||
'-DCMAKE_POLICY_VERSION_MINIMUM=3.5', '-DCMAKE_CXX_STANDARD=11',
|
||||
'-DCMAKE_POLICY_DEFAULT_CMP0167=OLD', '-DCMAKE_PREFIX_PATH='+prefix.as_posix()],
|
||||
env=env, check=True)
|
||||
subprocess.run([str(prefix/'bin/cmake.exe'), '--build', str(build), '--parallel', '6'], env=env, check=True)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source/'build.dir/DSView.exe', output/'DSView.exe')
|
||||
for relative in ('DSView/res', 'DSView/demo', 'libsigrokdecode4DSL/decoders', 'lang'):
|
||||
item = source/relative
|
||||
shutil.copytree(item, output/item.name, dirs_exist_ok=True, ignore=shutil.ignore_patterns('__pycache__'))
|
||||
package_decoders(output)
|
||||
for name in ('COPYING', 'NEWS25', 'NEWS31', 'ug25.pdf', 'ug31.pdf'):
|
||||
shutil.copy2(source/name, output/name)
|
||||
stdlib = sorted((prefix/'lib').glob('python3.*'))
|
||||
stdlib = [item for item in stdlib if (item/'encodings').is_dir()]
|
||||
if len(stdlib) != 1:
|
||||
raise RuntimeError('Expected exactly one Python standard library in UCRT64')
|
||||
shutil.copytree(stdlib[0], output/'lib'/stdlib[0].name, dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns('__pycache__', 'site-packages', 'test', 'tests', 'idlelib', 'tkinter', 'ensurepip'))
|
||||
for name in ('platforms', 'imageformats', 'iconengines', 'styles'):
|
||||
shutil.copytree(prefix/'share/qt5/plugins'/name, output/name, dirs_exist_ok=True)
|
||||
shutil.copytree(prefix/'share/licenses', output/'licenses', dirs_exist_ok=True)
|
||||
(output/'qt.conf').write_text('[Paths]\nPrefix=.\nPlugins=.\n', encoding='utf-8')
|
||||
queue = [p for p in output.rglob('*') if p.suffix.lower() in ('.exe', '.dll', '.pyd')]
|
||||
seen = set()
|
||||
while queue:
|
||||
binary = queue.pop()
|
||||
if binary in seen:
|
||||
continue
|
||||
seen.add(binary)
|
||||
info = subprocess.check_output([str(prefix/'bin/objdump.exe'), '-p', str(binary)], env=env, text=True, errors='replace')
|
||||
for dll in re.findall(r'DLL Name:\s*(\S+)', info):
|
||||
dependency, target = prefix/'bin'/dll, output/dll
|
||||
if dependency.is_file():
|
||||
if target not in seen:
|
||||
shutil.copy2(dependency, target)
|
||||
queue.append(target)
|
||||
elif not (Path(os.environ['SystemRoot'])/'System32'/dll).is_file() and not dll.lower().startswith(('api-ms-', 'ext-ms-')):
|
||||
raise RuntimeError('Missing dependency: ' + dll)
|
||||
check_env = dict(os.environ)
|
||||
check_env['PATH'] = str(Path(os.environ['SystemRoot'])/'System32')
|
||||
version = subprocess.check_output([str(output/'DSView.exe'), '--host-version'], env=check_env, text=True).strip()
|
||||
if version != 'DSView host protocol 1':
|
||||
raise RuntimeError('Host protocol self-check failed: ' + version)
|
||||
(output/'host-protocol-1').write_text(version+'\n', encoding='utf-8')
|
||||
for patch_name in ('setgui-host.patch', 'decoder-panel.patch', 'windows-usb-events.patch'):
|
||||
shutil.copy2(Path(__file__).with_name(patch_name), output/patch_name)
|
||||
(output/'SOURCE.txt').write_text('https://github.com/DreamSourceLab/DSView\nCommit: '+revision+'\nPatches: setgui-host.patch, decoder-panel.patch, windows-usb-events.patch\nLicense: GPL-3.0-or-later (COPYING)\n', encoding='utf-8')
|
||||
(output/'host-build.json').write_text(json.dumps({
|
||||
'recipe': recipe_digest(),
|
||||
'executable': hashlib.sha256((output/'DSView.exe').read_bytes()).hexdigest(),
|
||||
}, indent=2) + '\n', encoding='utf-8')
|
||||
print('DSView runtime:', output)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
119
tools/dsview/decoder-panel.patch
Normal file
119
tools/dsview/decoder-panel.patch
Normal file
@@ -0,0 +1,119 @@
|
||||
--- a/DSView/pv/dock/searchcombobox.h
|
||||
+++ b/DSView/pv/dock/searchcombobox.h
|
||||
@@ -58,6 +58,7 @@
|
||||
public:
|
||||
QString _id;
|
||||
QString _name;
|
||||
+ QString _section;
|
||||
void *_data_handle;
|
||||
QWidget *_control;
|
||||
};
|
||||
@@ -74,7 +75,7 @@
|
||||
|
||||
void ShowDlg(QWidget *editline);
|
||||
|
||||
- void AddDataItem(QString id, QString name, void *data_handle);
|
||||
+ void AddDataItem(QString id, QString name, void *data_handle, QString section = QString());
|
||||
|
||||
inline void SetItemClickHandle(ISearchItemClick *click){
|
||||
_item_click = click;
|
||||
@@ -95,6 +96,7 @@
|
||||
std::vector<SearchDataItem*> _items;
|
||||
ISearchItemClick *_item_click;
|
||||
QScrollArea *_scroll;
|
||||
+ std::vector<std::pair<QString, QWidget*> > _sections;
|
||||
};
|
||||
|
||||
#endif // SEARCHCOMBOBOX_H
|
||||
--- a/DSView/pv/dock/searchcombobox.cpp
|
||||
+++ b/DSView/pv/dock/searchcombobox.cpp
|
||||
@@ -25,6 +25,7 @@
|
||||
#include <QPoint>
|
||||
#include <QLineEdit>
|
||||
#include <QScrollBar>
|
||||
+#include <QLabel>
|
||||
#include "../config/appconfig.h"
|
||||
#include "../appcontrol.h"
|
||||
#include "../ui/fn.h"
|
||||
@@ -107,8 +108,17 @@
|
||||
QFont font = this->font();
|
||||
font.setPointSizeF(AppConfig::Instance().appOptions.fontSize);
|
||||
|
||||
+ QString section;
|
||||
for(auto o : _items)
|
||||
- {
|
||||
+ {
|
||||
+ if (!o->_section.isEmpty() && o->_section != section) {
|
||||
+ section = o->_section;
|
||||
+ auto heading = new QLabel(section, listPanel);
|
||||
+ heading->setFont(font);
|
||||
+ heading->setStyleSheet("font-weight: bold; padding: 8px 4px; border-top: 1px solid #344762;");
|
||||
+ listLay->addWidget(heading);
|
||||
+ _sections.push_back(std::make_pair(section, heading));
|
||||
+ }
|
||||
ComboButtonItem *bt = new ComboButtonItem(panel, this, o);
|
||||
bt->setText(o->_name);
|
||||
bt->setObjectName("flat");
|
||||
@@ -143,11 +153,12 @@
|
||||
this->show();
|
||||
}
|
||||
|
||||
-void SearchComboBox::AddDataItem(QString id, QString name, void *data_handle)
|
||||
+void SearchComboBox::AddDataItem(QString id, QString name, void *data_handle, QString section)
|
||||
{
|
||||
SearchDataItem *item = new SearchDataItem();
|
||||
item->_id = id;
|
||||
item->_name = name;
|
||||
+ item->_section = section;
|
||||
item->_data_handle = data_handle;
|
||||
this->_items.push_back(item);
|
||||
}
|
||||
@@ -197,5 +208,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
+ for (auto section : _sections) {
|
||||
+ bool visible = false;
|
||||
+ for (auto item : _items)
|
||||
+ if (item->_section == section.first && !item->_control->isHidden()) {
|
||||
+ visible = true;
|
||||
+ break;
|
||||
+ }
|
||||
+ section.second->setVisible(visible);
|
||||
+ }
|
||||
_scroll->verticalScrollBar()->setValue(0);
|
||||
}
|
||||
--- a/DSView/pv/dock/protocoldock.cpp
|
||||
+++ b/DSView/pv/dock/protocoldock.cpp
|
||||
@@ -1060,10 +1060,15 @@
|
||||
{
|
||||
SearchComboBox *panel = new SearchComboBox(this);
|
||||
|
||||
- for (auto info : _decoderInfoList)
|
||||
- {
|
||||
- srd_decoder *dec = (srd_decoder *)(info->_data_handle);
|
||||
- panel->AddDataItem(QString(dec->id), QString(dec->name), info);
|
||||
+ const QStringList customIds = {"gate_driver_timing", "transistor_pair", "set_uart", "set_can", "pm35_uart"};
|
||||
+ for (bool custom : {true, false}) {
|
||||
+ for (auto info : _decoderInfoList) {
|
||||
+ srd_decoder *dec = (srd_decoder *)(info->_data_handle);
|
||||
+ if (customIds.contains(QString(dec->id)) != custom)
|
||||
+ continue;
|
||||
+ panel->AddDataItem(QString(dec->id), QString(dec->name), info,
|
||||
+ custom ? QString::fromUtf8("Пользовательские") : QString::fromUtf8("Стандартные"));
|
||||
+ }
|
||||
}
|
||||
|
||||
QFont font = this->font();
|
||||
--- a/DSView/pv/mainwindow.cpp
|
||||
+++ b/DSView/pv/mainwindow.cpp
|
||||
@@ -1329,7 +1329,8 @@
|
||||
// default dockwidget size
|
||||
AppConfig &app = AppConfig::Instance();
|
||||
QByteArray st = app.frameOptions.windowState;
|
||||
- if (!st.isEmpty())
|
||||
+ // Standalone toolbar rows must not reserve space above embedded docks.
|
||||
+ if (!qApp->property("embeddedHost").toBool() && !st.isEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
60
tools/dsview/decoders/gate_driver_timing/README.md
Normal file
60
tools/dsview/decoders/gate_driver_timing/README.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 1SP0635 / 1SD536F2 timing decoder for DSView
|
||||
|
||||
This decoder checks driver control and status responses:
|
||||
|
||||
- `Vin1`: optional control sent to the first gate driver (legacy channel ID `vin`);
|
||||
- `Vstat`: edge acknowledgement and fault feedback returned by the driver.
|
||||
|
||||
Built-in data-sheet profiles:
|
||||
|
||||
| Profile | ACK delay (typ.) | ACK width (specified) | Long pulse |
|
||||
|---|---:|---:|---:|
|
||||
| 1SP0635 | 250 ns | 400...1050 ns | fault above 1.5 us |
|
||||
| 1SD536F2 | 380 ns | 600...1800 ns | fault above 1.8 us* |
|
||||
|
||||
`*` The 1.8 us fault threshold is a project classification choice based on the
|
||||
data-sheet ACK maximum, not a verified universal manufacturer fault threshold.
|
||||
The previously stated 1.5 us manual threshold could not be verified.
|
||||
Correlated pulses within the ACK width limits pass. Uncorrelated pulses up to
|
||||
the fault threshold are warnings; pulses above it are faults. Width and fault
|
||||
annotations are emitted only when the pulse ends.
|
||||
|
||||
The data sheets specify only a *typical* ACK delay, not minimum and maximum
|
||||
limits. DSView therefore emits a warning outside the configurable tolerance
|
||||
(default +/-100 ns). The ACK-width check uses the published min/max values.
|
||||
|
||||
## Use in SETGUI
|
||||
|
||||
The DSView build recipe packages this adapter together with the canonical
|
||||
`python/logic_analyzer/decoders/gate_timing.py` core. In the DSView tab,
|
||||
choose **Анализаторы SET → IGBT · 1SP0635 / 1SD536F2** (or search Decode for
|
||||
`IGBT 1SP/1SD`). Assign Vstat and, if available, Vin1, choose the driver profile, and set `Vstat acknowledge/fault level`
|
||||
to match the receiver circuit. The diagrams in the application manual show
|
||||
the logical event; some optical receiver circuits invert it.
|
||||
|
||||
Use at least 50 MS/s; 100 MS/s or more is recommended. Never connect a DSLogic
|
||||
digital input directly to the IGBT gate (+15/-10 V) or to a power-stage node.
|
||||
Use the isolated optical receiver logic signals and a common logic ground.
|
||||
|
||||
## Separate transistor pair analyzer
|
||||
|
||||
This decoder analyzes only Vin1/Vstat driver responses. Vin1 is optional;
|
||||
Vstat alone retains pulse/fault classification, while Vin1 enables ACK timing
|
||||
and full-cycle results. Vstat is required. The channel ID `vin` remains stable.
|
||||
|
||||
Pair analysis was moved to **Transistor pair** in the **Пользовательские**
|
||||
section. Add that decoder separately and assign Vin1/Vin2. Old pair settings
|
||||
on IGBT instances must be recreated in the new decoder. The pair checkbox,
|
||||
Vin2 assignment and pulse limits are no longer part of the driver decoder.
|
||||
|
||||
## Full pulse cycle result
|
||||
|
||||
The **Vin1 / ACK full cycle** waveform row displays OK, WARNING or FAULT
|
||||
across each complete sequence **Vin1 ON -> ACK -> Vin1 OFF -> ACK**.
|
||||
The annotation starts at Vin1 ON and ends at the end of the OFF acknowledgment.
|
||||
It is emitted only after both ACKs have been classified. A delay warning makes
|
||||
that cycle WARNING; missing ACK, invalid width or fault feedback makes it FAULT.
|
||||
A later normal cycle can be OK independently of previous cycles.
|
||||
Unfinished cycles are never marked OK. Active-low Vin1 is supported.
|
||||
The results panel retains its original layout without a large summary banner.
|
||||
Deadtime and mintime are provided by the separate Transistor pair analyzer.
|
||||
3
tools/dsview/decoders/gate_driver_timing/__init__.py
Normal file
3
tools/dsview/decoders/gate_driver_timing/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""DSView protocol decoder for SCALE gate-driver timing checks."""
|
||||
|
||||
from .pd import Decoder
|
||||
168
tools/dsview/decoders/gate_driver_timing/pd.py
Normal file
168
tools/dsview/decoders/gate_driver_timing/pd.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""DSView/libsigrokdecode front end for SCALE gate-driver timing checks."""
|
||||
|
||||
import sigrokdecode as srd
|
||||
|
||||
from common.setgui_decoders.gate_timing import TimingChecker
|
||||
|
||||
|
||||
class SamplerateError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Ann(object):
|
||||
CONTROL = 0
|
||||
DELAY_OK = 1
|
||||
WIDTH_OK = 2
|
||||
WARNING = 3
|
||||
ERROR = 4
|
||||
FAULT = 5
|
||||
CYCLE_OK = 6
|
||||
CYCLE_WARNING = 7
|
||||
CYCLE_FAULT = 8
|
||||
|
||||
|
||||
ANN_FOR_KIND = {
|
||||
'control': Ann.CONTROL,
|
||||
'delay_ok': Ann.DELAY_OK,
|
||||
'width_ok': Ann.WIDTH_OK,
|
||||
'delay_warn': Ann.WARNING,
|
||||
'width_fail': Ann.ERROR,
|
||||
'missing': Ann.ERROR,
|
||||
'orphan': Ann.WARNING,
|
||||
'fault': Ann.FAULT,
|
||||
'cycle_ok': Ann.CYCLE_OK,
|
||||
'cycle_warning': Ann.CYCLE_WARNING,
|
||||
'cycle_fault': Ann.CYCLE_FAULT,
|
||||
}
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'gate_driver_timing'
|
||||
name = 'IGBT 1SP/1SD'
|
||||
longname = 'Power Integrations 1SP0635 / 1SD536F2 timing checker'
|
||||
desc = 'Checks driver Vin1/Vstat ACK timing, faults and full pulse cycles.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['logic']
|
||||
outputs = []
|
||||
tags = ['Clock/timing', 'Util']
|
||||
|
||||
channels = ()
|
||||
optional_channels = (
|
||||
{'id': 'vin', 'name': 'Vin1', 'desc': 'First driver control input'},
|
||||
{'id': 'vstat', 'name': 'Vstat', 'desc': 'Driver status/acknowledge output'},
|
||||
)
|
||||
|
||||
options = (
|
||||
{'id': 'profile', 'desc': 'Driver profile', 'default': '1SP0635',
|
||||
'values': ('1SP0635', '1SD536F2', 'custom')},
|
||||
{'id': 'vin_active', 'desc': 'Vin1 active level', 'default': 'high',
|
||||
'values': ('high', 'low')},
|
||||
{'id': 'vstat_active', 'desc': 'Vstat acknowledge/fault level', 'default': 'high',
|
||||
'values': ('high', 'low')},
|
||||
{'id': 'delay_tolerance_ns',
|
||||
'desc': 'Warning tolerance around typical ACK delay (ns)', 'default': 100},
|
||||
{'id': 'orphan_min_width_ns',
|
||||
'desc': 'ORPHAN minimum pulse width (ns, 0 = off)', 'default': 0},
|
||||
{'id': 'custom_delay_ns', 'desc': 'Custom typical ACK delay (ns)', 'default': 250},
|
||||
{'id': 'custom_width_min_ns', 'desc': 'Custom ACK width minimum (ns)', 'default': 400},
|
||||
{'id': 'custom_width_typ_ns', 'desc': 'Custom ACK width typical (ns)', 'default': 700},
|
||||
{'id': 'custom_width_max_ns', 'desc': 'Custom ACK width maximum (ns)', 'default': 1050},
|
||||
{'id': 'custom_fault_ns', 'desc': 'Custom fault pulse threshold (ns)', 'default': 1500},
|
||||
)
|
||||
|
||||
annotations = (
|
||||
('control', 'Vin1 edge'),
|
||||
('delay-ok', 'ACK delay pass'),
|
||||
('width-ok', 'ACK width pass'),
|
||||
('warning', 'Warning'),
|
||||
('error', 'Timing failure'),
|
||||
('fault', 'Driver fault'),
|
||||
('cycle-ok', 'Cycle OK'),
|
||||
('cycle-warning', 'Cycle WARNING'),
|
||||
('cycle-fault', 'Cycle FAULT'),
|
||||
)
|
||||
|
||||
annotation_rows = (
|
||||
('cycle', 'Vin1 / ACK full cycle', (Ann.CYCLE_OK, Ann.CYCLE_WARNING, Ann.CYCLE_FAULT)),
|
||||
('vin', 'Vin1', (Ann.CONTROL,)),
|
||||
('measurements', 'Measurements', (Ann.DELAY_OK, Ann.WIDTH_OK)),
|
||||
('checks', 'Warnings / failures', (Ann.WARNING, Ann.ERROR, Ann.FAULT)),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.samplerate = None
|
||||
self.checker = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Здесь регистрируются аннотации для дорожек измерений и предупреждений.
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
|
||||
# Адаптер переводит настройки DSView в параметры общего TimingChecker.
|
||||
# Пороговые алгоритмы остаются в templates/python/logic_analyzer/decoders.
|
||||
def _make_checker(self):
|
||||
custom = {
|
||||
'ack_delay_typ_ns': float(self.options['custom_delay_ns']),
|
||||
'ack_width_min_ns': float(self.options['custom_width_min_ns']),
|
||||
'ack_width_typ_ns': float(self.options['custom_width_typ_ns']),
|
||||
'ack_width_max_ns': float(self.options['custom_width_max_ns']),
|
||||
'fault_threshold_ns': float(self.options['custom_fault_ns']),
|
||||
}
|
||||
return TimingChecker(
|
||||
self.samplerate,
|
||||
profile=self.options['profile'],
|
||||
delay_tolerance_ns=float(self.options['delay_tolerance_ns']),
|
||||
vin_active_high=self.options['vin_active'] == 'high',
|
||||
vstat_active_high=self.options['vstat_active'] == 'high',
|
||||
orphan_min_width_ns=self.options.get('orphan_min_width_ns', 0),
|
||||
custom=custom, cycle_results=True)
|
||||
|
||||
def _put_events(self, events):
|
||||
for event in events:
|
||||
ann = ANN_FOR_KIND[event['kind']]
|
||||
self.put(event['start'], max(event['start'], event['end']),
|
||||
self.out_ann, [ann, [event['text'], event['short']]])
|
||||
|
||||
def decode(self):
|
||||
if not self.samplerate:
|
||||
raise SamplerateError('Cannot decode without samplerate.')
|
||||
self.checker = self._make_checker()
|
||||
has_vin1 = self.has_channel(0)
|
||||
if not self.has_channel(1):
|
||||
raise ValueError('Connect Vstat for driver response analysis.')
|
||||
while True:
|
||||
conditions = [{0: 'e'}, {1: 'e'}] if has_vin1 else [{1: 'e'}]
|
||||
timer_bit = 1 << len(conditions)
|
||||
deadline = self.checker.next_deadline()
|
||||
if deadline is not None:
|
||||
conditions.append({'skip': max(1, deadline - self.samplenum)})
|
||||
|
||||
# DSView mutates and returns the same native tuple on every wait.
|
||||
# Release that tuple before waiting again (PyTuple_SetItem requires
|
||||
# exclusive ownership); tuple(...) would not make a copy.
|
||||
pins = list(self.wait(conditions))
|
||||
vin, vstat = pins[:2]
|
||||
now = self.samplenum
|
||||
# Both channels may transition at the same sample; preserve both.
|
||||
if has_vin1 and self.matched & 0b001:
|
||||
self._put_events(self.checker.on_control_edge(now, vin))
|
||||
if self.matched & (0b010 if has_vin1 else 0b001):
|
||||
self._put_events(self.checker.on_status_edge(now, vstat))
|
||||
if deadline is not None and self.matched & timer_bit:
|
||||
self._put_events(self.checker.expire(now))
|
||||
|
||||
def end(self):
|
||||
if self.checker is not None:
|
||||
self._put_events(self.checker.finish_cycles(self.samplenum))
|
||||
1
tools/dsview/decoders/pm35_uart/__init__.py
Normal file
1
tools/dsview/decoders/pm35_uart/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
82
tools/dsview/decoders/pm35_uart/pd.py
Normal file
82
tools/dsview/decoders/pm35_uart/pd.py
Normal file
@@ -0,0 +1,82 @@
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.pm35_uart import PM35Parser
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'pm35_uart'
|
||||
name = 'PM35 UART'
|
||||
longname = 'PM35 / TMS320F28335 terminal'
|
||||
desc = 'MODBUS 03/06 register traffic and CRC16.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['uart']
|
||||
outputs = ['pm35']
|
||||
tags = ['Embedded/industrial']
|
||||
options = (
|
||||
{'id': 'rx_role', 'desc': 'RX carries', 'default': 'response', 'values': ('request', 'response', 'auto')},
|
||||
{'id': 'tx_role', 'desc': 'TX carries', 'default': 'request', 'values': ('request', 'response', 'auto')},
|
||||
{'id': 'baudrate', 'desc': 'UART baudrate (RTU gap detection)', 'default': 115200},
|
||||
{'id': 'bits_per_char', 'desc': 'Bits per UART character including start/stop/parity', 'default': 10},
|
||||
)
|
||||
annotations = (('frame', 'Frame'), ('error', 'Error'))
|
||||
annotation_rows = (('frames', 'Frames', (0,)), ('errors', 'Errors', (1,)))
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.parsers = {}
|
||||
self.last = {}
|
||||
self.samplerate = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Аннотации предназначены для дорожек, структурированные события — для стека.
|
||||
def start(self):
|
||||
if self.options['baudrate'] <= 0 or self.options['bits_per_char'] <= 0:
|
||||
raise ValueError('Baudrate and bits per character must be positive')
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON)
|
||||
|
||||
def emit(self, events, direction):
|
||||
for ss, es, result, error in events:
|
||||
self.put(ss, es, self.out_ann, [1 if error else 0,
|
||||
[('RX', 'TX')[direction] + ' ' + (error or result['summary'])]])
|
||||
if result:
|
||||
result['direction'] = direction
|
||||
self.put(ss, es, self.out_python, ['FRAME', result])
|
||||
|
||||
def decode(self, ss, es, data):
|
||||
kind, direction, payload = data
|
||||
if direction not in (0, 1):
|
||||
return
|
||||
parser = self.parsers.setdefault(direction, PM35Parser(self.options[('rx_role', 'tx_role')[direction]]))
|
||||
if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'):
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [1, [kind]])
|
||||
return
|
||||
if kind != 'FRAME':
|
||||
return
|
||||
if not payload[1]:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [1, ['Invalid UART frame']])
|
||||
return
|
||||
# Граница MODBUS определяется паузой 3,5 символа. bits_per_char включает
|
||||
# все биты символа; эта настройка должна соответствовать базовому UART.
|
||||
if self.samplerate and direction in self.last:
|
||||
gap = self.samplerate * 3.5 * self.options['bits_per_char'] / self.options['baudrate']
|
||||
if ss - self.last[direction] >= gap:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.last[direction] = es
|
||||
if not 0 <= payload[0] <= 255:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [1, ['Configure UART for 8 data bits']])
|
||||
return
|
||||
self.emit(parser.feed(payload[0], ss, es), direction)
|
||||
1
tools/dsview/decoders/set_can/__init__.py
Normal file
1
tools/dsview/decoders/set_can/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
66
tools/dsview/decoders/set_can/pd.py
Normal file
66
tools/dsview/decoders/set_can/pd.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.set_can import Reassembler, legacy
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'set_can'
|
||||
name = 'SET CAN'
|
||||
longname = 'SET v2 CAN / ProtoCAN / Balsam'
|
||||
desc = 'Application fields and segmented SET packets over classic CAN.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['can']
|
||||
outputs = ['set']
|
||||
tags = ['Embedded/industrial']
|
||||
options = ({'id': 'protocol', 'desc': 'CAN protocol (select explicitly)',
|
||||
'default': 'protocan', 'values': ('protocan', 'set-v2', 'balsam')},)
|
||||
annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'))
|
||||
annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)))
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.reassembler = Reassembler()
|
||||
self.samplerate = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Аннотации предназначены для дорожек, структурированные события — для стека.
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON)
|
||||
|
||||
def decode(self, ss, es, data):
|
||||
frame_type, ident, rtr, dlc, payload = data
|
||||
if frame_type != 'extended' or rtr == 'remote':
|
||||
return
|
||||
mode = self.options['protocol']
|
||||
if not 0 <= dlc <= 8 or len(payload) != dlc:
|
||||
events = [(ss, es, None, 'Invalid classic CAN DLC')]
|
||||
# Сегментированный SET v2 требует времени для тайм-аута сборки.
|
||||
# Идентификатор, порядок сегментов и CRC проверяет общее ядро.
|
||||
elif mode == 'set-v2':
|
||||
if not self.samplerate:
|
||||
raise ValueError('SET CAN requires samplerate for reassembly timeout')
|
||||
events = self.reassembler.feed(ident, payload, ss, es, es * 1000.0 / self.samplerate)
|
||||
else:
|
||||
try:
|
||||
result = legacy(ident, payload, mode)
|
||||
events = [(ss, es, result, None)] if result else []
|
||||
except ValueError as exc:
|
||||
events = [(ss, es, None, str(exc))]
|
||||
for start, end, result, error in events:
|
||||
self.put(start, end, self.out_ann, [2 if error else 0, [error or result['summary']]])
|
||||
if result:
|
||||
result['can_id'] = ident
|
||||
self.put(start, end, self.out_python, ['FRAME', result])
|
||||
if events and payload:
|
||||
self.put(ss, es, self.out_ann, [1, [' '.join('%02X' % b for b in payload)]])
|
||||
1
tools/dsview/decoders/set_uart/__init__.py
Normal file
1
tools/dsview/decoders/set_uart/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
89
tools/dsview/decoders/set_uart/pd.py
Normal file
89
tools/dsview/decoders/set_uart/pd.py
Normal file
@@ -0,0 +1,89 @@
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.set_uart import StreamParser
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'set_uart'
|
||||
name = 'SET UART'
|
||||
longname = 'SET v2 / SETGUI v1 / ProtoCAN bridge'
|
||||
desc = 'SET frames, fields and CRC over UART.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['uart']
|
||||
outputs = ['set']
|
||||
tags = ['Embedded/industrial']
|
||||
options = (
|
||||
{'id': 'protocol', 'desc': 'Protocol', 'default': 'auto',
|
||||
'values': ('auto', 'v2', 'v1', 'bridge')},
|
||||
{'id': 'gap_ms', 'desc': 'Reset incomplete frame after gap (ms, 0 disables)', 'default': 100},
|
||||
)
|
||||
annotations = (('frame', 'Frame'), ('payload', 'Payload'), ('error', 'Error'))
|
||||
annotation_rows = (('frames', 'Frames', (0,)), ('payload', 'Payload', (1,)), ('errors', 'Errors', (2,)))
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
# Состояние принадлежит одному запуску декодера. Сброс не должен
|
||||
# переносить незавершённый кадр или частоту из предыдущей записи.
|
||||
def reset(self):
|
||||
self.parsers = {}
|
||||
self.last = {}
|
||||
self.samplerate = None
|
||||
|
||||
# DSView передаёт время в номерах отсчётов. Частота нужна общему ядру
|
||||
# для перевода тайм-аутов и измерений в физические единицы.
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
# Выходы регистрируются в жизненном цикле sigrok, а не в конструкторе.
|
||||
# Аннотации предназначены для дорожек, структурированные события — для стека.
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
self.out_python = self.register(srd.OUTPUT_PYTHON)
|
||||
|
||||
def emit(self, events, direction):
|
||||
for ss, es, frame, error in events:
|
||||
prefix = ('RX', 'TX')[direction] + ' '
|
||||
self.put(ss, es, self.out_ann, [2 if error else 0, [prefix + (error or frame['summary'])]])
|
||||
if frame:
|
||||
if frame['protocol'] == 'ProtoCAN bridge' and frame['flags'] & 1 and not frame['flags'] & 10:
|
||||
# Imported only at decode time: both packages are installed together.
|
||||
from common.setgui_decoders.set_can import legacy
|
||||
try:
|
||||
application = legacy(frame['can_id'], frame['payload'])
|
||||
frame['application'] = application
|
||||
self.put(ss, es, self.out_ann, [0, [application['summary']]])
|
||||
except ValueError as exc:
|
||||
self.put(ss, es, self.out_ann, [2, [str(exc)]])
|
||||
frame['direction'] = direction
|
||||
self.put(ss, es, self.out_python, ['FRAME', frame])
|
||||
if frame['payload']:
|
||||
self.put(ss, es, self.out_ann, [1, [prefix + ' '.join('%02X' % b for b in frame['payload'])]])
|
||||
|
||||
def decode(self, ss, es, data):
|
||||
kind, direction, payload = data
|
||||
if direction not in (0, 1):
|
||||
return
|
||||
# RX и TX имеют независимые парсеры: перемежающиеся байты двух направлений
|
||||
# нельзя объединять в один прикладной кадр.
|
||||
parser = self.parsers.setdefault(direction, StreamParser(self.options['protocol']))
|
||||
if kind in ('INVALID STOPBIT', 'INVALID STARTBIT', 'PARITY ERROR', 'BREAK'):
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [2, [kind]])
|
||||
return
|
||||
if kind != 'FRAME':
|
||||
return
|
||||
if not payload[1]:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [2, ['Invalid UART frame']])
|
||||
return
|
||||
gap = float(self.options['gap_ms'])
|
||||
if self.samplerate and gap > 0 and direction in self.last and ss - self.last[direction] > self.samplerate * gap / 1000:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.last[direction] = es
|
||||
if not 0 <= payload[0] <= 255:
|
||||
self.emit(parser.flush(), direction)
|
||||
self.put(ss, es, self.out_ann, [2, ['Configure UART for 8 data bits']])
|
||||
return
|
||||
self.emit(parser.feed(payload[0], ss, es), direction)
|
||||
26
tools/dsview/decoders/transistor_pair/README.md
Normal file
26
tools/dsview/decoders/transistor_pair/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Transistor pair
|
||||
|
||||
Independent two-channel analyzer. Assign Vin1 and Vin2; Vstat is not used.
|
||||
Available in DSView's **Пользовательские** section as **Transistor pair**.
|
||||
|
||||
- Minimum active ON pulse duration (mintime), independently per input.
|
||||
- Minimum inactive OFF duration, independently per input.
|
||||
- Deadtime from Vin1 OFF to Vin2 ON and vice versa, with independent minimums.
|
||||
- Active overlap is a fault, regardless of the deadtime settings.
|
||||
- Period, frequency and active duty cycle for complete cycles on each input.
|
||||
- Independent active-high/active-low polarity for both inputs.
|
||||
|
||||
All limits are nanoseconds. Zero disables the corresponding lower limit;
|
||||
equality passes. Simultaneous handovers measure zero deadtime. These settings
|
||||
check measured pulses and do not debounce or filter the source waveform.
|
||||
|
||||
Measurements occupy separate waveform rows. Timing violations appear in the
|
||||
FAULT row. Unknown edges outside the capture are not fabricated; partial
|
||||
pulses do not produce full pulse widths or full-cycle frequency/duty values.
|
||||
|
||||
Python: `PairTimingChecker` in `logic_analyzer.decoders.transistor_pair`.
|
||||
Offline API: `analyze_capture(..., dict(mode='Transistor pair', channel=0,
|
||||
vin2_channel=1, vin1_mintime_ns=100, deadtime_12_ns=50))`.
|
||||
|
||||
For Vin/Vstat ACK checks and full driver-cycle OK/WARNING/FAULT results use
|
||||
the separate **IGBT 1SP/1SD** decoder.
|
||||
1
tools/dsview/decoders/transistor_pair/__init__.py
Normal file
1
tools/dsview/decoders/transistor_pair/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .pd import Decoder
|
||||
88
tools/dsview/decoders/transistor_pair/pd.py
Normal file
88
tools/dsview/decoders/transistor_pair/pd.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Two-input transistor pair analyzer, independent of driver feedback."""
|
||||
import sigrokdecode as srd
|
||||
from common.setgui_decoders.transistor_pair import PairTimingChecker
|
||||
|
||||
|
||||
class Decoder(srd.Decoder):
|
||||
api_version = 3
|
||||
id = 'transistor_pair'
|
||||
name = 'Transistor pair'
|
||||
longname = 'Transistor pair: mintime / deadtime / overlap'
|
||||
desc = 'Checks ON/OFF pulse limits, deadtime, overlap, frequency and duty.'
|
||||
license = 'gplv2+'
|
||||
inputs = ['logic']
|
||||
outputs = []
|
||||
tags = ['Clock/timing', 'Util']
|
||||
channels = (
|
||||
{'id': 'vin1', 'name': 'Vin1', 'desc': 'First transistor command'},
|
||||
{'id': 'vin2', 'name': 'Vin2', 'desc': 'Second transistor command'},
|
||||
)
|
||||
options = (
|
||||
{'id': 'vin1_active', 'desc': 'Vin1 active level', 'default': 'high', 'values': ('high', 'low')},
|
||||
{'id': 'vin2_active', 'desc': 'Vin2 active level', 'default': 'high', 'values': ('high', 'low')},
|
||||
{'id': 'vin1_mintime_ns', 'desc': 'Vin1 minimum ON time (ns)', 'default': 0},
|
||||
{'id': 'vin2_mintime_ns', 'desc': 'Vin2 minimum ON time (ns)', 'default': 0},
|
||||
{'id': 'vin1_minoff_ns', 'desc': 'Vin1 minimum OFF time (ns)', 'default': 0},
|
||||
{'id': 'vin2_minoff_ns', 'desc': 'Vin2 minimum OFF time (ns)', 'default': 0},
|
||||
{'id': 'deadtime_12_ns', 'desc': 'Minimum deadtime Vin1 -> Vin2 (ns)', 'default': 0},
|
||||
{'id': 'deadtime_21_ns', 'desc': 'Minimum deadtime Vin2 -> Vin1 (ns)', 'default': 0},
|
||||
)
|
||||
annotations = (
|
||||
('vin1-on', 'Vin1 ON duration'), ('vin2-on', 'Vin2 ON duration'),
|
||||
('deadtime', 'Deadtime'), ('failure', 'FAULT'),
|
||||
('vin1-off', 'Vin1 OFF duration'), ('vin2-off', 'Vin2 OFF duration'),
|
||||
('vin1-period', 'Vin1 period / frequency / duty'),
|
||||
('vin2-period', 'Vin2 period / frequency / duty'),
|
||||
)
|
||||
annotation_rows = (
|
||||
('deadtime', 'Deadtime', (2,)),
|
||||
('faults', 'FAULT: overlap / timing', (3,)),
|
||||
('vin1', 'Vin1 mintime / OFF', (0, 4)),
|
||||
('vin2', 'Vin2 mintime / OFF', (1, 5)),
|
||||
('pwm1', 'Vin1 period / frequency / duty', (6,)),
|
||||
('pwm2', 'Vin2 period / frequency / duty', (7,)),
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.samplerate = None
|
||||
self.checker = None
|
||||
|
||||
def metadata(self, key, value):
|
||||
if key == srd.SRD_CONF_SAMPLERATE:
|
||||
self.samplerate = value
|
||||
|
||||
def start(self):
|
||||
self.out_ann = self.register(srd.OUTPUT_ANN)
|
||||
|
||||
def _put_events(self, events):
|
||||
for event in events:
|
||||
kind = event['kind']
|
||||
channel = event.get('channel', 1) - 1
|
||||
ann = (3 if kind in ('mintime_fail', 'deadtime_fail', 'off_fail', 'overlap') else
|
||||
2 if kind == 'deadtime' else channel if kind == 'mintime_ok' else
|
||||
4 + channel if kind == 'off_time' else 6 + channel)
|
||||
self.put(event['start'], event['end'], self.out_ann, [ann, [event['text'], event['short']]])
|
||||
|
||||
def decode(self):
|
||||
if not self.samplerate:
|
||||
raise ValueError('Cannot decode without samplerate.')
|
||||
if not self.has_channel(0) or not self.has_channel(1):
|
||||
raise ValueError('Connect both Vin1 and Vin2.')
|
||||
numeric = {name: self.options[name] for name in (
|
||||
'vin1_mintime_ns', 'vin2_mintime_ns', 'vin1_minoff_ns', 'vin2_minoff_ns',
|
||||
'deadtime_12_ns', 'deadtime_21_ns')}
|
||||
self.checker = PairTimingChecker(self.samplerate,
|
||||
self.options['vin1_active'] == 'high', self.options['vin2_active'] == 'high', **numeric)
|
||||
# Do not retain DSView's mutable native wait tuple.
|
||||
pins = list(self.wait({}))
|
||||
self._put_events(self.checker.update(self.samplenum, *pins))
|
||||
while True:
|
||||
pins = list(self.wait([{0: 'e'}, {1: 'e'}]))
|
||||
self._put_events(self.checker.update(self.samplenum, *pins))
|
||||
|
||||
def end(self):
|
||||
if self.checker is not None:
|
||||
self._put_events(self.checker.finish(self.samplenum))
|
||||
83
tools/dsview/driver-summary.patch
Normal file
83
tools/dsview/driver-summary.patch
Normal file
@@ -0,0 +1,83 @@
|
||||
--- a/DSView/pv/data/decoderstack.h
|
||||
+++ b/DSView/pv/data/decoderstack.h
|
||||
@@ -30,6 +30,7 @@
|
||||
#include <QString>
|
||||
#include <mutex>
|
||||
|
||||
+#include "decode/gate_summary.h"
|
||||
#include "decode/row.h"
|
||||
#include "../data/signaldata.h"
|
||||
#include "decode/decoderstatus.h"
|
||||
@@ -96,6 +97,7 @@
|
||||
}
|
||||
|
||||
const char* get_root_decoder_id();
|
||||
+ GateSummary gate_summary;
|
||||
|
||||
void add_sub_decoder(decode::Decoder *decoder);
|
||||
void remove_sub_decoder(decode::Decoder *decoder);
|
||||
--- a/DSView/pv/data/decoderstack.cpp
|
||||
+++ b/DSView/pv/data/decoderstack.cpp
|
||||
@@ -369,6 +369,7 @@
|
||||
|
||||
void DecoderStack::init()
|
||||
{
|
||||
+ gate_summary.reset();
|
||||
_sample_count = 0;
|
||||
_samples_decoded = 0;
|
||||
_error_message = QString();
|
||||
@@ -814,6 +815,9 @@
|
||||
const srd_decoder *const decc = pdata->pdo->di->decoder;
|
||||
assert(decc);
|
||||
|
||||
+ if (strcmp(decc->id, "gate_driver_timing") == 0)
|
||||
+ d->gate_summary.observe(a->format());
|
||||
+
|
||||
auto row_iter = d->_rows.end();
|
||||
|
||||
// Try looking up the sub-row of this class
|
||||
--- a/DSView/pv/dock/protocoldock.cpp
|
||||
+++ b/DSView/pv/dock/protocoldock.cpp
|
||||
@@ -190,6 +190,42 @@
|
||||
match_layout->addStretch(1);
|
||||
|
||||
QVBoxLayout *bot_layout = new QVBoxLayout();
|
||||
+ auto gate_status = new QLabel(bot_panel);
|
||||
+ gate_status->setObjectName("gateDriverSummary");
|
||||
+ gate_status->setAlignment(Qt::AlignCenter);
|
||||
+ gate_status->setMinimumHeight(64);
|
||||
+ gate_status->hide();
|
||||
+ bot_layout->addWidget(gate_status);
|
||||
+ auto gate_timer = new QTimer(this);
|
||||
+ connect(gate_timer, &QTimer::timeout, this, [this, gate_status]() {
|
||||
+ int severity = 0;
|
||||
+ int drivers = 0;
|
||||
+ bool complete = true;
|
||||
+ for (auto trace : _session->get_decode_signals()) {
|
||||
+ auto stack = trace->decoder();
|
||||
+ if (QString(stack->get_root_decoder_id()) != "gate_driver_timing") continue;
|
||||
+ ++drivers;
|
||||
+ const bool done = !stack->IsRunning() && stack->get_progress() == 100;
|
||||
+ complete = complete && done;
|
||||
+ int result = stack->gate_summary.severity(done);
|
||||
+ if (stack->out_of_memory() || !stack->error_message().isEmpty())
|
||||
+ result = qMax(result, 1);
|
||||
+ severity = qMax(severity, result);
|
||||
+ }
|
||||
+ gate_status->setVisible(drivers > 0);
|
||||
+ if (!drivers) return;
|
||||
+ const QString text = severity == 2 ? "FAULT" : severity == 1 ? "WARNING" : "OK";
|
||||
+ const QString color = severity == 2 ? "#ff6262" : severity == 1 ? "#ffc857" : "#41d687";
|
||||
+ if (gate_status->text() != text) {
|
||||
+ gate_status->setText(text);
|
||||
+ gate_status->setStyleSheet("QLabel#gateDriverSummary { font-size: 32px; font-weight: bold; "
|
||||
+ "padding: 8px; border: 2px solid " + color + "; border-radius: 6px; color: " + color + "; }");
|
||||
+ }
|
||||
+ gate_status->setToolTip(complete
|
||||
+ ? QString::fromUtf8("Итог всех анализаторов драйвера за всю запись. FAULT: ошибки ACK, Vstat или входов; WARNING: предупреждения, неполные события или нет подтверждений; OK: проверки пройдены.")
|
||||
+ : QString::fromUtf8("Анализ ещё не завершён. Итог OK пока недоступен."));
|
||||
+ });
|
||||
+ gate_timer->start(250);
|
||||
bot_layout->addLayout(bot_title_layout);
|
||||
bot_layout->addLayout(ann_search_layout);
|
||||
bot_layout->addLayout(match_layout);
|
||||
35
tools/dsview/gate-pair-checkbox.patch
Normal file
35
tools/dsview/gate-pair-checkbox.patch
Normal file
@@ -0,0 +1,35 @@
|
||||
--- a/DSView/pv/prop/binding/decoderoptions.cpp
|
||||
+++ b/DSView/pv/prop/binding/decoderoptions.cpp
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
#include "../../data/decoderstack.h"
|
||||
#include "../../data/decode/decoder.h"
|
||||
+#include "../bool.h"
|
||||
#include "../double.h"
|
||||
#include "../enum.h"
|
||||
#include "../int.h"
|
||||
@@ -85,7 +86,23 @@
|
||||
|
||||
Property *prop = NULL;
|
||||
|
||||
- if (opt->values)
|
||||
+ if (strcmp(dec->id, "gate_driver_timing") == 0 &&
|
||||
+ strcmp(opt->id, "pair_analysis") == 0) {
|
||||
+ prop = new Bool(name, name,
|
||||
+ [getter]() -> GVariant* {
|
||||
+ GVariant *value = getter();
|
||||
+ const bool enabled = value && g_variant_get_int64(value) != 0;
|
||||
+ if (value) g_variant_unref(value);
|
||||
+ return g_variant_ref_sink(g_variant_new_boolean(enabled));
|
||||
+ },
|
||||
+ [setter](GVariant *value) {
|
||||
+ GVariant *number = g_variant_ref_sink(g_variant_new_int64(
|
||||
+ g_variant_get_boolean(value) ? 1 : 0));
|
||||
+ setter(number);
|
||||
+ g_variant_unref(number);
|
||||
+ });
|
||||
+ }
|
||||
+ else if (opt->values)
|
||||
prop = bind_enum(name, opt, getter, setter);
|
||||
else if (g_variant_is_of_type(opt->def, G_VARIANT_TYPE("d")))
|
||||
prop = new Double(name, name, 2, "",none, none, getter, setter);
|
||||
151
tools/dsview/install_decoders.ps1
Normal file
151
tools/dsview/install_decoders.ps1
Normal file
@@ -0,0 +1,151 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$DsViewPath = (Join-Path $env:ProgramFiles 'DSView'),
|
||||
[switch]$CheckOnly
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Set-StrictMode -Version 2
|
||||
# Resolve source paths relative to templates, never the caller working directory.
|
||||
# Standalone DSView and SETGUI use the same adapters and parsing cores.
|
||||
$templateRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../..'))
|
||||
$targetRoot = [IO.Path]::GetFullPath($DsViewPath)
|
||||
$decoderRoot = Join-Path $targetRoot 'decoders'
|
||||
|
||||
# Use .NET for compatibility with PowerShell lacking Get-FileHash.
|
||||
# Release both stream and hashing provider even if reading fails.
|
||||
function Get-ContentHash([string]$Path) {
|
||||
$algorithm = [Security.Cryptography.SHA256]::Create()
|
||||
$stream = [IO.File]::OpenRead($Path)
|
||||
try {
|
||||
return [BitConverter]::ToString($algorithm.ComputeHash($stream))
|
||||
} finally {
|
||||
$stream.Dispose()
|
||||
$algorithm.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
# Check all existing ancestors: a junction under decoders/common could
|
||||
# redirect a local-looking update outside the chosen installation.
|
||||
function Assert-PlainPath([string]$Path) {
|
||||
$cursor = [IO.Path]::GetFullPath($Path)
|
||||
$first = $true
|
||||
while ($cursor) {
|
||||
if (Test-Path -LiteralPath $cursor) {
|
||||
$item = Get-Item -LiteralPath $cursor -Force
|
||||
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
throw "Symbolic links and junctions are not supported: $cursor"
|
||||
}
|
||||
if (-not $first -and -not $item.PSIsContainer) {
|
||||
throw "Expected a parent directory: $cursor"
|
||||
}
|
||||
}
|
||||
$parent = [IO.Path]::GetDirectoryName($cursor)
|
||||
if ($parent -eq $cursor) { break }
|
||||
$cursor = $parent
|
||||
$first = $false
|
||||
}
|
||||
}
|
||||
|
||||
Assert-PlainPath $targetRoot
|
||||
$exe = Join-Path $targetRoot 'DSView.exe'
|
||||
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
|
||||
throw "DSView.exe not found in '$targetRoot'. Pass -DsViewPath with the installed DSView directory."
|
||||
}
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $decoderRoot 'common') -PathType Container)) {
|
||||
throw "DSView decoders/common is missing. Select a complete DSView installation."
|
||||
}
|
||||
|
||||
# An explicit allowlist excludes tests, bytecode and unrelated modules.
|
||||
# The private common/setgui_decoders layout matches the embedded build.
|
||||
$files = @()
|
||||
foreach ($name in @('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) {
|
||||
foreach ($leaf in @('__init__.py', 'pd.py')) {
|
||||
$files += [pscustomobject]@{
|
||||
Source = Join-Path $PSScriptRoot "decoders/$name/$leaf"
|
||||
Relative = "$name/$leaf"
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach ($name in @('__init__', 'gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')) {
|
||||
$files += [pscustomobject]@{
|
||||
Source = Join-Path $templateRoot "python/logic_analyzer/decoders/$name.py"
|
||||
Relative = "common/setgui_decoders/$name.py"
|
||||
}
|
||||
}
|
||||
# Validate the entire payload and destination before changing any decoder.
|
||||
foreach ($file in $files) {
|
||||
if (-not (Test-Path -LiteralPath $file.Source -PathType Leaf)) {
|
||||
throw "Missing templates source: $($file.Source). Initialize the templates submodule."
|
||||
}
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
Assert-PlainPath $destination
|
||||
if (Test-Path -LiteralPath $destination -PathType Container) {
|
||||
throw "Expected a file, found a directory: $destination"
|
||||
}
|
||||
if ([IO.Path]::GetFullPath($file.Source) -eq [IO.Path]::GetFullPath($destination)) {
|
||||
throw 'The installation destination must differ from the templates source.'
|
||||
}
|
||||
}
|
||||
# Read-only validation does not create backups or check elevated write access.
|
||||
# Real installation performs its own write-access check before copying.
|
||||
if ($CheckOnly) {
|
||||
Write-Output "Validated $($files.Count) files for $targetRoot. No files changed."
|
||||
return
|
||||
}
|
||||
# A mapped/running Windows EXE cannot be opened for writing. Never terminate DSView.
|
||||
try {
|
||||
$handle = [IO.File]::Open($exe, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::Read)
|
||||
$handle.Dispose()
|
||||
} catch {
|
||||
throw "Close DSView and check write permissions for '$targetRoot'. For Program Files, run PowerShell as administrator. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
# Keep each previous version outside decoder discovery directories.
|
||||
# Retain backups after success for manual recovery, not only after errors.
|
||||
$backup = Join-Path $targetRoot ('set-decoder-backups/' + (Get-Date -Format 'yyyyMMdd-HHmmss') + '-' + [guid]::NewGuid().ToString('N'))
|
||||
Assert-PlainPath $backup
|
||||
# Back up all existing files before writing the first replacement.
|
||||
foreach ($file in $files) {
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
if (Test-Path -LiteralPath $destination -PathType Leaf) {
|
||||
$saved = Join-Path $backup $file.Relative
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($saved)) | Out-Null
|
||||
Copy-Item -LiteralPath $destination -Destination $saved
|
||||
}
|
||||
}
|
||||
# Track files before copying: a failed copy may leave a partial destination.
|
||||
# Rollback must include that file as well as completed replacements.
|
||||
$attempted = @()
|
||||
try {
|
||||
foreach ($file in $files) {
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
[IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($destination)) | Out-Null
|
||||
$attempted += $file
|
||||
Copy-Item -LiteralPath $file.Source -Destination $destination -Force
|
||||
if ((Get-ContentHash $file.Source) -ne (Get-ContentHash $destination)) {
|
||||
throw "Copy verification failed: $destination"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
$failure = $_
|
||||
# Restore overwritten files and remove only new files from our allowlist.
|
||||
# Never recursively delete decoder folders or remove unrelated analyzers.
|
||||
foreach ($file in $attempted) {
|
||||
$destination = Join-Path $decoderRoot $file.Relative
|
||||
$saved = Join-Path $backup $file.Relative
|
||||
try {
|
||||
if (Test-Path -LiteralPath $saved -PathType Leaf) {
|
||||
Copy-Item -LiteralPath $saved -Destination $destination -Force
|
||||
} elseif (Test-Path -LiteralPath $destination -PathType Leaf) {
|
||||
Remove-Item -LiteralPath $destination -Force
|
||||
}
|
||||
} catch {
|
||||
Write-Warning "Could not restore $destination. Retained backup: $backup"
|
||||
}
|
||||
}
|
||||
throw $failure
|
||||
}
|
||||
Write-Output "Installed: IGBT 1SP/1SD, SET UART, SET CAN, PM35 UART in $decoderRoot"
|
||||
if (Test-Path -LiteralPath $backup) { Write-Output "Previous files retained in: $backup" }
|
||||
Write-Output 'Restart DSView. Add UART/CAN first, then stack the corresponding SET decoder. No external Python is required.'
|
||||
29
tools/dsview/native/gate_summary.h
Normal file
29
tools/dsview/native/gate_summary.h
Normal file
@@ -0,0 +1,29 @@
|
||||
#pragma once
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
// Annotation IDs are the public gate_driver_timing decoder contract.
|
||||
// Independent of row visibility and text search; one summary per decode run.
|
||||
class GateSummary {
|
||||
public:
|
||||
void reset() {
|
||||
controls = 0; acknowledgments = 0; warnings = 0; faults = 0;
|
||||
}
|
||||
void observe(int annotation) {
|
||||
switch (annotation) {
|
||||
case 0: ++controls; break;
|
||||
case 2: ++acknowledgments; break;
|
||||
case 3: ++warnings; break;
|
||||
case 4: case 5: ++faults; break;
|
||||
}
|
||||
}
|
||||
// 0 = OK, 1 = WARNING / incomplete, 2 = FAULT.
|
||||
int severity(bool complete) const {
|
||||
if (faults.load()) return 2;
|
||||
if (!complete || warnings.load() || !acknowledgments.load() ||
|
||||
controls.load() != acknowledgments.load()) return 1;
|
||||
return 0;
|
||||
}
|
||||
private:
|
||||
std::atomic<uint64_t> controls{0}, acknowledgments{0}, warnings{0}, faults{0};
|
||||
};
|
||||
1686
tools/dsview/setgui-host.patch
Normal file
1686
tools/dsview/setgui-host.patch
Normal file
@@ -0,0 +1,1686 @@
|
||||
diff --git a/CMakeLists.txt b/CMakeLists.txt
|
||||
index cc82669f..575eb851 100644
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -190,6 +190,10 @@ if(Qt5Core_FOUND)
|
||||
#set(QT_LIBRARIES Qt5::Gui Qt5::Widgets Qt5::WinExtras)
|
||||
set(QT_INCLUDE_DIRS ${Qt5Gui_INCLUDE_DIRS} ${Qt5Widgets_INCLUDE_DIRS})
|
||||
set(QT_LIBRARIES Qt5::Gui Qt5::Widgets)
|
||||
+ if(WIN32)
|
||||
+ find_package(Qt5WinExtras REQUIRED)
|
||||
+ list(APPEND QT_LIBRARIES Qt5::WinExtras)
|
||||
+ endif()
|
||||
add_definitions(${Qt5Gui_DEFINITIONS} ${Qt5Widgets_DEFINITIONS})
|
||||
else()
|
||||
find_package(Qt6Core QUIET)
|
||||
@@ -551,10 +555,13 @@ set(DSView_RESOURCES
|
||||
|
||||
if(WIN32)
|
||||
# Use the DSView icon for the DSView.exe executable.
|
||||
+ add_definitions(-DUNICODE -D_UNICODE)
|
||||
set(CMAKE_RC_COMPILE_OBJECT "${CMAKE_RC_COMPILER} -O coff -I${CMAKE_CURRENT_SOURCE_DIR} <SOURCE> <OBJECT>")
|
||||
enable_language(RC)
|
||||
# app icon
|
||||
list(APPEND DSView_SOURCES applogo.rc)
|
||||
+ list(APPEND DSView_SOURCES DSView/pv/winnativewidget.cpp DSView/pv/winshadow.cpp)
|
||||
+ list(APPEND DSView_HEADERS DSView/pv/winshadow.h)
|
||||
endif()
|
||||
|
||||
if(Qt5Core_FOUND)
|
||||
diff --git a/DSView/main.cpp b/DSView/main.cpp
|
||||
index 748a59d9..58d2d155 100644
|
||||
--- a/DSView/main.cpp
|
||||
+++ b/DSView/main.cpp
|
||||
@@ -64,6 +64,7 @@ int main(int argc, char *argv[])
|
||||
const char *open_file = NULL;
|
||||
int logLevel = -1;
|
||||
bool bStoreLog = false;
|
||||
+ quintptr embedParent = 0;
|
||||
|
||||
//----------------------rebuild command param
|
||||
#ifdef _WIN32
|
||||
@@ -103,6 +104,8 @@ int main(int argc, char *argv[])
|
||||
{"version", no_argument, 0, 'v'},
|
||||
{"storelog", no_argument, 0, 's'},
|
||||
{"help", no_argument, 0, 'h'},
|
||||
+ {"embed-parent", required_argument, 0, 1000},
|
||||
+ {"host-version", no_argument, 0, 1001},
|
||||
{0, 0, 0, 0}
|
||||
};
|
||||
|
||||
@@ -113,6 +116,20 @@ int main(int argc, char *argv[])
|
||||
|
||||
switch (c)
|
||||
{
|
||||
+ case 1000: {
|
||||
+ bool ok = false;
|
||||
+ embedParent = QString::fromUtf8(optarg).toULongLong(&ok, 0);
|
||||
+#ifdef _WIN32
|
||||
+ if (!ok || !embedParent || !IsWindow(reinterpret_cast<HWND>(embedParent)))
|
||||
+#else
|
||||
+ if (true)
|
||||
+#endif
|
||||
+ return 2;
|
||||
+ break;
|
||||
+ }
|
||||
+ case 1001:
|
||||
+ printf("DSView host protocol 1\n");
|
||||
+ return 0;
|
||||
case 'l': // log level
|
||||
logLevel = atoi(optarg);
|
||||
break;
|
||||
@@ -174,6 +191,14 @@ bool bHighScale = true;
|
||||
QApplication::setApplicationName("DSView");
|
||||
QApplication::setOrganizationName("DreamSourceLab");
|
||||
QApplication::setOrganizationDomain("www.DreamSourceLab.com");
|
||||
+ if (embedParent) {
|
||||
+ QApplication::setOrganizationName("SET");
|
||||
+ QApplication::setApplicationName("SETGUI-DSView");
|
||||
+ a.setProperty("embeddedHost", true);
|
||||
+ // The native child uses Qt::Tool and is not a primary Qt window.
|
||||
+ // Closing a modal dialog must not end the hosted application's loop.
|
||||
+ a.setQuitOnLastWindowClosed(false);
|
||||
+ }
|
||||
|
||||
//----------------------init log
|
||||
dsv_log_init(); // Don't call before QApplication be inited
|
||||
@@ -199,6 +224,17 @@ bool bHighScale = true;
|
||||
AppControl *control = AppControl::Instance();
|
||||
AppConfig &app = AppConfig::Instance();
|
||||
app.LoadAll(); //load app config
|
||||
+ if (embedParent) {
|
||||
+ app.frameOptions.style = "dark";
|
||||
+ app.appOptions.fontSize = 10;
|
||||
+ QString captureDir = QString::fromUtf8(qgetenv("DSVIEW_CAPTURE_DIR"));
|
||||
+ if (QDir(captureDir).exists() && !captureDir.isEmpty()) {
|
||||
+ app.userHistory.saveDir = captureDir;
|
||||
+ app.userHistory.openDir = captureDir;
|
||||
+ app.userHistory.exportDir = captureDir;
|
||||
+ app.userHistory.screenShotPath = captureDir;
|
||||
+ }
|
||||
+ }
|
||||
LangResource::Instance()->Load(app.frameOptions.language);
|
||||
|
||||
if (app.appOptions.ableSaveLog){
|
||||
@@ -237,8 +273,23 @@ bool bHighScale = true;
|
||||
{
|
||||
pv::MainFrame w;
|
||||
control->Start();
|
||||
- w.ShowFormInit();
|
||||
- w.ShowHelpDocAsync(); //to show the dailog for open help document
|
||||
+#ifdef _WIN32
|
||||
+ if (embedParent) {
|
||||
+ if (!w.ShowEmbedded(embedParent)) return 2;
|
||||
+ printf("DSVIEW_READY %llu\n", static_cast<unsigned long long>(w.winId()));
|
||||
+ printf("DSVIEW_CAPABILITIES OPEN_FILE\n");
|
||||
+ fflush(stdout);
|
||||
+ // Optional render artifact for integration smoke tests.
|
||||
+ const QString screenshot = QString::fromUtf8(qgetenv("DSVIEW_HOST_SCREENSHOT"));
|
||||
+ if (!screenshot.isEmpty()) QTimer::singleShot(2500, &w, [&w, screenshot]() {
|
||||
+ w.grab().save(screenshot);
|
||||
+ });
|
||||
+ } else
|
||||
+#endif
|
||||
+ {
|
||||
+ w.ShowFormInit();
|
||||
+ w.ShowHelpDocAsync();
|
||||
+ }
|
||||
|
||||
ret = a.exec(); //Run the application
|
||||
control->Stop();
|
||||
diff --git a/DSView/pv/config/appconfig.cpp b/DSView/pv/config/appconfig.cpp
|
||||
index 1e318306..b4a7f5ea 100644
|
||||
--- a/DSView/pv/config/appconfig.cpp
|
||||
+++ b/DSView/pv/config/appconfig.cpp
|
||||
@@ -438,8 +438,9 @@ bool AppConfig::IsDarkStyle()
|
||||
return false;
|
||||
}
|
||||
|
||||
-QColor AppConfig::GetStyleColor()
|
||||
-{
|
||||
+QColor AppConfig::GetStyleColor()
|
||||
+{
|
||||
+ if (qApp->property("embeddedHost").toBool()) return QColor("#10151d");
|
||||
if (IsDarkStyle()){
|
||||
return QColor(38, 38, 38);
|
||||
}
|
||||
@@ -551,4 +552,4 @@ QString GetProfileDir()
|
||||
#else
|
||||
return QStandardPaths::writableLocation(QStandardPaths::DataLocation);
|
||||
#endif
|
||||
-}
|
||||
\ No newline at end of file
|
||||
+}
|
||||
diff --git a/DSView/pv/data/decode/annotation.cpp b/DSView/pv/data/decode/annotation.cpp
|
||||
index f285247c..7da1849c 100644
|
||||
--- a/DSView/pv/data/decode/annotation.cpp
|
||||
+++ b/DSView/pv/data/decode/annotation.cpp
|
||||
@@ -179,6 +179,16 @@ const std::vector<QString>& Annotation::annotations() const
|
||||
return resItem.cvt_lines;
|
||||
}
|
||||
|
||||
+bool Annotation::contains_text(const QString &text) const
|
||||
+{
|
||||
+ // The waveform can display any of these labels, not just the longest one.
|
||||
+ for (const auto &label : annotations()) {
|
||||
+ if (label.contains(text, Qt::CaseInsensitive))
|
||||
+ return true;
|
||||
+ }
|
||||
+ return false;
|
||||
+}
|
||||
+
|
||||
bool Annotation::is_numberic()
|
||||
{
|
||||
AnnotationSourceItem *resItem = _status->m_resTable.GetItem(_resIndex);
|
||||
diff --git a/DSView/pv/data/decode/annotation.h b/DSView/pv/data/decode/annotation.h
|
||||
index a5b0e440..06067395 100644
|
||||
--- a/DSView/pv/data/decode/annotation.h
|
||||
+++ b/DSView/pv/data/decode/annotation.h
|
||||
@@ -66,6 +66,8 @@ public:
|
||||
|
||||
const std::vector<QString>& annotations() const;
|
||||
|
||||
+ bool contains_text(const QString &text) const;
|
||||
+
|
||||
private:
|
||||
uint64_t _start_sample;
|
||||
uint64_t _end_sample;
|
||||
diff --git a/DSView/pv/data/decodermodel.cpp b/DSView/pv/data/decodermodel.cpp
|
||||
index 61710e54..ee814a76 100644
|
||||
--- a/DSView/pv/data/decodermodel.cpp
|
||||
+++ b/DSView/pv/data/decodermodel.cpp
|
||||
@@ -32,6 +32,58 @@ using namespace std;
|
||||
namespace pv {
|
||||
namespace data {
|
||||
|
||||
+AnnotationMatchModel::AnnotationMatchModel(QAbstractItemModel *source, QObject *parent)
|
||||
+ : QAbstractTableModel(parent), _source(source)
|
||||
+{
|
||||
+ connect(source, &QAbstractItemModel::modelAboutToBeReset, this, [this]() {
|
||||
+ setMatches({}, QString());
|
||||
+ });
|
||||
+}
|
||||
+
|
||||
+int AnnotationMatchModel::rowCount(const QModelIndex &parent) const
|
||||
+{
|
||||
+ return parent.isValid() ? 0 : int(_matches.size());
|
||||
+}
|
||||
+
|
||||
+int AnnotationMatchModel::columnCount(const QModelIndex &parent) const
|
||||
+{
|
||||
+ return parent.isValid() ? 0 : 2;
|
||||
+}
|
||||
+
|
||||
+QModelIndex AnnotationMatchModel::sourceIndex(int row) const
|
||||
+{
|
||||
+ return _source && row >= 0 && row < int(_matches.size()) ? _matches[row] : QModelIndex();
|
||||
+}
|
||||
+
|
||||
+void AnnotationMatchModel::setMatches(const std::vector<QModelIndex> &matches, const QString &term)
|
||||
+{
|
||||
+ beginResetModel();
|
||||
+ _matches = matches;
|
||||
+ _term = term;
|
||||
+ endResetModel();
|
||||
+}
|
||||
+
|
||||
+QVariant AnnotationMatchModel::data(const QModelIndex &index, int role) const
|
||||
+{
|
||||
+ const QModelIndex source = sourceIndex(index.row());
|
||||
+ if (!index.isValid() || !source.isValid()) return QVariant();
|
||||
+ if (role == Qt::DisplayRole && index.column() == 0)
|
||||
+ return _source->headerData(source.column(), Qt::Horizontal, role);
|
||||
+ if (role == Qt::DisplayRole) {
|
||||
+ const auto labels = source.data(Qt::ToolTipRole).toString().split('\n');
|
||||
+ for (const auto &label : labels)
|
||||
+ if (label.contains(_term, Qt::CaseInsensitive)) return label;
|
||||
+ }
|
||||
+ return source.data(role);
|
||||
+}
|
||||
+
|
||||
+QVariant AnnotationMatchModel::headerData(int section, Qt::Orientation orientation, int role) const
|
||||
+{
|
||||
+ if (role != Qt::DisplayRole) return QVariant();
|
||||
+ if (orientation == Qt::Vertical) return section + 1;
|
||||
+ return section == 0 ? tr("Annotation") : tr("Match");
|
||||
+}
|
||||
+
|
||||
DecoderModel::DecoderModel(QObject *parent)
|
||||
: QAbstractTableModel(parent),
|
||||
_decoder_stack(NULL)
|
||||
@@ -68,17 +120,51 @@ QVariant DecoderModel::data(const QModelIndex &index, int role) const
|
||||
if (role == Qt::TextAlignmentRole) {
|
||||
return int(Qt::AlignLeft | Qt::AlignVCenter);
|
||||
}
|
||||
- else if (role == Qt::DisplayRole) {
|
||||
+ else if (role == Qt::DisplayRole || role == Qt::ToolTipRole) {
|
||||
if (_decoder_stack) {
|
||||
pv::data::decode::Annotation ann;
|
||||
if (_decoder_stack->list_annotation(&ann, index.column(), index.row())) {
|
||||
- return ann.annotations().at(0);
|
||||
+ const auto &labels = ann.annotations();
|
||||
+ if (role == Qt::DisplayRole)
|
||||
+ return labels.empty() ? QVariant() : QVariant(labels.front());
|
||||
+ QStringList text;
|
||||
+ for (const auto &label : labels)
|
||||
+ if (!text.contains(label)) text.append(label);
|
||||
+ return text.join("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
return QVariant();
|
||||
}
|
||||
|
||||
+bool DecoderModel::annotation_matches(int column, int row, const QStringList &terms) const
|
||||
+{
|
||||
+ if (!_decoder_stack || terms.isEmpty() || terms.first().isEmpty())
|
||||
+ return false;
|
||||
+ decode::Annotation ann;
|
||||
+ if (!_decoder_stack->list_annotation(&ann, column, row) ||
|
||||
+ !ann.contains_text(terms.first()))
|
||||
+ return false;
|
||||
+
|
||||
+ if (terms.size() == 1)
|
||||
+ return true;
|
||||
+
|
||||
+ // Retain the numeric AA-BB-CC sequence search in each individual column.
|
||||
+ // Text containing a hyphen is handled as one literal term by the caller.
|
||||
+ if (!ann.is_numberic())
|
||||
+ return false;
|
||||
+ uint64_t next_row = uint64_t(row) + 1;
|
||||
+ for (int term = 1; term < terms.size(); ++term) {
|
||||
+ do {
|
||||
+ if (!_decoder_stack->list_annotation(&ann, column, next_row++))
|
||||
+ return false;
|
||||
+ } while (!ann.is_numberic());
|
||||
+ if (!ann.contains_text(terms.at(term)))
|
||||
+ return false;
|
||||
+ }
|
||||
+ return true;
|
||||
+}
|
||||
+
|
||||
QVariant DecoderModel::headerData(int section,
|
||||
Qt::Orientation orientation,
|
||||
int role) const
|
||||
diff --git a/DSView/pv/data/decodermodel.h b/DSView/pv/data/decodermodel.h
|
||||
index d35849f4..f2f15887 100644
|
||||
--- a/DSView/pv/data/decodermodel.h
|
||||
+++ b/DSView/pv/data/decodermodel.h
|
||||
@@ -23,6 +23,8 @@
|
||||
#define DSVIEW_PV_DATA_DECODERMODEL_H
|
||||
|
||||
#include <QAbstractTableModel>
|
||||
+#include <QStringList>
|
||||
+#include <QPointer>
|
||||
|
||||
#include "decode/rowdata.h"
|
||||
|
||||
@@ -31,6 +33,24 @@ namespace data {
|
||||
|
||||
class DecoderStack;
|
||||
|
||||
+// One matched annotation per row; columns in DecoderModel have independent
|
||||
+// timelines, so filtering its row numbers would also expose unrelated events.
|
||||
+class AnnotationMatchModel : public QAbstractTableModel
|
||||
+{
|
||||
+public:
|
||||
+ AnnotationMatchModel(QAbstractItemModel *source, QObject *parent);
|
||||
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
+ int columnCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||
+ QVariant data(const QModelIndex &index, int role) const override;
|
||||
+ QVariant headerData(int section, Qt::Orientation orientation, int role) const override;
|
||||
+ void setMatches(const std::vector<QModelIndex> &matches, const QString &term);
|
||||
+ QModelIndex sourceIndex(int row) const;
|
||||
+private:
|
||||
+ QPointer<QAbstractItemModel> _source;
|
||||
+ std::vector<QModelIndex> _matches;
|
||||
+ QString _term;
|
||||
+};
|
||||
+
|
||||
namespace decode {
|
||||
class Annotation;
|
||||
class Decoder;
|
||||
@@ -47,6 +67,8 @@ public:
|
||||
QVariant data(const QModelIndex &index, int role) const;
|
||||
QVariant headerData(int section, Qt::Orientation orientation,int role) const;
|
||||
|
||||
+ bool annotation_matches(int column, int row, const QStringList &terms) const;
|
||||
+
|
||||
void setDecoderStack(DecoderStack *decoder_stack);
|
||||
|
||||
inline DecoderStack* getDecoderStack(){
|
||||
diff --git a/DSView/pv/data/decoderstack.cpp b/DSView/pv/data/decoderstack.cpp
|
||||
index cb6b0b9a..ca46182f 100644
|
||||
--- a/DSView/pv/data/decoderstack.cpp
|
||||
+++ b/DSView/pv/data/decoderstack.cpp
|
||||
@@ -168,8 +168,7 @@ void DecoderStack::build_row()
|
||||
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
|
||||
if (iter == _rows_gshow.end()) {
|
||||
_rows_gshow[row] = true;
|
||||
- if (row.title().contains("bit", Qt::CaseInsensitive) ||
|
||||
- row.title().contains("warning", Qt::CaseInsensitive)) {
|
||||
+ if (row.title().contains("bit", Qt::CaseInsensitive)) {
|
||||
_rows_lshow[row] = false;
|
||||
} else {
|
||||
_rows_lshow[row] = true;
|
||||
@@ -192,8 +191,7 @@ void DecoderStack::build_row()
|
||||
std::map<const decode::Row, bool>::const_iterator iter = _rows_gshow.find(row);
|
||||
if (iter == _rows_gshow.end()) {
|
||||
_rows_gshow[row] = true;
|
||||
- if (row.title().contains("bit", Qt::CaseInsensitive) ||
|
||||
- row.title().contains("warning", Qt::CaseInsensitive)) {
|
||||
+ if (row.title().contains("bit", Qt::CaseInsensitive)) {
|
||||
_rows_lshow[row] = false;
|
||||
} else {
|
||||
_rows_lshow[row] = true;
|
||||
diff --git a/DSView/pv/dialogs/deviceoptions.cpp b/DSView/pv/dialogs/deviceoptions.cpp
|
||||
index 7d4ae1c4..efe19693 100644
|
||||
--- a/DSView/pv/dialogs/deviceoptions.cpp
|
||||
+++ b/DSView/pv/dialogs/deviceoptions.cpp
|
||||
@@ -392,18 +392,15 @@ void DeviceOptions::logic_probes(QVBoxLayout &layout)
|
||||
|
||||
QPushButton *enable_all_probes = new QPushButton(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_ENABLE_ALL), "Enable All"));
|
||||
QPushButton *disable_all_probes = new QPushButton(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_DISABLE_ALL), "Disable All"));
|
||||
- enable_all_probes->setMaximumHeight(33);
|
||||
- disable_all_probes->setMaximumHeight(33);
|
||||
+ // Let Qt include the full label and themed padding in the preferred size.
|
||||
+ enable_all_probes->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
+ disable_all_probes->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
|
||||
enable_all_probes->setFont(font);
|
||||
- disable_all_probes->setFont(font);
|
||||
-
|
||||
- int bt_width = enable_all_probes->fontMetrics().horizontalAdvance(enable_all_probes->text()) + 20;
|
||||
- enable_all_probes->setMaximumWidth(bt_width);
|
||||
- disable_all_probes->setMaximumWidth(bt_width);
|
||||
-
|
||||
- this->update_font();
|
||||
-
|
||||
- contentHeight += enable_all_probes->sizeHint().height();
|
||||
+ disable_all_probes->setFont(font);
|
||||
+
|
||||
+ this->update_font();
|
||||
+
|
||||
+ contentHeight += qMax(enable_all_probes->sizeHint().height(), disable_all_probes->sizeHint().height());
|
||||
contentHeight += channel_line_height * row2 + 50;
|
||||
|
||||
connect(enable_all_probes, SIGNAL(clicked()),
|
||||
diff --git a/DSView/pv/dock/keywordlineedit.cpp b/DSView/pv/dock/keywordlineedit.cpp
|
||||
index 2c2e4722..9a33e11a 100644
|
||||
--- a/DSView/pv/dock/keywordlineedit.cpp
|
||||
+++ b/DSView/pv/dock/keywordlineedit.cpp
|
||||
@@ -353,6 +353,10 @@ void PopupLineEdit::showPupopInput()
|
||||
_popup_input = input;
|
||||
|
||||
connect(input, SIGNAL(sig_inputEnd(QString)), this, SLOT(onPopupInputEditEnd(QString)));
|
||||
+ // The popup owns keyboard input on Windows. Forward Enter after it commits
|
||||
+ // the text and closes, so actions connected to the outer editor still run.
|
||||
+ connect(line, &QLineEdit::returnPressed, this, &QLineEdit::returnPressed,
|
||||
+ Qt::QueuedConnection);
|
||||
|
||||
if (_is_number_mode){
|
||||
connect(line, SIGNAL(valueChanged(int)), this, SLOT(onPopupInputValueChanged(int)));
|
||||
diff --git a/DSView/pv/dock/protocoldock.cpp b/DSView/pv/dock/protocoldock.cpp
|
||||
index 648eb5b8..1a023707 100644
|
||||
--- a/DSView/pv/dock/protocoldock.cpp
|
||||
+++ b/DSView/pv/dock/protocoldock.cpp
|
||||
@@ -37,14 +37,13 @@
|
||||
#include <QHeaderView>
|
||||
#include <QScrollBar>
|
||||
#include <QRegularExpression>
|
||||
-#include <QFuture>
|
||||
-#include <QProgressDialog>
|
||||
-#include <QtConcurrent/QtConcurrent>
|
||||
+#include <QElapsedTimer>
|
||||
#include <QSizePolicy>
|
||||
#include <assert.h>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <algorithm>
|
||||
+#include <limits>
|
||||
#include <QTableWidgetItem>
|
||||
#include <QHeaderView>
|
||||
#include "../ui/msgbox.h"
|
||||
@@ -56,6 +55,11 @@
|
||||
#include "../ui/langresource.h"
|
||||
#include "../appcontrol.h"
|
||||
#include "../ui/fn.h"
|
||||
+#include "../dialogs/decoderoptionsdlg.h"
|
||||
+#include "../dialogs/dsmessagebox.h"
|
||||
+#include <QApplication>
|
||||
+#include <QFile>
|
||||
+#include <QTimer>
|
||||
|
||||
using namespace std;
|
||||
|
||||
@@ -69,9 +73,10 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
_view(view)
|
||||
{
|
||||
_session = session;
|
||||
- _cur_search_index = -1;
|
||||
- _search_edited = false;
|
||||
_pro_add_button = NULL;
|
||||
+ _search_timer = new QTimer(this);
|
||||
+ _search_timer->setSingleShot(true);
|
||||
+ connect(_search_timer, &QTimer::timeout, this, &ProtocolDock::search_step);
|
||||
|
||||
//-----------------------------get protocol list
|
||||
GSList *l = const_cast<GSList*>(srd_decoder_list());
|
||||
@@ -160,7 +165,6 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
_ann_search_edit = new PopupLineEdit(bot_panel);
|
||||
|
||||
_ann_search_button->setFixedWidth(_ann_search_button->height());
|
||||
- _ann_search_button->setDisabled(true);
|
||||
|
||||
QHBoxLayout *ann_search_layout = new QHBoxLayout();
|
||||
ann_search_layout->setSpacing(2);
|
||||
@@ -170,6 +174,7 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
ann_search_layout->addWidget(_nxt_button);
|
||||
|
||||
_table_view = new QTableView(bot_panel);
|
||||
+ _match_model = new data::AnnotationMatchModel(_session->get_decoder_model(), this);
|
||||
_table_view->setModel(_session->get_decoder_model());
|
||||
_table_view->setAlternatingRowColors(true);
|
||||
_table_view->setShowGrid(false);
|
||||
@@ -191,12 +196,32 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
bot_layout->addWidget(_table_view);
|
||||
bot_panel->setLayout(bot_layout);
|
||||
|
||||
+ // Keep decoder rows scrollable so their combined minimum height does not
|
||||
+ // prevent the user from dragging the divider upwards.
|
||||
+ auto top_scroll = new QScrollArea(this);
|
||||
+ top_scroll->setFrameShape(QFrame::NoFrame);
|
||||
+ top_scroll->setWidgetResizable(true);
|
||||
+ top_scroll->setMinimumHeight(70);
|
||||
+ top_scroll->setWidget(top_panel);
|
||||
+
|
||||
QSplitter *split_widget = new QSplitter(this);
|
||||
- split_widget->insertWidget(0, top_panel);
|
||||
+ split_widget->insertWidget(0, top_scroll);
|
||||
split_widget->insertWidget(1, bot_panel);
|
||||
split_widget->setOrientation(Qt::Vertical);
|
||||
split_widget->setCollapsible(0, false);
|
||||
split_widget->setCollapsible(1, false);
|
||||
+ split_widget->setHandleWidth(10);
|
||||
+ split_widget->setStretchFactor(0, 0);
|
||||
+ split_widget->setStretchFactor(1, 1);
|
||||
+ split_widget->setSizes({180, 420});
|
||||
+ split_widget->handle(1)->setCursor(Qt::SizeVerCursor);
|
||||
+ split_widget->handle(1)->setToolTip(tr("Drag up or down to resize Decoders and Decoding Results"));
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ split_widget->setStyleSheet(
|
||||
+ "QSplitter#protocolWidget::handle:vertical { background: #243247; "
|
||||
+ "border-top: 1px solid #344762; border-bottom: 1px solid #344762; }"
|
||||
+ "QSplitter#protocolWidget::handle:vertical:hover { background: #1570d8; }");
|
||||
+ }
|
||||
|
||||
this->setWidgetResizable(true);
|
||||
this->setWidget(split_widget);
|
||||
@@ -207,6 +232,7 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
connect(_bot_set_button, SIGNAL(clicked()),this, SLOT(set_model()));
|
||||
connect(_pre_button, SIGNAL(clicked()),this, SLOT(search_pre()));
|
||||
connect(_nxt_button, SIGNAL(clicked()),this, SLOT(search_nxt()));
|
||||
+ connect(_ann_search_button, SIGNAL(clicked()),this, SLOT(search_nxt()));
|
||||
connect(_pro_add_button, SIGNAL(clicked()),this, SLOT(on_add_protocol()));
|
||||
connect(_del_all_button, SIGNAL(clicked()),this, SLOT(on_del_all_protocol()));
|
||||
|
||||
@@ -218,7 +244,12 @@ ProtocolDock::ProtocolDock(QWidget *parent, view::View &view, SigSession *sessio
|
||||
|
||||
connect(_pro_search_button, SIGNAL(clicked()), this, SLOT(show_protocol_select()));
|
||||
|
||||
- connect(_ann_search_edit, SIGNAL(editingFinished()), this, SLOT(search_changed()));
|
||||
+ connect(_ann_search_edit, &QLineEdit::textChanged, this, &ProtocolDock::search_changed);
|
||||
+ connect(_ann_search_edit, &QLineEdit::returnPressed, this, &ProtocolDock::search_nxt);
|
||||
+ connect(_session->get_decoder_model(), &QAbstractItemModel::modelAboutToBeReset,
|
||||
+ this, &ProtocolDock::invalidate_search);
|
||||
+ connect(_session->get_decoder_model(), &QAbstractItemModel::modelReset,
|
||||
+ this, &ProtocolDock::search_done);
|
||||
|
||||
|
||||
ADD_UI(this);
|
||||
@@ -241,6 +272,12 @@ ProtocolDock::~ProtocolDock()
|
||||
void ProtocolDock::retranslateUi()
|
||||
{
|
||||
_ann_search_edit->setPlaceholderText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_SEARCH), "search"));
|
||||
+ _ann_search_edit->setToolTip(tr("Search all enabled result columns, including short waveform labels.\n"
|
||||
+ "Case insensitive. Enter or the magnifier: next match.\n"
|
||||
+ "Numeric sequence: AA-BB-CC."));
|
||||
+ _ann_search_button->setToolTip(tr("Find next (Enter)"));
|
||||
+ _pre_button->setToolTip(tr("Previous match"));
|
||||
+ _nxt_button->setToolTip(tr("Next match"));
|
||||
_matchs_title_label->setText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_MATCHING_ITEMS), "Matching Items:"));
|
||||
_bot_title_label->setText(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_PROTOCOL_LIST_VIEWER), "Protocol List Viewer"));
|
||||
_pro_keyword_edit->ResetText();
|
||||
@@ -290,6 +327,89 @@ int ProtocolDock::get_protocol_index_by_id(QString id)
|
||||
return -1;
|
||||
}
|
||||
|
||||
+bool ProtocolDock::add_host_decoder(const QString &id)
|
||||
+{
|
||||
+ if (_session->is_working() || _session->get_device()->get_work_mode() != LOGIC ||
|
||||
+ QApplication::activeModalWidget() || get_protocol_index_by_id(id) < 0)
|
||||
+ return false;
|
||||
+ _selected_protocol_id = id;
|
||||
+ const size_t count = _protocol_lay_items.size();
|
||||
+ on_add_protocol();
|
||||
+ return _protocol_lay_items.size() > count;
|
||||
+}
|
||||
+
|
||||
+// Opt-in integration check used by SETGUI --check-dsview. Exercise the same
|
||||
+// modal dialogs and removal paths as the right-hand decoder panel.
|
||||
+void ProtocolDock::check_host_decoders()
|
||||
+{
|
||||
+ const QString report = QString::fromUtf8(qgetenv("DSVIEW_HOST_CHECK"));
|
||||
+ if (report.isEmpty()) return;
|
||||
+ auto log = [report](const QString &message) {
|
||||
+ QFile file(report);
|
||||
+ if (file.open(QIODevice::WriteOnly | QIODevice::Append))
|
||||
+ file.write((message + "\n").toUtf8());
|
||||
+ };
|
||||
+ auto timer = new QTimer(this);
|
||||
+ int phase = 0;
|
||||
+ connect(timer, &QTimer::timeout, this, [this, timer, phase, log, report]() mutable {
|
||||
+ bool ok = true;
|
||||
+ auto answer = [this](QMessageBox::StandardButton button) {
|
||||
+ QTimer::singleShot(80, this, [button]() {
|
||||
+ auto dialog = qobject_cast<dialogs::DSMessageBox*>(QApplication::activeModalWidget());
|
||||
+ if (dialog && dialog->mBox()->button(button))
|
||||
+ dialog->mBox()->button(button)->click();
|
||||
+ });
|
||||
+ };
|
||||
+ if (phase < 4) {
|
||||
+ const auto before = _protocol_lay_items.size();
|
||||
+ if (before == 0) ok = false;
|
||||
+ else {
|
||||
+ answer(phase % 2 ? QMessageBox::Yes : QMessageBox::No);
|
||||
+ if (phase < 2) OnProtocolDelete(_protocol_lay_items.front());
|
||||
+ else on_del_all_protocol();
|
||||
+ const auto after = _protocol_lay_items.size();
|
||||
+ ok = phase % 2 == 0 ? after == before : after == (phase == 1 ? before - 1 : 0);
|
||||
+ }
|
||||
+ } else if (phase < 8) {
|
||||
+ const QString id = QStringList({"gate_driver_timing", "set_uart", "set_can", "pm35_uart"})[phase - 4];
|
||||
+ if (get_protocol_index_by_id(id) < 0) ok = false;
|
||||
+ else {
|
||||
+ QTimer::singleShot(80, this, [report, id]() {
|
||||
+ auto dialog = qobject_cast<dialogs::DecoderOptionsDlg*>(QApplication::activeModalWidget());
|
||||
+ if (!dialog) return;
|
||||
+ int channel = 1;
|
||||
+ for (auto combo : dialog->findChildren<QComboBox*>()) {
|
||||
+ if (combo->count() > 1 && combo->itemText(0) == "-" && combo->itemData(0).toInt() == -1)
|
||||
+ combo->setCurrentIndex(qMin(channel++, combo->count() - 1));
|
||||
+ }
|
||||
+ dialog->grab().save(report + "." + id + ".png");
|
||||
+ QMetaObject::invokeMethod(dialog, "on_accept", Qt::DirectConnection);
|
||||
+ });
|
||||
+ ok = add_host_decoder(id);
|
||||
+ if (ok) {
|
||||
+ auto trace = _session->get_decode_signals().back();
|
||||
+ ok = QString(trace->decoder()->stack().back()->decoder()->id) == id;
|
||||
+ }
|
||||
+ }
|
||||
+ } else if (phase == 8) {
|
||||
+ answer(QMessageBox::Yes);
|
||||
+ on_del_all_protocol();
|
||||
+ ok = _protocol_lay_items.empty() && _session->get_decode_signals().empty();
|
||||
+ } else {
|
||||
+ log("PASS");
|
||||
+ timer->stop();
|
||||
+ timer->deleteLater();
|
||||
+ return;
|
||||
+ }
|
||||
+ log(QString("phase %1: %2").arg(phase++).arg(ok ? "ok" : "FAIL"));
|
||||
+ if (!ok) {
|
||||
+ timer->stop();
|
||||
+ timer->deleteLater();
|
||||
+ }
|
||||
+ });
|
||||
+ timer->start(350);
|
||||
+}
|
||||
+
|
||||
void ProtocolDock::on_add_protocol()
|
||||
{
|
||||
if (_decoderInfoList.size() == 0){
|
||||
@@ -471,7 +591,7 @@ void ProtocolDock::decoded_progress(int progress)
|
||||
index++;
|
||||
}
|
||||
|
||||
- if (progress == 0 || progress % 10 == 1){
|
||||
+ if (progress == 0 || progress == 100 || progress % 10 == 1){
|
||||
update_model();
|
||||
}
|
||||
}
|
||||
@@ -481,8 +601,6 @@ void ProtocolDock::set_model()
|
||||
pv::dialogs::ProtocolList *protocollist_dlg = new pv::dialogs::ProtocolList(this, _session);
|
||||
protocollist_dlg->exec();
|
||||
resize_table_view(_session->get_decoder_model());
|
||||
- _model_proxy.setSourceModel(_session->get_decoder_model());
|
||||
- search_done();
|
||||
|
||||
// clear mark_index of all DecoderStacks
|
||||
const auto &decode_sigs = _session->get_decode_signals();
|
||||
@@ -513,15 +631,13 @@ void ProtocolDock::update_model()
|
||||
if (index >= decode_sigs.size())
|
||||
decoder_model->setDecoderStack(decode_sigs.at(0)->decoder());
|
||||
}
|
||||
- _model_proxy.setSourceModel(decoder_model);
|
||||
- search_done();
|
||||
resize_table_view(decoder_model);
|
||||
}
|
||||
|
||||
void ProtocolDock::resize_table_view(data::DecoderModel* decoder_model)
|
||||
{
|
||||
if (decoder_model->getDecoderStack()) {
|
||||
- for (int i = 0; i < decoder_model->columnCount(QModelIndex()) - 1; i++) {
|
||||
+ for (int i = 0; i < _table_view->model()->columnCount(QModelIndex()) - 1; i++) {
|
||||
_table_view->resizeColumnToContents(i);
|
||||
if (_table_view->columnWidth(i) > 200)
|
||||
_table_view->setColumnWidth(i, 200);
|
||||
@@ -535,8 +651,11 @@ void ProtocolDock::resize_table_view(data::DecoderModel* decoder_model)
|
||||
}
|
||||
}
|
||||
|
||||
-void ProtocolDock::item_clicked(const QModelIndex &index)
|
||||
+void ProtocolDock::item_clicked(const QModelIndex &table_index)
|
||||
{
|
||||
+ const QModelIndex index = table_index.model() == _match_model
|
||||
+ ? _match_model->sourceIndex(table_index.row()) : table_index;
|
||||
+ if (!index.isValid()) return;
|
||||
pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
|
||||
auto decoder_stack = decoder_model->getDecoderStack();
|
||||
@@ -555,41 +674,16 @@ void ProtocolDock::item_clicked(const QModelIndex &index)
|
||||
}
|
||||
}
|
||||
|
||||
- _table_view->resizeRowToContents(index.row());
|
||||
- if (index.column() != _model_proxy.filterKeyColumn()) {
|
||||
- _model_proxy.setFilterKeyColumn(index.column());
|
||||
- _model_proxy.setSourceModel(decoder_model);
|
||||
- search_done();
|
||||
- }
|
||||
- QModelIndex filterIndex = _model_proxy.mapFromSource(index);
|
||||
- if (filterIndex.isValid()) {
|
||||
- _cur_search_index = filterIndex.row();
|
||||
- } else {
|
||||
- if (_model_proxy.rowCount() == 0) {
|
||||
- _cur_search_index = -1;
|
||||
- } else {
|
||||
- uint64_t up = 0;
|
||||
- uint64_t dn = _model_proxy.rowCount() - 1;
|
||||
- do {
|
||||
- uint64_t md = (up + dn)/2;
|
||||
- QModelIndex curIndex = _model_proxy.mapToSource(_model_proxy.index(md,_model_proxy.filterKeyColumn()));
|
||||
- if (index.row() == curIndex.row()) {
|
||||
- _cur_search_index = md;
|
||||
- break;
|
||||
- } else if (md == up) {
|
||||
- if (curIndex.row() < index.row() && up < dn) {
|
||||
- QModelIndex nxtIndex = _model_proxy.mapToSource(_model_proxy.index(md+1,_model_proxy.filterKeyColumn()));
|
||||
- if (nxtIndex.row() < index.row())
|
||||
- md++;
|
||||
- }
|
||||
- _cur_search_index = md + ((curIndex.row() < index.row()) ? 0.5 : -0.5);
|
||||
- break;
|
||||
- } else if (curIndex.row() < index.row()) {
|
||||
- up = md;
|
||||
- } else if (curIndex.row() > index.row()) {
|
||||
- dn = md;
|
||||
- }
|
||||
- }while(1);
|
||||
+ _table_view->resizeRowToContents(table_index.row());
|
||||
+ // A table click must not silently restrict subsequent searches to this column.
|
||||
+ if (_search_index >= 0 && _search_index < int(_search_matches.size()) &&
|
||||
+ _search_matches[_search_index].index == index)
|
||||
+ return;
|
||||
+ _search_index = -1;
|
||||
+ for (size_t i = 0; i < _search_matches.size(); ++i) {
|
||||
+ if (_search_matches[i].index == index) {
|
||||
+ _search_index = int(i);
|
||||
+ break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -618,6 +712,21 @@ void ProtocolDock::export_table_view()
|
||||
|
||||
void ProtocolDock::nav_table_view()
|
||||
{
|
||||
+ if (_table_view->model() == _match_model) {
|
||||
+ if (_search_running || _search_matches.empty()) return;
|
||||
+ auto stack = _session->get_decoder_model()->getDecoderStack();
|
||||
+ if (!stack) return;
|
||||
+ const uint64_t offset = _view.offset() * (stack->samplerate() * _view.scale());
|
||||
+ auto match = std::lower_bound(_search_matches.begin(), _search_matches.end(), offset,
|
||||
+ [](const SearchMatch &item, uint64_t sample) { return item.sample < sample; });
|
||||
+ _search_index = match == _search_matches.end() ? int(_search_matches.size()) - 1
|
||||
+ : int(match - _search_matches.begin());
|
||||
+ const QModelIndex index = _match_model->index(_search_index, 1);
|
||||
+ _table_view->setCurrentIndex(index);
|
||||
+ _table_view->scrollTo(index);
|
||||
+ item_clicked(index);
|
||||
+ return;
|
||||
+ }
|
||||
uint64_t row_index = 0;
|
||||
pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
|
||||
@@ -625,7 +734,8 @@ void ProtocolDock::nav_table_view()
|
||||
if (decoder_stack) {
|
||||
uint64_t offset = _view.offset() * (decoder_stack->samplerate() * _view.scale());
|
||||
std::map<const pv::data::decode::Row, bool> rows = decoder_stack->get_rows_lshow();
|
||||
- int column = _model_proxy.filterKeyColumn();
|
||||
+ const int selected_column = qMax(0, _table_view->currentIndex().column());
|
||||
+ int column = selected_column;
|
||||
for (std::map<const pv::data::decode::Row, bool>::const_iterator i = rows.begin();
|
||||
i != rows.end(); i++) {
|
||||
if ((*i).second && column-- == 0) {
|
||||
@@ -633,7 +743,7 @@ void ProtocolDock::nav_table_view()
|
||||
break;
|
||||
}
|
||||
}
|
||||
- QModelIndex index = _model_proxy.mapToSource(_model_proxy.index(row_index, _model_proxy.filterKeyColumn()));
|
||||
+ QModelIndex index = decoder_model->index(row_index, selected_column);
|
||||
|
||||
if(index.isValid()){
|
||||
|
||||
@@ -660,196 +770,141 @@ void ProtocolDock::nav_table_view()
|
||||
|
||||
void ProtocolDock::search_pre()
|
||||
{
|
||||
- search_update();
|
||||
- // now the proxy only contains rows that match the name
|
||||
- // let's take the pre one and map it to the original model
|
||||
- if (_model_proxy.rowCount() == 0) {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
- return;
|
||||
- }
|
||||
- int i = 0;
|
||||
- uint64_t rowCount = _model_proxy.rowCount();
|
||||
- QModelIndex matchingIndex;
|
||||
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
-
|
||||
- auto decoder_stack = decoder_model->getDecoderStack();
|
||||
- do {
|
||||
- _cur_search_index--;
|
||||
- if (_cur_search_index <= -1 || _cur_search_index >= _model_proxy.rowCount())
|
||||
- _cur_search_index = _model_proxy.rowCount() - 1;
|
||||
-
|
||||
- matchingIndex = _model_proxy.mapToSource(_model_proxy.index(ceil(_cur_search_index),_model_proxy.filterKeyColumn()));
|
||||
- if (!decoder_stack || !matchingIndex.isValid())
|
||||
- break;
|
||||
- i = 1;
|
||||
- uint64_t row = matchingIndex.row() + 1;
|
||||
- uint64_t col = matchingIndex.column();
|
||||
- pv::data::decode::Annotation ann;
|
||||
- bool ann_valid = false;
|
||||
-
|
||||
- while(i < _str_list.size()) {
|
||||
- QString nxt = _str_list.at(i);
|
||||
-
|
||||
- do {
|
||||
- ann_valid = decoder_stack->list_annotation(&ann, col, row);
|
||||
- row++;
|
||||
- }
|
||||
- while(ann_valid && !ann.is_numberic());
|
||||
-
|
||||
- if (ann_valid){
|
||||
- QString source = ann.annotations().at(0);
|
||||
- if (source.contains(nxt))
|
||||
- i++;
|
||||
- else
|
||||
- break;
|
||||
- }
|
||||
- else{
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
- }
|
||||
- while(i < _str_list.size() && --rowCount);
|
||||
-
|
||||
- if(i >= _str_list.size() && matchingIndex.isValid()){
|
||||
- _table_view->scrollTo(matchingIndex);
|
||||
- _table_view->setCurrentIndex(matchingIndex);
|
||||
- _table_view->clicked(matchingIndex);
|
||||
- } else {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
- }
|
||||
+ navigate_search(-1);
|
||||
}
|
||||
|
||||
void ProtocolDock::search_nxt()
|
||||
{
|
||||
- search_update();
|
||||
- // now the proxy only contains rows that match the name
|
||||
- // let's take the pre one and map it to the original model
|
||||
- if (_model_proxy.rowCount() == 0) {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
+ navigate_search(1);
|
||||
+}
|
||||
+
|
||||
+void ProtocolDock::navigate_search(int direction)
|
||||
+{
|
||||
+ if (_search_running) {
|
||||
+ // Finish the current query first; repeated presses do not restart it.
|
||||
+ _search_direction = direction;
|
||||
+ _search_timer->start(0);
|
||||
return;
|
||||
}
|
||||
-
|
||||
- int i = 0;
|
||||
- uint64_t rowCount = _model_proxy.rowCount();
|
||||
- QModelIndex matchingIndex;
|
||||
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
- auto decoder_stack = decoder_model->getDecoderStack();
|
||||
-
|
||||
- if (decoder_stack == NULL){
|
||||
- dsv_err("decoder_stack is null");
|
||||
+ if (_search_matches.empty())
|
||||
return;
|
||||
- }
|
||||
-
|
||||
- do {
|
||||
- _cur_search_index++;
|
||||
- if (_cur_search_index < 0 || _cur_search_index >= _model_proxy.rowCount())
|
||||
- _cur_search_index = 0;
|
||||
|
||||
- matchingIndex = _model_proxy.mapToSource(_model_proxy.index(floor(_cur_search_index),_model_proxy.filterKeyColumn()));
|
||||
-
|
||||
- if (!matchingIndex.isValid())
|
||||
- break;
|
||||
-
|
||||
- i = 1;
|
||||
- uint64_t row = matchingIndex.row() + 1;
|
||||
- uint64_t col = matchingIndex.column();
|
||||
- pv::data::decode::Annotation ann;
|
||||
- bool ann_valid = false;
|
||||
-
|
||||
- while(i < _str_list.size()) {
|
||||
- QString nxt = _str_list.at(i);
|
||||
+ const int count = int(_search_matches.size());
|
||||
+ if (_search_index < 0)
|
||||
+ _search_index = direction > 0 ? 0 : count - 1;
|
||||
+ else
|
||||
+ _search_index = (_search_index + direction + count) % count;
|
||||
+ const QModelIndex index = _match_model->index(_search_index, 1);
|
||||
+ _table_view->setCurrentIndex(index);
|
||||
+ _table_view->scrollTo(index);
|
||||
+ item_clicked(index);
|
||||
+}
|
||||
|
||||
- do {
|
||||
- ann_valid = decoder_stack->list_annotation(&ann, col, row);
|
||||
- row++;
|
||||
- }
|
||||
- while(ann_valid && !ann.is_numberic());
|
||||
-
|
||||
- if (ann_valid){
|
||||
- QString source = ann.annotations().at(0);
|
||||
- if (source.contains(nxt))
|
||||
- i++;
|
||||
- else
|
||||
- break;
|
||||
- }
|
||||
- else{
|
||||
- break;
|
||||
- }
|
||||
- }
|
||||
- }while(i < _str_list.size() && --rowCount);
|
||||
-
|
||||
- if(i >= _str_list.size() && matchingIndex.isValid()){
|
||||
- _table_view->scrollTo(matchingIndex);
|
||||
- _table_view->setCurrentIndex(matchingIndex);
|
||||
- _table_view->clicked(matchingIndex);
|
||||
- } else {
|
||||
- _table_view->scrollToTop();
|
||||
- _table_view->clearSelection();
|
||||
- _matchs_label->setText(QString::number(0));
|
||||
- _cur_search_index = -1;
|
||||
- }
|
||||
+void ProtocolDock::invalidate_search()
|
||||
+{
|
||||
+ _search_timer->stop();
|
||||
+ _search_running = false;
|
||||
+ _search_direction = 0;
|
||||
+ _search_index = -1;
|
||||
+ _search_matches.clear();
|
||||
+ _match_model->setMatches({}, QString());
|
||||
+ _search_column_sizes.clear();
|
||||
+ _search_row = 0;
|
||||
+ _search_column = 0;
|
||||
+ _matchs_label->setText(QString::number(0));
|
||||
}
|
||||
|
||||
void ProtocolDock::search_done()
|
||||
{
|
||||
- QString str = _ann_search_edit->text().trimmed();
|
||||
- QRegularExpression rx("(-)");
|
||||
- _str_list = str.split(rx);
|
||||
- _model_proxy.setFilterFixedString(_str_list.first());
|
||||
- if (_str_list.size() > 1)
|
||||
- _matchs_label->setText("...");
|
||||
- else
|
||||
- _matchs_label->setText(QString::number(_model_proxy.rowCount()));
|
||||
+ const int pending_direction = _search_direction;
|
||||
+ invalidate_search();
|
||||
+ const QString text = _ann_search_edit->text().trimmed();
|
||||
+ auto decoder_model = _session->get_decoder_model();
|
||||
+ QAbstractItemModel *table_model = text.isEmpty()
|
||||
+ ? static_cast<QAbstractItemModel*>(decoder_model) : _match_model;
|
||||
+ if (_table_view->model() != table_model)
|
||||
+ _table_view->setModel(table_model);
|
||||
+ auto decoder_stack = decoder_model->getDecoderStack();
|
||||
+ if (text.isEmpty() || !decoder_stack)
|
||||
+ return;
|
||||
+
|
||||
+ // Only numeric sequences use '-'. Labels like CAN-FD and negative numbers
|
||||
+ // must remain literal text. Always match every waveform label variant.
|
||||
+ static const QRegularExpression sequence(
|
||||
+ "^[0-9A-Fa-f]+(?:\\s*-\\s*[0-9A-Fa-f]+)+$");
|
||||
+ _search_terms = sequence.match(text).hasMatch()
|
||||
+ ? text.split(QRegularExpression("\\s*-\\s*")) : QStringList{text};
|
||||
+ for (int col = 0; col < decoder_model->columnCount(QModelIndex()); ++col)
|
||||
+ _search_column_sizes.push_back(int(qMin<uint64_t>(
|
||||
+ decoder_stack->list_annotation_size(col), std::numeric_limits<int>::max())));
|
||||
+ _search_running = true;
|
||||
+ _search_direction = pending_direction;
|
||||
+ _matchs_label->setText("...");
|
||||
+ _search_timer->start(0);
|
||||
}
|
||||
|
||||
void ProtocolDock::search_changed()
|
||||
{
|
||||
- _search_edited = true;
|
||||
- _matchs_label->setText("...");
|
||||
+ // A new query cancels any pending navigation for the old text.
|
||||
+ _search_direction = 0;
|
||||
+ search_done();
|
||||
+ if (_search_running)
|
||||
+ _search_timer->start(180);
|
||||
}
|
||||
|
||||
-void ProtocolDock::search_update()
|
||||
+void ProtocolDock::search_step()
|
||||
{
|
||||
- if (!_search_edited)
|
||||
+ if (!_search_running)
|
||||
return;
|
||||
-
|
||||
- pv::data::DecoderModel *decoder_model = _session->get_decoder_model();
|
||||
-
|
||||
+ auto decoder_model = _session->get_decoder_model();
|
||||
auto decoder_stack = decoder_model->getDecoderStack();
|
||||
- if (!decoder_stack)
|
||||
+ if (!decoder_stack) {
|
||||
+ invalidate_search();
|
||||
return;
|
||||
+ }
|
||||
|
||||
- if (decoder_stack->list_annotation_size(_model_proxy.filterKeyColumn()) > ProgressRows) {
|
||||
- QFuture<void> future;
|
||||
- future = QtConcurrent::run([&]{
|
||||
- search_done();
|
||||
- });
|
||||
- Qt::WindowFlags flags = Qt::CustomizeWindowHint;
|
||||
- QProgressDialog dlg(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_SEARCHING), "Searching..."),
|
||||
- L_S(STR_PAGE_DLG, S_ID(IDS_DLG_CANCEL), "Cancel"),0,0,this,flags);
|
||||
- dlg.setWindowModality(Qt::WindowModal);
|
||||
- dlg.setWindowFlags(Qt::Dialog | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint |
|
||||
- Qt::WindowMinimizeButtonHint | Qt::WindowMaximizeButtonHint);
|
||||
- dlg.setCancelButton(NULL);
|
||||
-
|
||||
- QFutureWatcher<void> watcher;
|
||||
- connect(&watcher,SIGNAL(finished()),&dlg,SLOT(cancel()));
|
||||
- watcher.setFuture(future);
|
||||
-
|
||||
- dlg.exec();
|
||||
- } else {
|
||||
- search_done();
|
||||
+ // Keep Qt models and widgets on the GUI thread, yielding between chunks.
|
||||
+ // Model resets stop this timer and discard indices before a decoder dies.
|
||||
+ QElapsedTimer elapsed;
|
||||
+ elapsed.start();
|
||||
+ while (_search_column < int(_search_column_sizes.size())) {
|
||||
+ if (_search_row >= _search_column_sizes[_search_column]) {
|
||||
+ ++_search_column;
|
||||
+ _search_row = 0;
|
||||
+ continue;
|
||||
+ }
|
||||
+ const int row = _search_row++;
|
||||
+ if (decoder_model->annotation_matches(_search_column, row, _search_terms)) {
|
||||
+ pv::data::decode::Annotation ann;
|
||||
+ if (decoder_stack->list_annotation(&ann, _search_column, row))
|
||||
+ _search_matches.push_back({decoder_model->index(row, _search_column),
|
||||
+ ann.start_sample()});
|
||||
+ }
|
||||
+ if (elapsed.elapsed() >= 8) {
|
||||
+ _matchs_label->setText(QString("%1...").arg(qulonglong(_search_matches.size())));
|
||||
+ _search_timer->start(1);
|
||||
+ return;
|
||||
+ }
|
||||
}
|
||||
- _search_edited = false;
|
||||
+
|
||||
+ // Result columns have independent row numbers. Navigate in time order.
|
||||
+ std::sort(_search_matches.begin(), _search_matches.end(),
|
||||
+ [](const SearchMatch &a, const SearchMatch &b) {
|
||||
+ if (a.sample != b.sample) return a.sample < b.sample;
|
||||
+ if (a.index.column() != b.index.column()) return a.index.column() < b.index.column();
|
||||
+ return a.index.row() < b.index.row();
|
||||
+ });
|
||||
+ _search_running = false;
|
||||
+ std::vector<QModelIndex> matches;
|
||||
+ matches.reserve(_search_matches.size());
|
||||
+ for (const auto &match : _search_matches) matches.push_back(match.index);
|
||||
+ _match_model->setMatches(matches, _search_terms.first());
|
||||
+ resize_table_view(decoder_model);
|
||||
+ _matchs_label->setText(QString::number(qulonglong(_search_matches.size())));
|
||||
+ const int direction = _search_direction;
|
||||
+ _search_direction = 0;
|
||||
+ if (direction)
|
||||
+ navigate_search(direction);
|
||||
}
|
||||
|
||||
//-------------------IProtocolItemLayerCallback
|
||||
@@ -1100,7 +1155,8 @@ void ProtocolDock::UpdateFont()
|
||||
QRect rc = fm.boundingRect(str);
|
||||
|
||||
int lineHeight = rc.height() + 15;
|
||||
- _pro_keyword_edit->setFixedHeight(rc.height() + 5);
|
||||
+ // Include the line edit's styled padding and border so glyphs are not clipped.
|
||||
+ _pro_keyword_edit->setFixedHeight(qMax(rc.height() + 5, _pro_keyword_edit->sizeHint().height()));
|
||||
int pannelHeight = lineHeight * _protocol_lay_items.size() + _pro_keyword_edit->height();
|
||||
|
||||
if (pannelHeight < 100){
|
||||
diff --git a/DSView/pv/dock/protocoldock.h b/DSView/pv/dock/protocoldock.h
|
||||
index 0da4c4cc..2bcceeb8 100644
|
||||
--- a/DSView/pv/dock/protocoldock.h
|
||||
+++ b/DSView/pv/dock/protocoldock.h
|
||||
@@ -33,9 +33,8 @@
|
||||
#include <QScrollArea>
|
||||
#include <QSplitter>
|
||||
#include <QTableView>
|
||||
-#include <QSortFilterProxyModel>
|
||||
+#include <QTimer>
|
||||
#include <vector>
|
||||
-#include <mutex>
|
||||
#include <list>
|
||||
#include "../data/decodermodel.h"
|
||||
#include "protocolitemlayer.h"
|
||||
@@ -75,14 +74,13 @@ public IUiWindow
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
-public:
|
||||
- static const uint64_t ProgressRows = 100000;
|
||||
-
|
||||
public:
|
||||
ProtocolDock(QWidget *parent, view::View &view, SigSession *session);
|
||||
~ProtocolDock();
|
||||
|
||||
void del_all_protocol();
|
||||
+ bool add_host_decoder(const QString &id);
|
||||
+ void check_host_decoders();
|
||||
bool add_protocol_by_id(QString id, bool silent, std::list<pv::data::decode::Decoder*> &sub_decoders);
|
||||
|
||||
void reset_view();
|
||||
@@ -119,6 +117,8 @@ private:
|
||||
void UpdateFont() override;
|
||||
|
||||
void adjustPannelSize();
|
||||
+ void invalidate_search();
|
||||
+ void navigate_search(int direction);
|
||||
|
||||
signals:
|
||||
void protocol_updated();
|
||||
@@ -139,18 +139,29 @@ private slots:
|
||||
void search_nxt();
|
||||
void search_done();
|
||||
void search_changed();
|
||||
- void search_update();
|
||||
+ void search_step();
|
||||
void show_protocol_select();
|
||||
|
||||
private:
|
||||
SigSession *_session;
|
||||
view::View &_view;
|
||||
- QSortFilterProxyModel _model_proxy;
|
||||
- int _cur_search_index;
|
||||
- QStringList _str_list;
|
||||
+ struct SearchMatch {
|
||||
+ QModelIndex index;
|
||||
+ uint64_t sample;
|
||||
+ };
|
||||
+ std::vector<SearchMatch> _search_matches;
|
||||
+ std::vector<int> _search_column_sizes;
|
||||
+ QTimer *_search_timer;
|
||||
+ QStringList _search_terms;
|
||||
+ int _search_row = 0;
|
||||
+ int _search_column = 0;
|
||||
+ int _search_index = -1;
|
||||
+ int _search_direction = 0;
|
||||
+ bool _search_running = false;
|
||||
|
||||
QWidget *_top_panel;
|
||||
QTableView *_table_view;
|
||||
+ data::AnnotationMatchModel *_match_model;
|
||||
QPushButton *_pre_button;
|
||||
QPushButton *_nxt_button;
|
||||
PopupLineEdit *_ann_search_edit;
|
||||
@@ -172,8 +183,6 @@ private:
|
||||
QString _selected_protocol_id;
|
||||
XToolButton *_pro_search_button;
|
||||
|
||||
- mutable std::mutex _search_mutex;
|
||||
- bool _search_edited;
|
||||
};
|
||||
|
||||
} // namespace dock
|
||||
diff --git a/DSView/pv/mainframe.cpp b/DSView/pv/mainframe.cpp
|
||||
index 9f92d42b..f493c90e 100644
|
||||
--- a/DSView/pv/mainframe.cpp
|
||||
+++ b/DSView/pv/mainframe.cpp
|
||||
@@ -44,7 +44,8 @@
|
||||
#include <QFile>
|
||||
#include <QGuiApplication>
|
||||
#include <QFont>
|
||||
-#include <algorithm>
|
||||
+#include <algorithm>
|
||||
+#include <cstdio>
|
||||
#include <QWindow>
|
||||
|
||||
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
|
||||
@@ -97,7 +98,8 @@ MainFrame::MainFrame()
|
||||
#ifdef _WIN32
|
||||
setWindowFlags(Qt::FramelessWindowHint);
|
||||
_is_win32_parent_window = true;
|
||||
- _taskBtn = NULL;
|
||||
+ _taskBtn = NULL;
|
||||
+ _taskPrg = NULL;
|
||||
isWin32 = true;
|
||||
#else
|
||||
setWindowFlags(Qt::Window | Qt::FramelessWindowHint | Qt::WindowSystemMenuHint);
|
||||
@@ -297,7 +299,7 @@ void MainFrame::resizeEvent(QResizeEvent *event)
|
||||
|
||||
void MainFrame::closeEvent(QCloseEvent *event)
|
||||
{
|
||||
- writeSettings();
|
||||
+ if (!qApp->property("embeddedHost").toBool()) writeSettings();
|
||||
|
||||
if (_mainWindow->able_to_close()){
|
||||
|
||||
@@ -309,7 +311,8 @@ void MainFrame::closeEvent(QCloseEvent *event)
|
||||
}
|
||||
#endif
|
||||
|
||||
- event->accept();
|
||||
+ event->accept();
|
||||
+ if (qApp->property("embeddedHost").toBool()) qApp->quit();
|
||||
}
|
||||
else{
|
||||
event->ignore();
|
||||
@@ -645,7 +648,7 @@ void MainFrame::writeSettings()
|
||||
dsv_info("Save form, x:%d, y:%d, w:%d, h:%d", x, y, w, h);
|
||||
}
|
||||
|
||||
-void MainFrame::ShowFormInit()
|
||||
+void MainFrame::ShowFormInit()
|
||||
{
|
||||
ReadSettings();
|
||||
|
||||
@@ -697,9 +700,80 @@ void MainFrame::ShowFormInit()
|
||||
AttachNativeWindow();
|
||||
}
|
||||
#endif
|
||||
-}
|
||||
-
|
||||
-void MainFrame::AttachNativeWindow()
|
||||
+}
|
||||
+
|
||||
+#ifdef _WIN32
|
||||
+bool MainFrame::ShowEmbedded(quintptr parentId)
|
||||
+{
|
||||
+ const HWND parent = reinterpret_cast<HWND>(parentId);
|
||||
+ DWORD parentPid = 0;
|
||||
+ GetWindowThreadProcessId(parent, &parentPid);
|
||||
+ if (!parentPid || !IsWindow(parent)) return false;
|
||||
+ _titleBar->hide();
|
||||
+ _titleBar->EnableAbleDrag(false);
|
||||
+ setMinimumSize(0, 0);
|
||||
+ _mainWindow->setMinimumSize(0, 0);
|
||||
+ setWindowFlags(Qt::FramelessWindowHint | Qt::Tool);
|
||||
+ const HWND child = reinterpret_cast<HWND>(winId());
|
||||
+ SetWindowLongPtr(child, GWL_STYLE, WS_CHILD | WS_CLIPCHILDREN | WS_CLIPSIBLINGS);
|
||||
+ SetWindowLongPtr(child, GWL_EXSTYLE, GetWindowLongPtr(child, GWL_EXSTYLE) & ~WS_EX_APPWINDOW);
|
||||
+ SetLastError(0);
|
||||
+ SetParent(child, parent);
|
||||
+ if (GetLastError() != 0 || GetParent(child) != parent) return false;
|
||||
+ _mainWindow->restore_dock();
|
||||
+ PopupDlgList::SetCurrentScreen(QGuiApplication::primaryScreen());
|
||||
+ QByteArray commandBuffer;
|
||||
+ auto fit = [this, parent, child, parentPid, commandBuffer]() mutable {
|
||||
+ DWORD currentPid = 0;
|
||||
+ GetWindowThreadProcessId(parent, ¤tPid);
|
||||
+ if (!IsWindow(parent) || currentPid != parentPid) {
|
||||
+ qApp->quit();
|
||||
+ return;
|
||||
+ }
|
||||
+ DWORD available = 0;
|
||||
+ const HANDLE input = GetStdHandle(STD_INPUT_HANDLE);
|
||||
+ if (PeekNamedPipe(input, NULL, 0, NULL, &available, NULL) && available) {
|
||||
+ char data[256];
|
||||
+ DWORD count = 0;
|
||||
+ if (ReadFile(input, data, qMin<DWORD>(available, sizeof(data)), &count, NULL)) {
|
||||
+ commandBuffer.append(data, count);
|
||||
+ int newline;
|
||||
+ while ((newline = commandBuffer.indexOf('\n')) >= 0) {
|
||||
+ const QByteArray command = commandBuffer.left(newline).trimmed();
|
||||
+ commandBuffer.remove(0, newline + 1);
|
||||
+ if (command == "CLOSE") close();
|
||||
+ else if (command.startsWith("ADD_DECODER "))
|
||||
+ _mainWindow->addHostDecoder(QString::fromUtf8(command.mid(12)));
|
||||
+ else if (command.startsWith("OPEN_FILE ")) {
|
||||
+ const QString path = QString::fromUtf8(QByteArray::fromBase64(command.mid(10)));
|
||||
+ printf("DSVIEW_FILE_RESULT %s\n", _mainWindow->openHostFile(path));
|
||||
+ fflush(stdout);
|
||||
+ }
|
||||
+ }
|
||||
+ if (commandBuffer.size() > 262144) commandBuffer.clear();
|
||||
+ }
|
||||
+ }
|
||||
+ RECT rect, childRect;
|
||||
+ GetClientRect(parent, &rect);
|
||||
+ GetWindowRect(child, &childRect);
|
||||
+ const int width = rect.right - rect.left;
|
||||
+ const int height = rect.bottom - rect.top;
|
||||
+ if (width > 0 && height > 0 &&
|
||||
+ (childRect.right - childRect.left != width || childRect.bottom - childRect.top != height))
|
||||
+ ::MoveWindow(child, 0, 0, width, height, TRUE);
|
||||
+ };
|
||||
+ QTimer *hostTimer = new QTimer(this);
|
||||
+ connect(hostTimer, &QTimer::timeout, this, fit);
|
||||
+ hostTimer->start(50);
|
||||
+ fit();
|
||||
+ QFrame::show();
|
||||
+ if (!qgetenv("DSVIEW_HOST_CHECK").isEmpty())
|
||||
+ QTimer::singleShot(3500, _mainWindow, &MainWindow::checkHostDecoders);
|
||||
+ return true;
|
||||
+}
|
||||
+#endif
|
||||
+
|
||||
+void MainFrame::AttachNativeWindow()
|
||||
{
|
||||
#ifdef _WIN32
|
||||
|
||||
@@ -1022,7 +1096,7 @@ void MainFrame::ReadSettings()
|
||||
void MainFrame::showEvent(QShowEvent *event)
|
||||
{
|
||||
// Taskbar Progress Effert for Win7 and Above
|
||||
- if (_taskBtn && _taskBtn->window() == NULL) {
|
||||
+ if (!qApp->property("embeddedHost").toBool() && _taskBtn && _taskBtn->window() == NULL) {
|
||||
_taskBtn->setWindow(windowHandle());
|
||||
_taskPrg = _taskBtn->progress();
|
||||
}
|
||||
@@ -1032,8 +1106,9 @@ void MainFrame::showEvent(QShowEvent *event)
|
||||
|
||||
void MainFrame::setTaskbarProgress(int progress)
|
||||
{
|
||||
-#ifdef _WIN32
|
||||
- if (progress > 0) {
|
||||
+#ifdef _WIN32
|
||||
+ if (!_taskPrg) return;
|
||||
+ if (progress > 0) {
|
||||
_taskPrg->setVisible(true);
|
||||
_taskPrg->setValue(progress);
|
||||
} else {
|
||||
@@ -1107,8 +1182,14 @@ QWidget* MainFrame::GetBodyView()
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
-bool MainFrame::nativeEvent(const QByteArray &eventType, void *message, MESSAGE_RESULT_PTR result)
|
||||
-{
|
||||
+bool MainFrame::nativeEvent(const QByteArray &eventType, void *message, MESSAGE_RESULT_PTR result)
|
||||
+{
|
||||
+ if (qApp->property("embeddedHost").toBool() &&
|
||||
+ static_cast<MSG*>(message)->message == WM_CLOSE) {
|
||||
+ QTimer::singleShot(0, this, [this]() { close(); });
|
||||
+ *result = 0;
|
||||
+ return true;
|
||||
+ }
|
||||
if (_parentNativeWidget != NULL)
|
||||
{
|
||||
MSG *msg = static_cast<MSG*>(message);
|
||||
diff --git a/DSView/pv/mainframe.h b/DSView/pv/mainframe.h
|
||||
index 0c2ec3ee..76f91faa 100644
|
||||
--- a/DSView/pv/mainframe.h
|
||||
+++ b/DSView/pv/mainframe.h
|
||||
@@ -99,7 +99,10 @@ public:
|
||||
public:
|
||||
MainFrame();
|
||||
|
||||
- void ShowFormInit();
|
||||
+ void ShowFormInit();
|
||||
+#ifdef _WIN32
|
||||
+ bool ShowEmbedded(quintptr parentId);
|
||||
+#endif
|
||||
void ShowHelpDocAsync();
|
||||
|
||||
bool IsMaxsized();
|
||||
diff --git a/DSView/pv/mainwindow.cpp b/DSView/pv/mainwindow.cpp
|
||||
index 74dc348b..c6800415 100644
|
||||
--- a/DSView/pv/mainwindow.cpp
|
||||
+++ b/DSView/pv/mainwindow.cpp
|
||||
@@ -22,12 +22,16 @@
|
||||
|
||||
#include <QAction>
|
||||
#include <QButtonGroup>
|
||||
-#include <QFileDialog>
|
||||
+#include <QFileDialog>
|
||||
+#include <QFileInfo>
|
||||
#include <QMessageBox>
|
||||
#include <QMenu>
|
||||
#include <QMenuBar>
|
||||
#include <QStatusBar>
|
||||
-#include <QVBoxLayout>
|
||||
+#include <QVBoxLayout>
|
||||
+#include <QScrollArea>
|
||||
+#include <QScrollBar>
|
||||
+#include <QSplitter>
|
||||
#include <QWidget>
|
||||
#include <QDesktopServices>
|
||||
#include <QKeyEvent>
|
||||
@@ -108,8 +112,60 @@
|
||||
namespace pv
|
||||
{
|
||||
|
||||
- namespace{
|
||||
- QString tmp_file;
|
||||
+ namespace{
|
||||
+ QString tmp_file;
|
||||
+
|
||||
+ // Keep the instrument controls above the waveform, rather than above
|
||||
+ // the right-hand docks. Narrow hosts scroll instead of wrapping Help.
|
||||
+ class EmbeddedToolbars : public QScrollArea {
|
||||
+ public:
|
||||
+ EmbeddedToolbars(const QList<QToolBar*> &bars, QWidget *parent)
|
||||
+ : QScrollArea(parent), _bars(bars)
|
||||
+ {
|
||||
+ setFrameShape(QFrame::NoFrame);
|
||||
+ setWidgetResizable(true);
|
||||
+ setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||
+ setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
|
||||
+ auto panel = new QWidget(this);
|
||||
+ auto layout = new QHBoxLayout(panel);
|
||||
+ layout->setContentsMargins(0, 0, 0, 0);
|
||||
+ layout->setSpacing(0);
|
||||
+ layout->setSizeConstraint(QLayout::SetMinAndMaxSize);
|
||||
+ for (auto bar : _bars) {
|
||||
+ layout->addWidget(bar);
|
||||
+ bar->installEventFilter(this);
|
||||
+ }
|
||||
+ layout->addStretch(1);
|
||||
+ setWidget(panel);
|
||||
+ panel->installEventFilter(this);
|
||||
+ updateSize();
|
||||
+ }
|
||||
+ protected:
|
||||
+ bool eventFilter(QObject *object, QEvent *event) override {
|
||||
+ if (event->type() == QEvent::LayoutRequest)
|
||||
+ updateSize();
|
||||
+ return QScrollArea::eventFilter(object, event);
|
||||
+ }
|
||||
+ void resizeEvent(QResizeEvent *event) override {
|
||||
+ QScrollArea::resizeEvent(event);
|
||||
+ updateSize();
|
||||
+ }
|
||||
+ private:
|
||||
+ void updateSize() {
|
||||
+ int height = 0;
|
||||
+ int width = 0;
|
||||
+ for (auto bar : _bars) {
|
||||
+ const QSize hint = bar->sizeHint();
|
||||
+ bar->setMinimumSize(hint);
|
||||
+ width += hint.width();
|
||||
+ height = qMax(height, hint.height());
|
||||
+ }
|
||||
+ if (width > viewport()->width())
|
||||
+ height += horizontalScrollBar()->sizeHint().height();
|
||||
+ setFixedHeight(height);
|
||||
+ }
|
||||
+ QList<QToolBar*> _bars;
|
||||
+ };
|
||||
}
|
||||
|
||||
MainWindow::MainWindow(toolbars::TitleBar *title_bar, QWidget *parent)
|
||||
@@ -189,10 +245,15 @@ namespace pv
|
||||
|
||||
|
||||
setIconSize(QSize(40, 40));
|
||||
- addToolBar(_sampling_bar);
|
||||
- addToolBar(_trig_bar);
|
||||
- addToolBar(_file_bar);
|
||||
- addToolBar(_logo_bar);
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ _vertical_layout->insertWidget(0, new EmbeddedToolbars(
|
||||
+ {_sampling_bar, _trig_bar, _file_bar, _logo_bar}, _central_widget));
|
||||
+ } else {
|
||||
+ addToolBar(_sampling_bar);
|
||||
+ addToolBar(_trig_bar);
|
||||
+ addToolBar(_file_bar);
|
||||
+ addToolBar(_logo_bar);
|
||||
+ }
|
||||
|
||||
// Setup the dockWidget
|
||||
_protocol_dock = new QDockWidget(L_S(STR_PAGE_DLG, S_ID(IDS_DLG_PROTOCOL_DOCK_TITLE), "Decode Protocol"), this);
|
||||
@@ -200,8 +261,43 @@ namespace pv
|
||||
_protocol_dock->setFeatures(QDockWidget::DockWidgetMovable);
|
||||
_protocol_dock->setAllowedAreas(Qt::RightDockWidgetArea);
|
||||
_protocol_dock->setVisible(false);
|
||||
- _protocol_widget = new dock::ProtocolDock(_protocol_dock, *_view, _session);
|
||||
- _protocol_dock->setWidget(_protocol_widget);
|
||||
+ _protocol_widget = new dock::ProtocolDock(_protocol_dock, *_view, _session);
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ // The dock occupies the right column, but its visible panel can be
|
||||
+ // shortened from the top. Move the title with the whole panel.
|
||||
+ _protocol_dock->setTitleBarWidget(new QWidget(_protocol_dock));
|
||||
+ _protocol_dock->setFeatures(QDockWidget::NoDockWidgetFeatures);
|
||||
+ auto outer = new QSplitter(Qt::Vertical, _protocol_dock);
|
||||
+ outer->setObjectName("decoderOuterSplit");
|
||||
+ auto space = new QWidget(outer);
|
||||
+ space->setMinimumHeight(0);
|
||||
+ auto panel = new QWidget(outer);
|
||||
+ auto panel_layout = new QVBoxLayout(panel);
|
||||
+ panel_layout->setContentsMargins(0, 0, 0, 0);
|
||||
+ panel_layout->setSpacing(0);
|
||||
+ auto title = new QLabel(_protocol_dock->windowTitle(), panel);
|
||||
+ title->setStyleSheet("background: #151d28; padding: 5px 10px;");
|
||||
+ connect(_protocol_dock, &QWidget::windowTitleChanged, title, &QLabel::setText);
|
||||
+ panel_layout->addWidget(title);
|
||||
+ panel_layout->addWidget(_protocol_widget, 1);
|
||||
+ outer->addWidget(space);
|
||||
+ outer->addWidget(panel);
|
||||
+ outer->setCollapsible(0, true);
|
||||
+ outer->setCollapsible(1, false);
|
||||
+ outer->setHandleWidth(10);
|
||||
+ outer->setStretchFactor(0, 0);
|
||||
+ outer->setStretchFactor(1, 1);
|
||||
+ outer->setSizes({0, 600});
|
||||
+ outer->handle(1)->setCursor(Qt::SizeVerCursor);
|
||||
+ outer->handle(1)->setToolTip(tr("Drag the top edge to resize the entire Decoders panel"));
|
||||
+ outer->setStyleSheet(
|
||||
+ "QSplitter#decoderOuterSplit::handle:vertical { background: #243247; "
|
||||
+ "border-top: 1px solid #344762; border-bottom: 1px solid #344762; }"
|
||||
+ "QSplitter#decoderOuterSplit::handle:vertical:hover { background: #1570d8; }");
|
||||
+ _protocol_dock->setWidget(outer);
|
||||
+ } else {
|
||||
+ _protocol_dock->setWidget(_protocol_widget);
|
||||
+ }
|
||||
|
||||
_session->set_decoder_pannel(_protocol_widget);
|
||||
|
||||
@@ -479,7 +575,38 @@ namespace pv
|
||||
return true;
|
||||
}
|
||||
|
||||
- void MainWindow::on_protocol(bool visible)
|
||||
+ const char *MainWindow::openHostFile(const QString &path)
|
||||
+ {
|
||||
+ if (_session->is_working() || _session->is_saving() || QApplication::activeModalWidget())
|
||||
+ return "busy";
|
||||
+ if (!QFileInfo(path).isFile() || QFileInfo(path).suffix().compare("dsl", Qt::CaseInsensitive))
|
||||
+ return "error";
|
||||
+ if (confirm_to_store_data()) {
|
||||
+ on_save();
|
||||
+ return "save";
|
||||
+ }
|
||||
+ if (_session->is_working() || _session->is_saving()) return "busy";
|
||||
+ if (_device_agent->is_hardware()) save_config();
|
||||
+ try {
|
||||
+ return _session->set_file(path) ? "ok" : "error";
|
||||
+ } catch (...) {
|
||||
+ return "error";
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ void MainWindow::addHostDecoder(const QString &id)
|
||||
+ {
|
||||
+ on_protocol(true);
|
||||
+ _protocol_widget->add_host_decoder(id);
|
||||
+ }
|
||||
+
|
||||
+ void MainWindow::checkHostDecoders()
|
||||
+ {
|
||||
+ on_protocol(true);
|
||||
+ _protocol_widget->check_host_decoders();
|
||||
+ }
|
||||
+
|
||||
+ void MainWindow::on_protocol(bool visible)
|
||||
{
|
||||
_protocol_dock->setVisible(visible);
|
||||
|
||||
@@ -1414,8 +1541,9 @@ namespace pv
|
||||
_session->update_lang_text();
|
||||
}
|
||||
|
||||
- void MainWindow::switchTheme(QString style)
|
||||
- {
|
||||
+ void MainWindow::switchTheme(QString style)
|
||||
+ {
|
||||
+ if (qApp->property("embeddedHost").toBool()) style = "dark";
|
||||
AppConfig &app = AppConfig::Instance();
|
||||
|
||||
if (app.frameOptions.style != style)
|
||||
@@ -1427,7 +1555,25 @@ namespace pv
|
||||
QString qssRes = ":/" + style + ".qss";
|
||||
QFile qss(qssRes);
|
||||
qss.open(QFile::ReadOnly | QFile::Text);
|
||||
- qApp->setStyleSheet(qss.readAll());
|
||||
+ QString stylesheet = QString::fromUtf8(qss.readAll());
|
||||
+ if (qApp->property("embeddedHost").toBool()) {
|
||||
+ QFile hostStyle(QString::fromUtf8(qgetenv("DSVIEW_HOST_STYLE")));
|
||||
+ if (hostStyle.open(QFile::ReadOnly | QFile::Text))
|
||||
+ stylesheet += QString::fromUtf8(hostStyle.readAll());
|
||||
+ QPalette palette;
|
||||
+ palette.setColor(QPalette::Window, QColor("#10151d"));
|
||||
+ palette.setColor(QPalette::Base, QColor("#111c29"));
|
||||
+ palette.setColor(QPalette::AlternateBase, QColor("#151d28"));
|
||||
+ palette.setColor(QPalette::WindowText, QColor("#dce6f2"));
|
||||
+ palette.setColor(QPalette::Text, QColor("#dce6f2"));
|
||||
+ palette.setColor(QPalette::Button, QColor("#243247"));
|
||||
+ palette.setColor(QPalette::ButtonText, QColor("#dce6f2"));
|
||||
+ palette.setColor(QPalette::Highlight, QColor("#1570d8"));
|
||||
+ palette.setColor(QPalette::HighlightedText, Qt::white);
|
||||
+ qApp->setPalette(palette);
|
||||
+ qApp->setFont(QFont("Segoe UI", 10));
|
||||
+ }
|
||||
+ qApp->setStyleSheet(stylesheet);
|
||||
qss.close();
|
||||
|
||||
UiManager::Instance()->Update(UI_UPDATE_ACTION_THEME);
|
||||
diff --git a/DSView/pv/mainwindow.h b/DSView/pv/mainwindow.h
|
||||
index a43e0427..8212fe14 100644
|
||||
--- a/DSView/pv/mainwindow.h
|
||||
+++ b/DSView/pv/mainwindow.h
|
||||
@@ -93,7 +93,10 @@ public:
|
||||
public:
|
||||
explicit MainWindow(toolbars::TitleBar *title_bar, QWidget *parent = 0);
|
||||
|
||||
- void openDoc();
|
||||
+ void openDoc();
|
||||
+ void addHostDecoder(const QString &id);
|
||||
+ const char *openHostFile(const QString &path);
|
||||
+ void checkHostDecoders();
|
||||
|
||||
public slots:
|
||||
void switchTheme(QString style);
|
||||
diff --git a/DSView/pv/toolbars/filebar.cpp b/DSView/pv/toolbars/filebar.cpp
|
||||
index f562431c..792c6971 100644
|
||||
--- a/DSView/pv/toolbars/filebar.cpp
|
||||
+++ b/DSView/pv/toolbars/filebar.cpp
|
||||
@@ -24,6 +24,7 @@
|
||||
#include <QFileDialog>
|
||||
#include <deque>
|
||||
#include <QApplication>
|
||||
+#include <cstdio>
|
||||
|
||||
#include "filebar.h"
|
||||
#include "../ui/msgbox.h"
|
||||
@@ -146,7 +147,9 @@ void FileBar::on_actionOpen_triggered()
|
||||
this,
|
||||
L_S(STR_PAGE_DLG, S_ID(IDS_DLG_OPEN_FILE), "Open File"),
|
||||
app.userHistory.openDir,
|
||||
- "DSView Data (*.dsl)");
|
||||
+ qApp->property("embeddedHost").toBool()
|
||||
+ ? "Digital captures (*.dsl *.csv);;DSView Data (*.dsl);;CSV (*.csv)"
|
||||
+ : "DSView Data (*.dsl)");
|
||||
|
||||
if (!file_name.isEmpty()) {
|
||||
QString fname = path::GetDirectoryName(file_name);
|
||||
@@ -155,7 +158,12 @@ void FileBar::on_actionOpen_triggered()
|
||||
app.SaveHistory();
|
||||
}
|
||||
|
||||
- sig_load_file(file_name);
|
||||
+ if (qApp->property("embeddedHost").toBool() && file_name.endsWith(".csv", Qt::CaseInsensitive)) {
|
||||
+ printf("DSVIEW_IMPORT_CSV %s\n", file_name.toUtf8().toBase64().constData());
|
||||
+ fflush(stdout);
|
||||
+ } else {
|
||||
+ sig_load_file(file_name);
|
||||
+ }
|
||||
}
|
||||
}
|
||||
|
||||
diff --git a/DSView/pv/toolbars/logobar.cpp b/DSView/pv/toolbars/logobar.cpp
|
||||
index 4b3b492c..95c28b49 100644
|
||||
--- a/DSView/pv/toolbars/logobar.cpp
|
||||
+++ b/DSView/pv/toolbars/logobar.cpp
|
||||
@@ -106,13 +106,17 @@ LogoBar::LogoBar(SigSession *session, QWidget *parent) :
|
||||
_logo_button.setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||
_logo_button.setPopupMode(QToolButton::InstantPopup);
|
||||
|
||||
- QWidget *spacer = new QWidget(this);
|
||||
- spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
- addWidget(spacer);
|
||||
+ if (!qApp->property("embeddedHost").toBool()) {
|
||||
+ QWidget *spacer = new QWidget(this);
|
||||
+ spacer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding);
|
||||
+ addWidget(spacer);
|
||||
+ }
|
||||
addWidget(&_logo_button);
|
||||
- QWidget *margin = new QWidget(this);
|
||||
- margin->setMinimumWidth(20);
|
||||
- addWidget(margin);
|
||||
+ if (!qApp->property("embeddedHost").toBool()) {
|
||||
+ QWidget *margin = new QWidget(this);
|
||||
+ margin->setMinimumWidth(20);
|
||||
+ addWidget(margin);
|
||||
+ }
|
||||
|
||||
connect(_action_en, SIGNAL(triggered()), this, SLOT(on_actionEn_triggered()));
|
||||
connect(_action_cn, SIGNAL(triggered()), this, SLOT(on_actionCn_triggered()));
|
||||
24
tools/dsview/tests/gate_summary_test.cpp
Normal file
24
tools/dsview/tests/gate_summary_test.cpp
Normal file
@@ -0,0 +1,24 @@
|
||||
#include "gate_summary.h"
|
||||
#include <cassert>
|
||||
int main() {
|
||||
GateSummary summary;
|
||||
assert(summary.severity(true) == 1); // Empty captures are not OK.
|
||||
summary.observe(0); summary.observe(1); summary.observe(2);
|
||||
assert(summary.severity(false) == 1);
|
||||
assert(summary.severity(true) == 0);
|
||||
summary.observe(0); // A command without a complete ACK at EOF.
|
||||
assert(summary.severity(true) == 1);
|
||||
summary.observe(2);
|
||||
assert(summary.severity(true) == 0);
|
||||
summary.observe(3);
|
||||
assert(summary.severity(true) == 1);
|
||||
summary.observe(4);
|
||||
assert(summary.severity(false) == 2);
|
||||
assert(summary.severity(true) == 2);
|
||||
summary.observe(2); // Later good events never hide earlier faults.
|
||||
assert(summary.severity(true) == 2);
|
||||
summary.reset();
|
||||
assert(summary.severity(true) == 1);
|
||||
summary.observe(5);
|
||||
assert(summary.severity(true) == 2);
|
||||
}
|
||||
438
tools/dsview/tests/test_decoders.py
Normal file
438
tools/dsview/tests/test_decoders.py
Normal file
@@ -0,0 +1,438 @@
|
||||
"""Wire vectors and DSView API boundary tests; no sigrok installation needed."""
|
||||
import binascii
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
import struct
|
||||
import sys
|
||||
import types
|
||||
import tempfile
|
||||
import importlib.util
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SPEC = importlib.util.spec_from_file_location('dsview_build', ROOT / 'build.py')
|
||||
BUILD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(BUILD)
|
||||
RUNTIME = tempfile.TemporaryDirectory()
|
||||
BUILD.package_decoders(Path(RUNTIME.name))
|
||||
(Path(RUNTIME.name) / 'decoders/common/__init__.py').write_text('')
|
||||
|
||||
|
||||
class FakeDecoder:
|
||||
def has_channel(self, channel):
|
||||
return channel < 2
|
||||
|
||||
def register(self, kind):
|
||||
return kind
|
||||
|
||||
def put(self, ss, es, output, data):
|
||||
self.emitted.append((ss, es, output, data))
|
||||
|
||||
|
||||
stub = types.ModuleType('sigrokdecode')
|
||||
stub.Decoder = FakeDecoder
|
||||
stub.OUTPUT_ANN, stub.OUTPUT_PYTHON, stub.SRD_CONF_SAMPLERATE = 0, 1, 2
|
||||
previous = sys.modules.get('sigrokdecode')
|
||||
sys.modules['sigrokdecode'] = stub
|
||||
sys.path.insert(0, str(Path(RUNTIME.name) / 'decoders'))
|
||||
try:
|
||||
uart = importlib.import_module('set_uart.pd')
|
||||
wire = importlib.import_module('common.setgui_decoders.set_uart')
|
||||
can = importlib.import_module('set_can.pd')
|
||||
cancore = importlib.import_module('common.setgui_decoders.set_can')
|
||||
pm = importlib.import_module('pm35_uart.pd')
|
||||
gate = importlib.import_module('gate_driver_timing.pd')
|
||||
pair_decoder = importlib.import_module('transistor_pair.pd')
|
||||
pmcore = importlib.import_module('common.setgui_decoders.pm35_uart')
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
if previous is None:
|
||||
del sys.modules['sigrokdecode']
|
||||
else:
|
||||
sys.modules['sigrokdecode'] = previous
|
||||
|
||||
PING = bytes.fromhex('A5 5A 02 08 01 00 00 00 2A 00 34 12 00 00 33 EC 33 04')
|
||||
BRIDGE = bytes.fromhex('AA 55 08 01 01 67 45 23 01 AA BB FE 14')
|
||||
|
||||
|
||||
def crc32_frame(header, payload=b''):
|
||||
data = header + payload
|
||||
return data + struct.pack('<I', binascii.crc32(data[2:]) & 0xffffffff)
|
||||
|
||||
|
||||
def modbus(raw):
|
||||
return raw + struct.pack('<H', wire.crc16(raw, True))
|
||||
|
||||
|
||||
def feed(parser, raw):
|
||||
events = []
|
||||
for i, byte in enumerate(raw):
|
||||
events.extend(parser.feed(byte, i * 10, i * 10 + 9))
|
||||
return events
|
||||
|
||||
|
||||
def instance(module, **options):
|
||||
decoder = module.Decoder()
|
||||
decoder.options = {item['id']: item['default'] for item in decoder.options}
|
||||
decoder.options.update(options)
|
||||
decoder.emitted = []
|
||||
decoder.metadata(2, 1000000)
|
||||
decoder.start()
|
||||
return decoder
|
||||
|
||||
|
||||
def uart_bytes(decoder, raw, direction=0, base=0):
|
||||
for i, byte in enumerate(raw):
|
||||
ss = base + i * 100
|
||||
decoder.decode(ss, ss+80, ('DATA', direction, (byte, [])))
|
||||
decoder.decode(ss, ss+90, ('FRAME', direction, (byte, True)))
|
||||
|
||||
|
||||
def frames(decoder):
|
||||
return [item[3][1] for item in decoder.emitted if item[2] == 1]
|
||||
|
||||
|
||||
class WireTests(unittest.TestCase):
|
||||
def test_reference_crc(self):
|
||||
self.assertEqual(wire.crc16(b'123456789'), 0x29b1)
|
||||
self.assertEqual(wire.crc16(b'123456789', True), 0x4b37)
|
||||
|
||||
def test_documented_ping(self):
|
||||
result = wire.parse_frame(PING)
|
||||
self.assertEqual((result['destination'], result['sequence']), (42, 0x1234))
|
||||
|
||||
def test_documented_bridge(self):
|
||||
result = wire.parse_frame(BRIDGE)
|
||||
self.assertEqual((result['can_id'], result['payload']), (0x1234567, b'\xaa\xbb'))
|
||||
|
||||
def test_v1_big_endian_header(self):
|
||||
frame = crc32_frame(bytes.fromhex('a55a010c12340003'), b'abc')
|
||||
result = wire.parse_frame(frame)
|
||||
self.assertEqual(result['sequence'], 0x1234)
|
||||
self.assertIn('FIRMWARE_DATA', result['summary'])
|
||||
|
||||
def test_noise_crc_and_resync(self):
|
||||
damaged = bytearray(PING)
|
||||
damaged[-1] ^= 1
|
||||
events = feed(wire.StreamParser(), b'noise\xa5' + damaged + PING + BRIDGE)
|
||||
self.assertEqual(sum(e[2] is not None for e in events), 2)
|
||||
self.assertTrue(any(e[3] and 'CRC' in e[3] for e in events))
|
||||
|
||||
def test_bad_version_and_length(self):
|
||||
events = feed(wire.StreamParser(), bytes.fromhex('a55aff a55a010100000201') + PING)
|
||||
self.assertEqual(sum(e[2] is not None for e in events), 1)
|
||||
self.assertGreaterEqual(sum(bool(e[3]) for e in events), 2)
|
||||
|
||||
def test_max_payload(self):
|
||||
raw = crc32_frame(bytes.fromhex('a55a020009000000000001000002'), bytes(range(256))*2)
|
||||
events = feed(wire.StreamParser(), raw)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual(len(events[0][2]['payload']), 512)
|
||||
|
||||
def test_unknown_message_preserved(self):
|
||||
raw = crc32_frame(bytes.fromhex('a55a020034120000000001000000'))
|
||||
self.assertEqual(wire.parse_frame(raw)['message_type'], 0x1234)
|
||||
|
||||
def test_uart_directions_are_independent(self):
|
||||
decoder = instance(uart)
|
||||
for i, byte in enumerate(PING):
|
||||
decoder.decode(i*10, i*10+9, ('FRAME', 0, (byte, True)))
|
||||
decoder.decode(i*10, i*10+9, ('FRAME', 1, (byte, True)))
|
||||
self.assertEqual([f['direction'] for f in frames(decoder)], [0, 1])
|
||||
|
||||
def test_uart_invalid_last_byte_not_emitted(self):
|
||||
decoder = instance(uart)
|
||||
uart_bytes(decoder, PING[:-1])
|
||||
decoder.decode(2000, 2090, ('FRAME', 0, (PING[-1], False)))
|
||||
self.assertFalse(frames(decoder))
|
||||
uart_bytes(decoder, PING, base=3000)
|
||||
self.assertEqual(len(frames(decoder)), 1)
|
||||
|
||||
def test_uart_gap_discards_partial(self):
|
||||
decoder = instance(uart)
|
||||
uart_bytes(decoder, PING[:10])
|
||||
uart_bytes(decoder, PING, base=200000)
|
||||
self.assertEqual(len(frames(decoder)), 1)
|
||||
|
||||
|
||||
class CanTests(unittest.TestCase):
|
||||
def segments(self, packet=PING):
|
||||
yield bytes([0x10]) + struct.pack('<H', len(packet)) + packet[:5]
|
||||
for i, offset in enumerate(range(5, len(packet), 7), 1):
|
||||
yield bytes([0x20 | (i & 15)]) + packet[offset:offset+7]
|
||||
|
||||
def test_set_reassembly(self):
|
||||
decoder = instance(can, protocol='set-v2')
|
||||
for i, data in enumerate(self.segments()):
|
||||
decoder.decode(i*1000, i*1000+900, ('extended', 0x122a0000, 'data', len(data), list(data)))
|
||||
self.assertEqual(frames(decoder)[-1]['raw'], PING)
|
||||
|
||||
def test_missing_segment(self):
|
||||
reader = cancore.Reassembler()
|
||||
parts = list(self.segments())
|
||||
reader.feed(0x122a0000, parts[0], 0, 9, 0)
|
||||
events = reader.feed(0x122a0000, parts[2], 10, 19, 1)
|
||||
self.assertIn('sequence', events[-1][3])
|
||||
self.assertFalse(reader.pending)
|
||||
|
||||
def test_timeout_and_new_first(self):
|
||||
reader = cancore.Reassembler()
|
||||
first = next(self.segments())
|
||||
reader.feed(0x122a0000, first, 0, 9, 0)
|
||||
events = reader.feed(0x122a0000, first, 20, 29, 500)
|
||||
self.assertIn('timeout', events[0][3])
|
||||
self.assertEqual(reader.pending[0x122a0000]['start'], 20)
|
||||
|
||||
def test_id_mismatch(self):
|
||||
reader = cancore.Reassembler()
|
||||
for i, part in enumerate(self.segments()):
|
||||
events = reader.feed(0x122b0000, part, i*10, i*10+9, i)
|
||||
self.assertIn('disagree', events[-1][3])
|
||||
|
||||
def test_sequence_wrap(self):
|
||||
packet = crc32_frame(bytes.fromhex('a55a0200010000002a0001008000'), bytes(128))
|
||||
reader = cancore.Reassembler()
|
||||
for i, part in enumerate(self.segments(packet)):
|
||||
events = reader.feed(0x122a0000, part, i*10, i*10+9, i)
|
||||
self.assertEqual(events[-1][2]['raw'], packet)
|
||||
|
||||
def test_interleaved_channels(self):
|
||||
reader = cancore.Reassembler()
|
||||
complete = []
|
||||
for i, part in enumerate(self.segments()):
|
||||
for channel in (0, 1):
|
||||
complete += reader.feed(0x122a0000 | channel, part, i*10, i*10+9, i)
|
||||
self.assertEqual(sum(bool(e[2] and 'raw' in e[2]) for e in complete), 2)
|
||||
|
||||
def test_gas(self):
|
||||
result = cancore.legacy(0x1b530010, b'\x34\x12\xfe\xff')
|
||||
self.assertEqual(result['registers'], [(16, 0x1234), (17, 0xfffe)])
|
||||
with self.assertRaises(ValueError):
|
||||
cancore.legacy(0x1b530010, b'\x00')
|
||||
|
||||
def test_balsam_mask_and_endianness(self):
|
||||
result = cancore.legacy(0xba0010, bytes.fromhex('a01012340000ffff'), 'balsam')
|
||||
self.assertEqual(result['registers'], [(16, 0x1234), (18, 0xffff)])
|
||||
self.assertIsNone(cancore.legacy(0xba000f, bytes(8), 'balsam'))
|
||||
|
||||
def test_ignore_standard_and_remote(self):
|
||||
decoder = instance(can)
|
||||
decoder.decode(0, 9, ('standard', 0x123, 'data', 0, []))
|
||||
decoder.decode(10, 19, ('extended', 0x1b530010, 'remote', 0, []))
|
||||
self.assertFalse(decoder.emitted)
|
||||
|
||||
def test_boot_vectors(self):
|
||||
self.assertIn('ENTER_BOOT', cancore.legacy(0x13590702, b'')['summary'])
|
||||
self.assertIn('offset=0x8', cancore.legacy(0x135b0001, bytes.fromhex('1011121314151617'))['summary'])
|
||||
self.assertIn('status=0', cancore.legacy(0x1b5c0702, bytes.fromhex('00ff000000000000'))['summary'])
|
||||
|
||||
|
||||
class PM35Tests(unittest.TestCase):
|
||||
def test_single_wire_auto(self):
|
||||
reader = pmcore.PM35Parser('auto')
|
||||
raw = modbus(bytes.fromhex('100300000002')) + modbus(bytes.fromhex('1003041234ffff'))
|
||||
events = feed(reader, raw)
|
||||
self.assertEqual(len(events), 2)
|
||||
self.assertTrue(all(event[2] for event in events))
|
||||
|
||||
def test_request_and_response(self):
|
||||
request = modbus(bytes.fromhex('100300000002'))
|
||||
response = modbus(bytes.fromhex('1003041234ffff'))
|
||||
decoder = instance(pm)
|
||||
uart_bytes(decoder, request, 1)
|
||||
uart_bytes(decoder, response, 0, 10000)
|
||||
self.assertEqual(len(frames(decoder)), 2)
|
||||
self.assertEqual(frames(decoder)[1]['values'], [0x1234, 65535])
|
||||
|
||||
def test_128_word_legacy_reply(self):
|
||||
events = feed(pmcore.PM35Parser('response'), modbus(b'\x10\x03\x00' + bytes(256)))
|
||||
self.assertEqual(len(events[0][2]['values']), 128)
|
||||
|
||||
def test_write_command(self):
|
||||
result = pmcore.parse_pm35(modbus(bytes.fromhex('1006007f8000')), 'request')
|
||||
self.assertIn('command_bits=8000', result['summary'])
|
||||
|
||||
def test_exception(self):
|
||||
result = pmcore.parse_pm35(modbus(bytes.fromhex('108302')), 'response')
|
||||
self.assertIn('exception', result['summary'])
|
||||
|
||||
def test_crc_and_range_errors(self):
|
||||
with self.assertRaisesRegex(ValueError, 'CRC'):
|
||||
pmcore.parse_pm35(bytes.fromhex('1003000000010000'), 'request')
|
||||
with self.assertRaisesRegex(ValueError, 'range'):
|
||||
pmcore.parse_pm35(modbus(bytes.fromhex('1003007f0002')), 'request')
|
||||
|
||||
|
||||
class GateAdapterTests(unittest.TestCase):
|
||||
def test_wait_releases_dsview_shared_pin_tuple(self):
|
||||
for channels, pair in (((1,), 0), ((0, 1), 0), ((0, 1), 1)):
|
||||
with self.subTest(channels=channels, pair=pair):
|
||||
decoder = instance(pair_decoder if pair else gate)
|
||||
decoder.has_channel = lambda channel: channel in channels
|
||||
shared_pins = tuple([0, 0])
|
||||
calls = []
|
||||
|
||||
def wait(conditions):
|
||||
# Native DSView owns one reference and PyTuple_SetItem
|
||||
# rejects any reference retained by the previous caller.
|
||||
self.assertEqual(sys.getrefcount(shared_pins), 2)
|
||||
if len(calls) == 3:
|
||||
raise StopIteration
|
||||
calls.append(conditions)
|
||||
decoder.samplenum = len(calls) * 10
|
||||
decoder.matched = 0
|
||||
return shared_pins
|
||||
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
def test_orphan_filter_boundaries_profiles_and_polarities(self):
|
||||
for profile in ('1SP0635', '1SD536F2'):
|
||||
for active_high in (True, False):
|
||||
with self.subTest(profile=profile, active_high=active_high):
|
||||
checker = gate.TimingChecker(1_000_000_000, profile=profile,
|
||||
vstat_active_high=active_high, orphan_min_width_ns=50)
|
||||
for start, width, expected in ((100, 20, []), (200, 49, []),
|
||||
(300, 50, ['orphan']), (400, 51, ['orphan'])):
|
||||
self.assertEqual(checker.on_status_edge(start, int(active_high)), [])
|
||||
events = checker.on_status_edge(start + width, int(not active_high))
|
||||
self.assertEqual([event['kind'] for event in events], expected)
|
||||
self.assertIsNone(checker.status_start)
|
||||
|
||||
def test_orphan_filter_preserves_ack_errors_faults_and_missing_ack(self):
|
||||
checker = gate.TimingChecker(1_000_000_000, orphan_min_width_ns=10_000)
|
||||
checker.on_control_edge(0, 1)
|
||||
self.assertEqual(checker.on_status_edge(250, 1)[0]['kind'], 'delay_ok')
|
||||
self.assertEqual(checker.on_status_edge(950, 0)[0]['kind'], 'width_ok')
|
||||
checker.on_control_edge(1000, 0)
|
||||
checker.on_status_edge(1250, 1)
|
||||
self.assertEqual(checker.on_status_edge(1270, 0)[0]['kind'], 'width_fail')
|
||||
checker.on_status_edge(2000, 1)
|
||||
self.assertEqual(checker.on_status_edge(4000, 0)[0]['kind'], 'fault')
|
||||
checker.on_control_edge(5000, 1)
|
||||
self.assertEqual(checker.expire(checker.next_deadline())[0]['kind'], 'missing')
|
||||
|
||||
def test_orphan_filter_default_disabled_and_unknown_duration_retained(self):
|
||||
checker = gate.TimingChecker(1_000_000_000)
|
||||
checker.on_status_edge(10, 1)
|
||||
self.assertEqual(checker.on_status_edge(30, 0)[0]['short'], 'ORPHAN 20.0 ns')
|
||||
checker = gate.TimingChecker(1_000_000_000, orphan_min_width_ns=50)
|
||||
# A recording starting within a pulse gives no measurable pulse width.
|
||||
self.assertEqual(checker.on_status_edge(10, 0)[0]['short'], 'Vstat?')
|
||||
|
||||
def test_orphan_filter_rejects_invalid_thresholds(self):
|
||||
for value in (-1, float('nan'), float('inf')):
|
||||
with self.subTest(value=value), self.assertRaisesRegex(ValueError, 'ORPHAN'):
|
||||
gate.TimingChecker(100_000_000, orphan_min_width_ns=value)
|
||||
|
||||
def test_orphan_filter_option_through_native_edge_api(self):
|
||||
decoder = instance(gate, orphan_min_width_ns=50)
|
||||
decoder.metadata(2, 100_000_000)
|
||||
decoder.samplenum = 0
|
||||
samples = iter([(10, 1), (12, 0), (20, 1), (25, 0)])
|
||||
def wait(conditions):
|
||||
decoder.samplenum, level = next(samples)
|
||||
decoder.matched = 2
|
||||
return 0, level
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual(len(decoder.emitted), 1)
|
||||
self.assertEqual(decoder.emitted[0][:2], (20, 25))
|
||||
self.assertEqual(decoder.emitted[0][3][1][1], 'ORPHAN 50.0 ns')
|
||||
|
||||
def test_profiles_through_native_edge_api(self):
|
||||
for profile, active, edges, expected in [
|
||||
('1SP0635', 'high', [(100, 1, 0, 1), (125, 1, 1, 2), (195, 1, 0, 2)], 700),
|
||||
('1SD536F2', 'low', [(20, 1, 1, 1), (58, 1, 0, 2), (148, 1, 1, 2)], 900),
|
||||
]:
|
||||
with self.subTest(profile=profile):
|
||||
decoder = instance(gate, profile=profile, vstat_active=active)
|
||||
decoder.metadata(2, 100_000_000)
|
||||
decoder.samplenum = 0
|
||||
samples = iter(edges)
|
||||
def wait(conditions):
|
||||
decoder.samplenum, vin, vstat, decoder.matched = next(samples)
|
||||
return vin, vstat
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual([item[3][0] for item in decoder.emitted], [0, 1, 2])
|
||||
self.assertIn(str(expected), decoder.emitted[-1][3][1][0])
|
||||
|
||||
def test_driver_has_no_pair_controls(self):
|
||||
decoder = instance(gate)
|
||||
self.assertNotIn('pair_analysis', decoder.options)
|
||||
self.assertNotIn('vin2_mintime_ns', decoder.options)
|
||||
self.assertEqual([c['id'] for c in gate.Decoder.optional_channels], ['vin', 'vstat'])
|
||||
|
||||
def test_pair_decoder_without_vstat(self):
|
||||
decoder = instance(pair_decoder, vin2_mintime_ns=200, deadtime_12_ns=50)
|
||||
decoder.metadata(2, 1_000_000_000)
|
||||
samples = iter([(0, (0, 0)), (10, (1, 0)), (110, (0, 0)),
|
||||
(140, (0, 1)), (240, (0, 0)), (280, (1, 0))])
|
||||
def wait(conditions):
|
||||
decoder.samplenum, pins = next(samples)
|
||||
return pins
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
text = ' '.join(item[3][1][0] for item in decoder.emitted)
|
||||
self.assertIn('Deadtime Vin1 -> Vin2: 30.0 ns', text)
|
||||
self.assertIn('FAIL: Vin2 active', text)
|
||||
self.assertIn('duty', text)
|
||||
self.assertNotIn('ACK', text)
|
||||
self.assertTrue(any(e[3][0] == 3 for e in decoder.emitted))
|
||||
decoder.reset()
|
||||
self.assertIsNone(decoder.checker)
|
||||
|
||||
def test_status_only_and_pair_requires_two_inputs(self):
|
||||
decoder = instance(gate)
|
||||
decoder.has_channel = lambda channel: channel == 1
|
||||
samples = iter([(10, 1), (1010, 0)])
|
||||
def wait(conditions):
|
||||
self.assertEqual(conditions, [{1: 'e'}])
|
||||
decoder.samplenum, status = next(samples)
|
||||
decoder.matched = 1
|
||||
return (255, status, 255)
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual(len(decoder.emitted), 1)
|
||||
decoder = instance(pair_decoder)
|
||||
decoder.has_channel = lambda channel: channel == 0
|
||||
with self.assertRaisesRegex(ValueError, 'Vin1 and Vin2'):
|
||||
decoder.decode()
|
||||
|
||||
def test_missing_ack_timer_emits_error(self):
|
||||
decoder = instance(gate)
|
||||
decoder.metadata(2, 100_000_000)
|
||||
decoder.samplenum = 0
|
||||
calls = []
|
||||
def wait(conditions):
|
||||
calls.append(conditions)
|
||||
if len(calls) == 1:
|
||||
decoder.samplenum, decoder.matched = 100, 1
|
||||
elif len(calls) == 2:
|
||||
decoder.samplenum += conditions[2]['skip']
|
||||
decoder.matched = 4
|
||||
else:
|
||||
raise StopIteration
|
||||
return 1, 0
|
||||
decoder.wait = wait
|
||||
with self.assertRaises(StopIteration):
|
||||
decoder.decode()
|
||||
self.assertEqual([item[3][0] for item in decoder.emitted], [0, 4])
|
||||
|
||||
def test_packaged_core_matches_canonical_templates(self):
|
||||
canonical = ROOT.parents[1] / 'python/logic_analyzer/decoders'
|
||||
packaged = Path(RUNTIME.name) / 'decoders/common/setgui_decoders'
|
||||
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
self.assertEqual((canonical / (name + '.py')).read_bytes(),
|
||||
(packaged / (name + '.py')).read_bytes())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
21
tools/dsview/tests/test_gate_summary.py
Normal file
21
tools/dsview/tests/test_gate_summary.py
Normal file
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
COMPILER = shutil.which('g++') or (r'C:/setcorp/tools/msys64/ucrt64/bin/g++.exe' if os.name == 'nt' else None)
|
||||
|
||||
@unittest.skipUnless(COMPILER and Path(COMPILER).is_file(), 'C++ compiler required')
|
||||
class GateSummaryTests(unittest.TestCase):
|
||||
def test_verdict_priority_completion_and_reset(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
binary = Path(temporary) / ('summary.exe' if os.name == 'nt' else 'summary')
|
||||
env = dict(os.environ)
|
||||
env['PATH'] = str(Path(COMPILER).parent) + os.pathsep + env.get('PATH', '')
|
||||
subprocess.run([COMPILER, '-std=c++11', '-static', '-I', str(ROOT / 'native'),
|
||||
str(ROOT / 'tests/gate_summary_test.cpp'), '-o', str(binary)],
|
||||
env=env, check=True, capture_output=True)
|
||||
subprocess.run([str(binary)], env=env, check=True, capture_output=True)
|
||||
156
tools/dsview/tests/test_gate_timing.py
Normal file
156
tools/dsview/tests/test_gate_timing.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
MODULE_PATH = (Path(__file__).resolve().parents[3] / 'python/logic_analyzer/decoders/gate_timing.py')
|
||||
SPEC = importlib.util.spec_from_file_location('gate_driver_timing_core', MODULE_PATH)
|
||||
TIMING = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(TIMING)
|
||||
|
||||
|
||||
def kinds(events):
|
||||
return [item['kind'] for item in events]
|
||||
|
||||
|
||||
class TimingCheckerTests(unittest.TestCase):
|
||||
def test_1sp0635_nominal_ack_at_100_mhz(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(100, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(125, 1)), ['delay_ok'])
|
||||
result = checker.on_status_edge(195, 0)
|
||||
self.assertEqual(kinds(result), ['width_ok'])
|
||||
self.assertEqual(result[0]['width_ns'], 700.0)
|
||||
|
||||
def test_1sd536f2_nominal_ack_at_100_mhz_active_low(self):
|
||||
checker = TIMING.TimingChecker(
|
||||
100_000_000, profile='1SD536F2', vstat_active_high=False)
|
||||
checker.on_control_edge(20, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(58, 0)), ['delay_ok'])
|
||||
result = checker.on_status_edge(148, 1)
|
||||
self.assertEqual(kinds(result), ['width_ok'])
|
||||
self.assertEqual(result[0]['width_ns'], 900.0)
|
||||
|
||||
def test_late_delay_is_warning_but_valid_width_passes(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(0, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(50, 1)), ['delay_warn'])
|
||||
self.assertEqual(kinds(checker.on_status_edge(120, 0)), ['width_ok'])
|
||||
|
||||
def test_too_short_ack_fails(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(0, 1)
|
||||
checker.on_status_edge(25, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(55, 0)), ['width_fail'])
|
||||
|
||||
def test_long_status_is_fault(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(0, 1)
|
||||
checker.on_status_edge(25, 1)
|
||||
self.assertEqual(kinds(checker.on_status_edge(225, 0)), ['fault'])
|
||||
|
||||
def test_missing_ack_expires(self):
|
||||
checker = TIMING.TimingChecker(100_000_000, profile='1SP0635')
|
||||
checker.on_control_edge(10, 0)
|
||||
deadline = checker.next_deadline()
|
||||
self.assertIsNotNone(deadline)
|
||||
self.assertEqual(kinds(checker.expire(deadline)), ['missing'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class InputTimingTests(unittest.TestCase):
|
||||
def test_both_directions_and_independent_minimums(self):
|
||||
c = TIMING.InputTimingChecker(1e9, vin1_mintime_ns=100, vin2_mintime_ns=200)
|
||||
c.update(0, 0, 0)
|
||||
c.update(10, 1, 0)
|
||||
self.assertEqual(kinds(c.update(110, 0, 0)), ['mintime_ok'])
|
||||
event = c.update(140, 0, 1)[0]
|
||||
self.assertEqual(event['duration_ns'], 30)
|
||||
self.assertIn('Vin1 -> Vin2', event['text'])
|
||||
self.assertEqual(kinds(c.update(240, 0, 0)), ['mintime_fail'])
|
||||
event = c.update(280, 1, 0)[0]
|
||||
self.assertEqual(event['duration_ns'], 40)
|
||||
self.assertIn('Vin2 -> Vin1', event['text'])
|
||||
|
||||
def test_atomic_handover_and_active_low(self):
|
||||
c = TIMING.InputTimingChecker(1e9, False, False)
|
||||
c.update(0, 1, 1)
|
||||
c.update(10, 0, 1)
|
||||
events = c.update(20, 1, 0)
|
||||
self.assertEqual(kinds(events), ['mintime_ok', 'deadtime'])
|
||||
self.assertEqual(events[1]['duration_ns'], 0)
|
||||
self.assertEqual(kinds(c.update(30, 0, 1)), ['mintime_ok', 'deadtime'])
|
||||
|
||||
def test_overlap_and_capture_boundaries(self):
|
||||
c = TIMING.InputTimingChecker(1e9)
|
||||
c.update(0, 1, 0)
|
||||
c.update(10, 1, 1)
|
||||
events = c.update(25, 0, 1)
|
||||
self.assertEqual(kinds(events), ['overlap'])
|
||||
self.assertEqual(events[0]['duration_ns'], 15)
|
||||
c.update(30, 1, 1)
|
||||
self.assertEqual(kinds(c.finish(50)), ['overlap'])
|
||||
c = TIMING.InputTimingChecker(1e9)
|
||||
c.update(0, 0, 0)
|
||||
self.assertEqual(c.update(10, 1, 0), [])
|
||||
self.assertEqual(c.finish(20), [])
|
||||
|
||||
def test_no_stale_deadtime_after_same_input_restarts(self):
|
||||
c = TIMING.InputTimingChecker(1e9)
|
||||
for t, a, b in [(0, 0, 0), (10, 1, 0), (20, 0, 0), (30, 1, 0), (40, 0, 0)]:
|
||||
c.update(t, a, b)
|
||||
self.assertEqual(c.update(50, 0, 1)[0]['duration_ns'], 10)
|
||||
|
||||
def test_invalid_minimums(self):
|
||||
for value in (-1, float('nan'), float('inf')):
|
||||
with self.assertRaises(ValueError):
|
||||
TIMING.InputTimingChecker(1e9, vin2_mintime_ns=value)
|
||||
|
||||
|
||||
class CycleResultTests(unittest.TestCase):
|
||||
def cycle(self, checker, origin=0, delay=250, width=700, inverted=False):
|
||||
events = checker.on_control_edge(origin + 100, not inverted)
|
||||
events += checker.on_status_edge(origin + 100 + delay, 1)
|
||||
events += checker.on_status_edge(origin + 100 + delay + width, 0)
|
||||
events += checker.on_control_edge(origin + 3000, inverted)
|
||||
events += checker.on_status_edge(origin + 3250, 1)
|
||||
events += checker.on_status_edge(origin + 3950, 0)
|
||||
return [e for e in events if e['kind'].startswith('cycle_')]
|
||||
|
||||
def test_complete_cycle_range_and_active_low(self):
|
||||
for inverted in (False, True):
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True, vin_active_high=not inverted)
|
||||
events = self.cycle(checker, inverted=inverted)
|
||||
self.assertEqual(len(events), 1)
|
||||
self.assertEqual((events[0]['kind'], events[0]['start'], events[0]['end']), ('cycle_ok', 100, 3950))
|
||||
|
||||
def test_warning_fault_and_next_cycle_recovery(self):
|
||||
for delay, width, verdict in [(500, 700, 'warning'), (250, 100, 'fault'), (250, 2000, 'fault')]:
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True)
|
||||
self.assertEqual(self.cycle(checker, delay=delay, width=width)[0]['kind'], 'cycle_' + verdict)
|
||||
self.assertEqual(self.cycle(checker, origin=10000)[0]['kind'], 'cycle_ok')
|
||||
|
||||
def test_missing_ack_and_clipped_capture(self):
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True)
|
||||
checker.on_control_edge(100, 1)
|
||||
checker.expire(1500)
|
||||
checker.on_control_edge(3000, 0)
|
||||
checker.on_status_edge(3250, 1)
|
||||
events = checker.on_status_edge(3950, 0)
|
||||
self.assertEqual(events[-1]['kind'], 'cycle_fault')
|
||||
checker.on_control_edge(10000, 1)
|
||||
self.assertEqual(checker.finish_cycles(10100)[0]['kind'], 'cycle_warning')
|
||||
self.assertEqual(checker.finish_cycles(10100), [])
|
||||
|
||||
def test_no_ok_before_second_ack_finishes(self):
|
||||
checker = TIMING.TimingChecker(1e9, cycle_results=True)
|
||||
events = checker.on_control_edge(100, 1)
|
||||
events += checker.on_status_edge(350, 1)
|
||||
events += checker.on_status_edge(1050, 0)
|
||||
events += checker.on_control_edge(3000, 0)
|
||||
events += checker.on_status_edge(3250, 1)
|
||||
self.assertFalse(any(e['kind'].startswith('cycle_') for e in events))
|
||||
self.assertEqual(checker.on_status_edge(3950, 0)[-1]['kind'], 'cycle_ok')
|
||||
116
tools/dsview/tests/test_install_decoders.py
Normal file
116
tools/dsview/tests/test_install_decoders.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Exercise the standalone PowerShell installer without modifying installed DSView."""
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SHELL = shutil.which('powershell.exe') or shutil.which('pwsh')
|
||||
|
||||
|
||||
@unittest.skipUnless(os.name == 'nt' and SHELL, 'Windows PowerShell required')
|
||||
class InstallerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory(prefix='dsview installer ')
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.app = Path(self.temp.name)/'DSView test'
|
||||
self.app.mkdir()
|
||||
(self.app/'DSView.exe').write_bytes(b'fixture, not executable')
|
||||
(self.app/'decoders/common').mkdir(parents=True)
|
||||
(self.app/'decoders/common/__init__.py').write_text('# existing common package\n')
|
||||
(self.app/'decoders/common/keep.txt').write_text('unrelated')
|
||||
|
||||
def run_installer(self, *extra, script=None):
|
||||
return subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
str(script or ROOT/'install_decoders.ps1'),
|
||||
'-DsViewPath', str(self.app), *extra], capture_output=True, text=True)
|
||||
|
||||
def test_install_import_and_update_backup(self):
|
||||
result = self.run_installer()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
code = '''
|
||||
import importlib, pathlib, sys, types
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
sys.path.insert(0, str(root))
|
||||
stub = types.ModuleType('sigrokdecode')
|
||||
stub.Decoder = type('Decoder', (), {})
|
||||
sys.modules['sigrokdecode'] = stub
|
||||
for name in ('gate_driver_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
module = importlib.import_module(name + '.pd')
|
||||
assert module.Decoder.api_version == 3
|
||||
core = importlib.import_module('common.setgui_decoders.gate_timing')
|
||||
pathlib.Path(core.__file__).resolve().relative_to(root)
|
||||
assert '1SP0635' in core.PROFILES
|
||||
'''
|
||||
check = subprocess.run([sys.executable, '-I', '-c', code, str(self.app/'decoders')],
|
||||
cwd=self.app, capture_output=True, text=True)
|
||||
self.assertEqual(check.returncode, 0, check.stderr)
|
||||
target = self.app/'decoders/set_uart/pd.py'
|
||||
target.write_text('# old decoder\n')
|
||||
result = self.run_installer()
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
backups = list((self.app/'set-decoder-backups').glob('*/set_uart/pd.py'))
|
||||
self.assertEqual(len(backups), 1)
|
||||
self.assertEqual(backups[0].read_text(), '# old decoder\n')
|
||||
self.assertEqual(target.read_bytes(), (ROOT/'decoders/set_uart/pd.py').read_bytes())
|
||||
self.assertEqual((self.app/'decoders/common/keep.txt').read_text(), 'unrelated')
|
||||
self.assertEqual((self.app/'decoders/common/__init__.py').read_text(), '# existing common package\n')
|
||||
for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart'):
|
||||
self.assertEqual((self.app/('decoders/common/setgui_decoders/'+name+'.py')).read_bytes(),
|
||||
(ROOT.parents[1]/('python/logic_analyzer/decoders/'+name+'.py')).read_bytes())
|
||||
|
||||
def test_check_only_does_not_install(self):
|
||||
result = self.run_installer('-CheckOnly')
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertFalse((self.app/'decoders/set_uart').exists())
|
||||
self.assertFalse((self.app/'set-decoder-backups').exists())
|
||||
|
||||
def test_copy_failure_restores_previous_decoders(self):
|
||||
old = self.app/'decoders/gate_driver_timing/pd.py'
|
||||
old.parent.mkdir()
|
||||
old.write_text('# original\n')
|
||||
runner = Path(self.temp.name)/'inject-failure.ps1'
|
||||
runner.write_text('''
|
||||
param([string]$Installer, [string]$Target)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$global:installerTestFailed = $false
|
||||
function Copy-Item {
|
||||
param([string]$LiteralPath, [string]$Destination, [switch]$Force)
|
||||
if (-not $global:installerTestFailed -and $Destination.Replace('\\', '/').EndsWith('/decoders/set_uart/pd.py')) {
|
||||
$global:installerTestFailed = $true
|
||||
throw 'Injected copy failure'
|
||||
}
|
||||
Microsoft.PowerShell.Management\\Copy-Item -LiteralPath $LiteralPath -Destination $Destination -Force:$Force
|
||||
}
|
||||
& $Installer -DsViewPath $Target
|
||||
''', encoding='utf-8')
|
||||
result = subprocess.run([SHELL, '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
|
||||
str(runner), '-Installer', str(ROOT/'install_decoders.ps1'),
|
||||
'-Target', str(self.app)], capture_output=True)
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertEqual(old.read_text(), '# original\n')
|
||||
self.assertFalse((old.parent/'__init__.py').exists())
|
||||
self.assertFalse((self.app/'decoders/set_uart/__init__.py').exists())
|
||||
self.assertEqual(len(list((self.app/'set-decoder-backups').glob('*/gate_driver_timing/pd.py'))), 1, result.stderr)
|
||||
|
||||
def test_incomplete_target_is_untouched(self):
|
||||
(self.app/'DSView.exe').unlink()
|
||||
result = self.run_installer()
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.app/'decoders/set_uart').exists())
|
||||
|
||||
def test_missing_source_is_detected_before_changes(self):
|
||||
source = Path(self.temp.name)/'empty-template/tools/dsview'
|
||||
source.mkdir(parents=True)
|
||||
shutil.copy2(ROOT/'install_decoders.ps1', source/'install_decoders.ps1')
|
||||
result = self.run_installer(script=source/'install_decoders.ps1')
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertFalse((self.app/'decoders/gate_driver_timing').exists())
|
||||
self.assertFalse((self.app/'set-decoder-backups').exists())
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
55
tools/dsview/tests/test_runtime.py
Normal file
55
tools/dsview/tests/test_runtime.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Prevent packaging a new GUI with a stale native DSView component."""
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
RECIPE = Path(__file__).resolve().parents[1] / 'build.py'
|
||||
SPEC = importlib.util.spec_from_file_location('dsview_runtime_build', RECIPE)
|
||||
BUILD = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(BUILD)
|
||||
|
||||
|
||||
class RuntimeTests(unittest.TestCase):
|
||||
def test_legacy_runtime_and_mismatched_binary_or_recipe_require_rebuild(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
output = Path(temporary)
|
||||
(output / 'DSView.exe').write_bytes(b'native fixture')
|
||||
(output / 'host-protocol-1').write_text('DSView host protocol 1\n')
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
stamp = {'recipe': 'new', 'executable': hashlib.sha256(b'native fixture').hexdigest()}
|
||||
(output / 'host-build.json').write_text(json.dumps(stamp))
|
||||
with patch.object(BUILD, 'recipe_digest', return_value='new'):
|
||||
self.assertTrue(BUILD.runtime_is_current(output))
|
||||
(output / 'DSView.exe').write_bytes(b'old native fixture')
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
(output / 'DSView.exe').write_bytes(b'native fixture')
|
||||
with patch.object(BUILD, 'recipe_digest', return_value='changed'):
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
(output / 'host-build.json').write_text('partial JSON')
|
||||
self.assertFalse(BUILD.runtime_is_current(output))
|
||||
|
||||
def test_patch_adapters_and_shared_cores_all_invalidate_runtime(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
recipe = root / 'tools/dsview'
|
||||
core = root / 'python/logic_analyzer/decoders'
|
||||
core.mkdir(parents=True)
|
||||
(recipe / 'decoders/gate_driver_timing').mkdir(parents=True)
|
||||
(recipe / 'native').mkdir()
|
||||
files = [recipe / 'build.py', recipe / 'setgui-host.patch', recipe / 'gate-pair-checkbox.patch', recipe / 'decoder-panel.patch', recipe / 'driver-summary.patch', recipe / 'windows-usb-events.patch', recipe / 'native/gate_summary.h',
|
||||
recipe / 'decoders/gate_driver_timing/pd.py']
|
||||
files += [core / (name + '.py') for name in ('gate_timing', 'transistor_pair', 'set_uart', 'set_can', 'pm35_uart')]
|
||||
for item in files:
|
||||
item.write_text('initial')
|
||||
with patch.object(BUILD, '__file__', str(recipe / 'build.py')):
|
||||
initial = BUILD.recipe_digest()
|
||||
for item in files:
|
||||
with self.subTest(file=item.name):
|
||||
item.write_text('changed')
|
||||
self.assertNotEqual(initial, BUILD.recipe_digest())
|
||||
item.write_text('initial')
|
||||
self.assertEqual(initial, BUILD.recipe_digest())
|
||||
58
tools/dsview/tests/test_transistor_pair.py
Normal file
58
tools/dsview/tests/test_transistor_pair.py
Normal file
@@ -0,0 +1,58 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3] / 'python'))
|
||||
from logic_analyzer.decoders.transistor_pair import PairTimingChecker
|
||||
|
||||
|
||||
class TransistorPairTests(unittest.TestCase):
|
||||
def test_directional_deadtime_limits_and_equality(self):
|
||||
c = PairTimingChecker(1e9, deadtime_12_ns=30, deadtime_21_ns=50)
|
||||
c.update(0, 0, 0)
|
||||
c.update(10, 1, 0)
|
||||
c.update(110, 0, 0)
|
||||
events = c.update(140, 0, 1)
|
||||
self.assertEqual(events[0]['kind'], 'deadtime')
|
||||
self.assertEqual(events[0]['duration_ns'], 30)
|
||||
c.update(240, 0, 0)
|
||||
self.assertEqual(c.update(280, 1, 0)[0]['kind'], 'deadtime_fail')
|
||||
|
||||
def test_independent_on_off_times_frequency_and_duty(self):
|
||||
c = PairTimingChecker(1e9, vin1_mintime_ns=100, vin2_mintime_ns=101,
|
||||
vin1_minoff_ns=201, vin2_minoff_ns=50)
|
||||
c.update(0, 0, 0)
|
||||
c.update(10, 1, 0)
|
||||
self.assertEqual(c.update(110, 0, 0)[0]['kind'], 'mintime_ok')
|
||||
c.update(140, 0, 1)
|
||||
self.assertEqual(c.update(240, 0, 0)[0]['kind'], 'mintime_fail')
|
||||
events = c.update(310, 1, 0)
|
||||
self.assertTrue(any(e['kind'] == 'off_fail' for e in events))
|
||||
period = next(e for e in events if e['kind'] == 'period')
|
||||
self.assertAlmostEqual(period['frequency_hz'], 1e9/300)
|
||||
self.assertAlmostEqual(period['duty_percent'], 100/3)
|
||||
self.assertEqual((period['start'], period['end']), (10, 310))
|
||||
|
||||
def test_atomic_handover_active_low_and_overlap(self):
|
||||
c = PairTimingChecker(1e9, False, False, deadtime_12_ns=1)
|
||||
c.update(0, 1, 1)
|
||||
c.update(10, 0, 1)
|
||||
events = c.update(20, 1, 0)
|
||||
self.assertTrue(any(e['kind'] == 'deadtime_fail' and e['duration_ns'] == 0 for e in events))
|
||||
c.update(30, 0, 0)
|
||||
self.assertEqual(c.finish(50)[0]['kind'], 'overlap')
|
||||
self.assertTrue(any(e['kind'] == 'overlap' for e in c.update(60, 1, 0)))
|
||||
|
||||
def test_capture_boundary_does_not_invent_complete_pulses(self):
|
||||
c = PairTimingChecker(1e9)
|
||||
self.assertEqual(c.update(0, 1, 0), [])
|
||||
self.assertEqual(c.update(10, 0, 0), [])
|
||||
events = c.update(20, 1, 0)
|
||||
self.assertEqual([e['kind'] for e in events], ['off_time'])
|
||||
self.assertEqual(c.finish(30), [])
|
||||
|
||||
def test_invalid_limits(self):
|
||||
for name in ('deadtime_12_ns', 'deadtime_21_ns', 'vin1_minoff_ns', 'vin2_minoff_ns'):
|
||||
for value in (-1, float('nan'), float('inf')):
|
||||
with self.subTest(name=name, value=value), self.assertRaises(ValueError):
|
||||
PairTimingChecker(1e9, **{name: value})
|
||||
96
tools/dsview/windows-usb-events.patch
Normal file
96
tools/dsview/windows-usb-events.patch
Normal file
@@ -0,0 +1,96 @@
|
||||
--- a/libsigrok4DSL/hardware/DSL/dslogic.c
|
||||
+++ b/libsigrok4DSL/hardware/DSL/dslogic.c
|
||||
@@ -1316,10 +1316,15 @@
|
||||
{
|
||||
int i;
|
||||
sr_info("%s: remove fds from polling", __func__);
|
||||
+#ifdef _WIN32
|
||||
+ /* Windows libusb has no poll descriptors; remove the timer source. */
|
||||
+ sr_source_remove(-1);
|
||||
+#else
|
||||
/* Remove fds from polling. */
|
||||
for (i = 0; devc->usbfd[i] != -1; i++)
|
||||
sr_source_remove(devc->usbfd[i]);
|
||||
g_free(devc->usbfd);
|
||||
+#endif
|
||||
}
|
||||
|
||||
static void report_overflow(struct DSL_context *devc)
|
||||
@@ -1486,6 +1491,13 @@
|
||||
}
|
||||
|
||||
/* setup callback function for data transfer */
|
||||
+#ifdef _WIN32
|
||||
+ /* libusb_get_pollfds() always returns NULL on Windows. The acquisition
|
||||
+ * thread pumps libusb via receive_data on a bounded timer instead. */
|
||||
+ ret = sr_source_add(-1, 0, 10, receive_data, sdi);
|
||||
+ if (ret != SR_OK)
|
||||
+ return ret;
|
||||
+#else
|
||||
lupfd = libusb_get_pollfds(drvc->sr_ctx->libusb_ctx);
|
||||
for (i = 0; lupfd[i]; i++);
|
||||
|
||||
@@ -1500,7 +1512,8 @@
|
||||
devc->usbfd[i] = lupfd[i]->fd;
|
||||
}
|
||||
devc->usbfd[i] = -1;
|
||||
- g_free(lupfd);
|
||||
+ libusb_free_pollfds(lupfd);
|
||||
+#endif
|
||||
|
||||
wr_cmd.header.dest = DSL_CTL_START;
|
||||
wr_cmd.header.size = 0;
|
||||
--- a/libsigrok4DSL/hardware/DSL/dscope.c
|
||||
+++ b/libsigrok4DSL/hardware/DSL/dscope.c
|
||||
@@ -1901,10 +1901,15 @@
|
||||
{
|
||||
int i;
|
||||
sr_info("%s: remove fds from polling", __func__);
|
||||
+#ifdef _WIN32
|
||||
+ /* Windows libusb has no poll descriptors; remove the timer source. */
|
||||
+ sr_source_remove(-1);
|
||||
+#else
|
||||
/* Remove fds from polling. */
|
||||
for (i = 0; devc->usbfd[i] != -1; i++)
|
||||
sr_source_remove(devc->usbfd[i]);
|
||||
g_free(devc->usbfd);
|
||||
+#endif
|
||||
}
|
||||
|
||||
static int receive_data(int fd, int revents, const struct sr_dev_inst *sdi)
|
||||
@@ -2088,6 +2093,13 @@
|
||||
}
|
||||
|
||||
/* setup callback function for data transfer */
|
||||
+#ifdef _WIN32
|
||||
+ /* libusb_get_pollfds() always returns NULL on Windows. The acquisition
|
||||
+ * thread pumps libusb via receive_data on a bounded timer instead. */
|
||||
+ ret = sr_source_add(-1, 0, 10, receive_data, sdi);
|
||||
+ if (ret != SR_OK)
|
||||
+ return ret;
|
||||
+#else
|
||||
lupfd = libusb_get_pollfds(drvc->sr_ctx->libusb_ctx);
|
||||
for (i = 0; lupfd[i]; i++);
|
||||
|
||||
@@ -2103,7 +2115,8 @@
|
||||
}
|
||||
|
||||
devc->usbfd[i] = -1;
|
||||
- g_free(lupfd);
|
||||
+ libusb_free_pollfds(lupfd);
|
||||
+#endif
|
||||
|
||||
wr_cmd.header.dest = DSL_CTL_START;
|
||||
wr_cmd.header.size = 0;
|
||||
--- a/libsigrok4DSL/session.c
|
||||
+++ b/libsigrok4DSL/session.c
|
||||
@@ -207,7 +207,8 @@
|
||||
sr_dbg("Running...");
|
||||
|
||||
/* Do we have real sources? */
|
||||
- if (session->num_sources == 1 && session->pollfds[0].fd == -1) {
|
||||
+ if (session->num_sources == 1 && session->pollfds[0].fd == -1
|
||||
+ && session->sources[0].timeout == 0) {
|
||||
/* Dummy source, freewheel over it. */
|
||||
while (session->num_sources) {
|
||||
if (session->abort_session) {
|
||||
Reference in New Issue
Block a user