app/rcc/ai/skills/project_basics.md
A Serial Studio project is the bundle of configuration the dashboard needs to interpret data from one or more devices.
sourceId = 0 is the default.Project
├── sources[] (one or more connected devices)
│ └── frameParser (per-source JS or Lua script)
├── groups[] (visualization bundles)
│ └── datasets[] (per-channel data)
│ └── transformCode (optional, runs after parse)
├── actions[] (toolbar buttons)
├── workspaces[] (dashboard tabs; pin widgets here)
└── tables[] (data-bus registers; central state)
project.batch existsIf the user's task involves editing more than ~5 datasets/groups/widgets,
or creating an array of similar datasets, scroll to the "Bulk edits"
section now. project.batch and project.dataset.addMany are the
canonical answers, and using them up-front saves an entire conversation
turn over discovering them after a 30-call sequence stalls. Before the
first use, call meta.describeCommand{name: "project.batch"}. That
call keeps you from inventing parameter names.
The model's most-used reads:
project.snapshot // PREFERRED for broad context: sources, groups +
// datasets, workspaces summary, data tables summary
// -- one round trip. Pass verbose=true for parser
// code + source-level frame settings.
project.getStatus // top-level: title, modified, mode, counts
project.group.list // every group + datasetSummary + compatibleWidgetTypes
project.dataset.list // every dataset across all groups
project.source.list // every source with bus + parser info
project.workspace.list // every dashboard tab
project.dataTable.list // every user-defined data table
project.validate // semantic consistency check
meta.snapshot // composite across ALL subsystems (io + dashboard +
// project); broader than project.snapshot
For a SPECIFIC dataset, prefer the resolvers over walking the tree:
project.dataset.getByPath { path: "Group/Dataset" }
project.dataset.getByPath { path: "Source/Group/Dataset" }
project.dataset.getByTitle { title: "...", groupId?, sourceId? }
project.dataset.getByUniqueId { uniqueId: ... }
group.list is denser than dataset.list: it shows groups and a
summary of each dataset (title, units, uniqueId, etc.). Use it when
you need group-level metadata; use project.snapshot when you need
the whole picture in one shot.
sourceId, groupId, datasetId, actionId, widgetId,
workspaceId: integer ids assigned on creation. Stable until a
reorder/move/duplicate/delete. project.dataset.move /
project.group.move renumber adjacent items; cache nothing across
those calls.uniqueId (on datasets): OPAQUE runtime handle used by
datasetGetRaw/datasetGetFinal and the system __datasets__ table.
Read it from project.dataset.getByPath, project.dataset.list, or
project.snapshot. Don't compute it. It's allocated from a
persisted project-level counter at creation and stays stable across
reorders and moves; never derive it arithmetically.index (on datasets): the position in the parser's output array.
1-based. The user sets this; the parser's parse(frame)[i] maps to
the dataset whose index is i + 1. Patchable via
project.dataset.update {index: N}. Bulk renumbering (e.g. compacting
40 datasets to indexes 1..40) should go through project.batch to
avoid per-call autosave restarts.The dashboard has three operation modes (AppState::operationMode):
.ssproj
file, full editor + dashboard.Mode is sticky (persisted to QSettings). Switch with
dashboard.setOperationMode{mode}. project.open auto-switches to
ProjectFile.
Successful mutating AI tool calls schedule a debounced save (~1s) to the
project's existing file path. The assistant runtime skips autosave only
for read-only Safe tools, meta tools, and explicit project lifecycle
commands such as project.save, project.new, and project.open.
You don't need to drive normal autosave manually.
project.save{} after every edit; it's redundant.project.save{filePath: "..."} when the user wants Save As.project.batch / project.dataset.addMany.This is the #1 thing LLMs miss in this codebase. Read this section even if you skip the rest. The triggers below are not suggestions. Treat them as required pre-flight checks.
Before you issue a 2nd similar mutation in the same turn, ask: "is the 3rd one going to look like this too?" If yes, batch from the start.
project.batch.project.dataset.addMany. Do NOT loop project.dataset.add.index fields -> project.batch.project.batch.project.batch.40 sequential project.dataset.update calls is 40 round-trips AND 40
autosave-debounce restarts. The batch endpoints collapse that into ONE
suspended-autosave window with one final save.
project.batch: exact shapeThe parameter is ops (NOT commands, NOT mutations, NOT requests).
Each op is {command: string, params: object}. Both fields are
required. If you only have {command, ...} with params spread at the
top level, the op is rejected.
{
"ops": [
{
"command": "project.dataset.update",
"params": { "groupId": 0, "datasetId": 0, "title": "LED 1", "index": 1 }
},
{
"command": "project.dataset.update",
"params": { "groupId": 0, "datasetId": 1, "title": "LED 2", "index": 2 }
}
],
"stopOnError": false
}
Returns:
{
"results": [
{ "index": 0, "command": "...", "success": true, "result": {...} },
{ "index": 1, "command": "...", "success": false, "error": {...} }
],
"total": 2, "succeeded": 1, "failed": 1, "aborted": false
}
project.dataset.addMany: exact shape{
"groupId": 0,
"count": 40,
"options": 32,
"titlePattern": "LED {n}",
"startNumber": 1,
"startIndex": 1
}
options accepts the bitfield (1=Plot, 2=FFT, 4=Bar, 8=Gauge, 16=Compass,
32=LED, 64=Waterfall, 128=Meter) or an array of slugs (["plot","fft"]).
titlePattern substitutes {n} with startNumber + i and {i} with
the 0-based loop counter. Returns {count, created: [{groupId, datasetId, title, index, uniqueId}, ...]}. Capture those datasetIds
before the next call if you need to keep editing them.
stopOnError: true aborts on first failure but does not
undo. Treat as a save-suspend wrapper, not a database transaction.command: "project.batch" inside ops is an
immediate error.project.batch call. Split larger workloads.project.save afterwards.result.warnings on every per-op result. unknown_field
warnings mean the misnamed keys were silently dropped. See
api_semantics for the unknown-field protocol.If you would otherwise issue more than ~5 sequential mutations in a
turn: STOP. Open a project.batch instead. The latency and
turn-budget savings are dramatic.
A few things you might look for under project.* live under other
scopes because they're dashboard-wide preferences, not project state:
| Setting | Command |
|---|---|
| Plot time range (visible window per series) | dashboard.setTimeRange{seconds} (alias: project.dashboard.setTimeRange) |
| Theme | No JSON-API command; user-set in Settings (or the --theme CLI flag) |
| Operation mode | dashboard.setOperationMode |
| Console terminal display | console.set* |
If the user asks for "more history on the plot" or "a longer plot
window", call dashboard.setTimeRange (or its project.dashboard.setTimeRange
alias; they delegate to the same handler). The seconds value is
saved per-project and restored on load.
These are the pairs and triples that LLMs (and humans) routinely conflate. Memorize them once.
| Term | Range | Set by | Used for |
|---|---|---|---|
datasetId | per-group, 0..N | API auto | CRUD: dataset.update / delete / setOptions |
index | 1-based int | User | Position in parse(frame) output array |
uniqueId | global int | Allocated, persisted | OPAQUE stable handle for datasetGetRaw / Final, xAxisId, workspace refs |
Treat uniqueId as opaque. It's allocated once from the project's
nextUniqueId counter when a dataset or group is created, duplicated,
or imported, and then persisted in the project JSON. Reordering,
renaming, retyping, or moving a dataset between sources does NOT
change it, so references like xAxisId and workspace
WidgetRef.groupId survive reorders.
The legacy sourceId*1_000_000 + groupId*10_000 + datasetId formula
is only used as a one-shot back-fill when loading projects from before
this scheme. Don't compute it; read it from
assistant.dataset.resolve, project.dataset.getByPath {path: "Group/Dataset"}, project.dataset.getByTitle, or
project.snapshot. Duplicates always get a fresh uniqueId.
Workspace IDs live in a separate range, always >= 1000.
A single dataset can carry three independent min/max pairs, each driving a different surface. They do NOT cascade: setting one does not affect any other. Always define the pair(s) for every visualization you enable on a dataset, otherwise the matching widget renders against a default 0/0 range and looks empty / collapsed / max'd out.
| Range pair (file/response key) | API write-form (dataset.update) | Drives |
|---|---|---|
plotMin / plotMax | pltMin / pltMax | Y-axis on Plot / MultiPlot |
widgetMin / widgetMax | wgtMin / wgtMax | Gauge dial, Bar fill, Compass dial, Meter arc (Compass is fixed 0–360 and ignores these). Bar/Gauge/Meter also use these to size the digital-page value box on the two-page swipe view. |
fftMin / fftMax | fftMin / fftMax | Expected raw input range, used to normalize the time-domain signal to [-1, +1] before windowing + FFT. NOT a dB axis (the dB Y-axis on FFT/Waterfall widgets is fixed). |
Naming asymmetry (almost-silent footgun). Project files (.ssproj)
and API responses (project.dataset.getByPath, project.snapshot) use
the FULL form plotMin / widgetMin. The project.dataset.update API
accepts ONLY the abbreviated form pltMin / wgtMin. Round-tripping a
response field name into an update call writes nothing and returns
success, but the response now carries result.warnings with a
code: "unknown_field" entry listing the dropped keys. Read it.
A success without warnings means the update applied; a success WITH an
unknown_field warning means the misnamed parts were skipped. After
any write, also verify via dataset.getByPath that the response shows
the new values under the full key names. fftMin / fftMax are the
same in both directions.
A gauge that runs 0–360 (wgtMin/wgtMax) might still want the
underlying plot Y-axis at −50…+50 (pltMin/pltMax): set both. See
the dashboard_layout skill for which widget needs which pair, the
verify-after-update rule, and full recipes.
displayFormat vs decimalPointsTwo dataset fields shape how a numeric value is rendered — neither changes the stored value or what transforms compute. They overlap and are easy to confuse. Both use their FULL key name on read AND write (no abbreviation footgun like min/max).
| Field | Type / default | Accepts | Affects |
|---|---|---|---|
displayFormat | string, "0d" | "auto", "0d".."3d", "sci", printf "%.Nf" / "%.Ne" | Tick labels and the digital value on analog widgets (Bar/Gauge/Meter). Ignored by the DataGrid. |
decimalPoints | int, -1 | -1 (auto) or 0..15 fixed places | The DataGrid value column; and when >= 0 it overrides displayFormat on the Bar/Gauge/Meter value and tick labels too. |
displayFormat is the richer tick/label notation control: precision
or notation, scoped to the analog widgets. "auto" defers to the
dashboard's range-driven formatter; "0d".."3d" force 0–3 fixed
places; "sci" is scientific with 2 places; printf "%.Nf" / "%.Ne"
take an explicit precision. The DataGrid does not read it.
decimalPoints is the plain fixed-decimal-places override. Its
primary home is the DataGrid value column (-1 = auto, range-driven).
But when set >= 0 it also wins over displayFormat on the analog
widgets — exact fixed places on the digital readout, the same places
with trailing zeros trimmed on tick labels. -1 hands formatting back
to displayFormat (analog) / the range-driven default (DataGrid).
The difference in one line: displayFormat = how analog ticks and
value are styled (supports scientific/printf, DataGrid ignores it);
decimalPoints = a fixed-places override that drives the DataGrid
column and, when set, supersedes displayFormat everywhere. So: a
gauge whose tick labels should read 1.5k-style auto but whose big
number wants exactly 3 places → leave displayFormat: "auto" and set
decimalPoints: 3. A DataGrid column that wants 2 places → set
decimalPoints: 2 (displayFormat would do nothing there).
Both are settable via project.dataset.update and read back under the
same names; verify with dataset.getByPath after writing.
frameDetection field)0 = EndDelimiterOnly most common; e.g. line-based `\n`
1 = StartAndEndDelimiter bracketed frames; e.g. `$...;`
2 = NoDelimiters driver chunk = frame; for COBS / protobuf /
fixed-size or self-framing binary
3 = StartDelimiterOnly sync word at the head; next sync ends the frame
When the required delimiter for the chosen mode is empty, extraction
silently downgrades to NoDelimiters. Setting frameStart: "" while
leaving frameDetection at 1 or 3 is almost always wrong; flip
detection too.
decoderMethod / decoder field)0 = PlainText UTF-8 text frames -- parse() receives QString::fromUtf8(bytes)
1 = Hexadecimal hex-encoded string of the bytes
2 = Base64 base64-encoded string of the bytes
3 = Binary raw bytes -- 1-indexed table in Lua, length-keyed in JS
Picking PlainText (0) for a binary protocol replaces every non-UTF-8
byte with U+FFFD and loses the original. COBS, Modbus, protobuf,
custom binary: ALL need decoderMethod: 3. For ASCII / NMEA /
CSV / AT-commands keep decoderMethod: 0. Hex and Base64 are
narrow choices for devices that already emit encoded payloads.
See the frame_parsers skill for the full pipeline picture and the
project.frameParser.dryRun shape that lets you exercise extraction
| Kind | Lifetime | Writable at runtime? | Use for |
|---|---|---|---|
| Constant | Whole session | NO (project-static; tableSet no-ops) | Calibration coefficients, thresholds, gains |
| Computed | Whole session | YES via tableSet | Filter/integrator state, latched flags, cross-frame totals |
Computed registers hold the last value written indefinitely; there
is no per-frame reset. The defaultValue is the starting value at
project load only. If you want a Computed register to start each frame
at a known value, write that value yourself with tableSet at the top
of an early transform. For per-dataset state isolated from other
datasets, a top-level upvalue in the transform script is still the
lightest option (see the transforms skill).
project.dataTable.get { name } returns each register's type field
("Constant" or "Computed") along with its current value. Read it
when you need to know the kind without inspecting the raw project
JSON.
A virtual dataset has no slot in the parser's output array; its value
comes entirely from its transformCode (typically reading peers via
datasetGetRaw / datasetGetFinal / tableGet). Set virtual: true
on creation, OR write a transform whose body never references value
(the save path auto-flags those as virtual). Explicit setting is
still recommended for clarity.
project.dataset.move { uniqueId, newPosition } and
project.group.move { groupId, newPosition } reorder in place.
Workspace refs re-anchor automatically. A move renumbers datasetId /
groupId only; uniqueIds survive reorders. Read fresh positional
ids from the response or a follow-up snapshot before issuing more
datasetId/groupId-keyed calls.
Every .ssproj file carries three root-level keys stamped by Serial
Studio at save time:
| Key | Meaning |
|---|---|
schemaVersion | Project file format version (currently 1) |
writerVersion | Serial Studio version that wrote this file |
writerVersionAtCreation | Serial Studio version that originally created it |
Use project.getStatus to see them on the loaded project. Older
Serial Studio versions ignore unknown keys, so a 3.4 project still
loads in 3.2 with any 3.4-only fields silently dropped.
Don't conflate these; they live in different namespaces:
| Where you see it | Type |
|---|---|
project.group.add{widgetType} | GroupWidget enum (group SHAPE, e.g. DataGrid, MultiPlot) |
project.dataset.options bit | DatasetOption bitflag (per-dataset visualisation) |
project.workspace.addWidget{widgetType} | DashboardWidget enum (the rendered tile) |
dashboard_layout skill has the full mapping; api_semantics has the
identity rules.
For typed projects (IMU, GPS, scope, telemetry, MQTT subscriber), prefer
project.template.apply{templateId} over building from scratch. List
the catalog with project.template.list. After applying, narrate what
landed and offer to customize.