Compare commits
81
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9a2afedf | ||
|
|
4e45ec962e | ||
|
|
879a5c62c7 | ||
|
|
fcf2a52717 | ||
|
|
ab3f7f3223 | ||
|
|
859a04a61b | ||
|
|
9b19cbac05 | ||
|
|
a4ab067431 | ||
|
|
e4f4d1f9e7 | ||
|
|
43d485dd76 | ||
|
|
d878c81ce8 | ||
|
|
8a0c7592cc | ||
|
|
882ca0a216 | ||
|
|
5d1c4f15b7 | ||
|
|
745b53698a | ||
|
|
b938b63e8a | ||
|
|
b76e5e6ba4 | ||
|
|
bbcc35e209 | ||
|
|
bede05356d | ||
|
|
031e73bbe6 | ||
|
|
9ea1f0065a | ||
|
|
8267bb22bd | ||
|
|
07a87a8254 | ||
|
|
eb719f6fca | ||
|
|
02081dc85d | ||
|
|
ed52e3019d | ||
|
|
c2bcec93d0 | ||
|
|
8bb5364d8c | ||
|
|
83c7e4ede3 | ||
|
|
a14f7afe87 | ||
|
|
1490daa7ca | ||
|
|
a4001d71d5 | ||
|
|
6da9f5f20e | ||
|
|
ab882c5619 | ||
|
|
2541db2bef | ||
|
|
f875518b28 | ||
|
|
334ad9b313 | ||
|
|
0953706e9e | ||
|
|
309d51a3e8 | ||
|
|
93f87c57b9 | ||
|
|
94ef2ae451 | ||
|
|
abef0d85a2 | ||
|
|
6b3f975ba5 | ||
|
|
e028663658 | ||
|
|
bf68b9e597 | ||
|
|
a9b98f47e9 | ||
|
|
90a3ac5938 | ||
|
|
38f15db1d0 | ||
|
|
da821ec663 | ||
|
|
124bffad84 | ||
|
|
f98abe00f3 | ||
|
|
56a33a07f7 | ||
|
|
ce80433e43 | ||
|
|
3d98622b96 | ||
|
|
d3691258d3 | ||
|
|
360c54f1f9 | ||
|
|
8c4900a52f | ||
|
|
bfb833982e | ||
|
|
1410f170f9 | ||
|
|
14e998e6c3 | ||
|
|
57f678240d | ||
|
|
99df0a6fe9 | ||
|
|
ce7444c1a1 | ||
|
|
e3ae0a6ab5 | ||
|
|
733430ed45 | ||
|
|
cd2466692f | ||
|
|
4b424f0234 | ||
|
|
a212d2e84f | ||
|
|
5154e81d0b | ||
|
|
5c1b6b2a23 | ||
|
|
de345939af | ||
|
|
ef51c7ec11 | ||
|
|
3e873c51b7 | ||
|
|
f86cb7781e | ||
|
|
fe23dcfa3a | ||
|
|
2d6f2ba1a4 | ||
|
|
5d55353939 | ||
|
|
c3a46d4d85 | ||
|
|
0e0e17928b | ||
|
|
a522973fe7 | ||
|
|
cbfa8d7ffd |
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "fleetsuite-ui",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"--prefix",
|
||||
"mcp-server/web-ui",
|
||||
"run",
|
||||
"dev",
|
||||
"--",
|
||||
"--port",
|
||||
"5199",
|
||||
"--strictPort"
|
||||
],
|
||||
"port": 5199
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "f=$(tr -d '\\n' | grep -o '\"file_path\"[[:space:]]*:[[:space:]]*\"[^\"]*\"' | head -1 | sed 's/.*:[[:space:]]*\"//; s/\"$//'); [ -n \"$f\" ] && [ -f \"$f\" ] || exit 0; t=$(command -v trunk || echo \"$HOME/.cache/trunk/launcher/trunk\"); [ -x \"$t\" ] || { echo \"trunk-fmt hook: trunk not found; its launcher needs curl or wget to bootstrap the CLI (see 'Formatting & the trunk toolchain' in .github/copilot-instructions.md)\" >&2; exit 1; }; out=$(\"$t\" fmt --force \"$f\" 2>&1) || { echo \"trunk-fmt hook: trunk fmt failed on $f: $out\" >&2; exit 1; }",
|
||||
"timeout": 120,
|
||||
"statusMessage": "Formatting (trunk)..."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
|
||||
# trunk-ignore(hadolint/DL3008): apt packages are not pinned.
|
||||
# trunk-ignore(terrascan/AC_DOCKER_0002): apt packages are not pinned.
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
cmake git zip libgpiod-dev libbluetooth-dev libi2c-dev \
|
||||
cmake git zip libgpiod-dev libjsoncpp-dev libbluetooth-dev libi2c-dev \
|
||||
libunistring-dev libmicrohttpd-dev libgnutls28-dev libgcrypt20-dev \
|
||||
libusb-1.0-0-dev libssl-dev pkg-config libsqlite3-dev libsdl2-dev && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
||||
|
||||
@@ -31,7 +31,7 @@ cmake --install "$WORK/ulfius/$SANITIZER" --prefix /usr
|
||||
cd "$SRC/firmware"
|
||||
|
||||
PLATFORMIO_EXTRA_SCRIPTS=$(echo -e "pre:.clusterfuzzlite/platformio-clusterfuzzlite-pre.py\npost:.clusterfuzzlite/platformio-clusterfuzzlite-post.py")
|
||||
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp bluez --silence-errors)
|
||||
STATIC_LIBS=$(pkg-config --libs --static libulfius openssl libgpiod yaml-cpp jsoncpp bluez --silence-errors)
|
||||
export PLATFORMIO_EXTRA_SCRIPTS
|
||||
export STATIC_LIBS
|
||||
export PLATFORMIO_WORKSPACE_DIR="$WORK/pio/$SANITIZER"
|
||||
|
||||
@@ -16,6 +16,7 @@ RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
libssl-dev \
|
||||
libulfius-dev \
|
||||
libyaml-cpp-dev \
|
||||
libjsoncpp-dev \
|
||||
pipx \
|
||||
pkg-config \
|
||||
python3 \
|
||||
|
||||
@@ -13,7 +13,7 @@ runs:
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get -y update --fix-missing
|
||||
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev lsb-release
|
||||
sudo apt-get install -y cppcheck libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev lsb-release
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
|
||||
@@ -11,4 +11,4 @@ runs:
|
||||
- name: Install libs needed for native build
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
sudo apt-get install -y libbluetooth-dev libgpiod-dev libyaml-cpp-dev libjsoncpp-dev openssl libssl-dev libulfius-dev liborcania-dev libusb-1.0-0-dev libi2c-dev libuv1-dev
|
||||
|
||||
@@ -283,6 +283,15 @@ firmware/
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### Formatting & the trunk toolchain
|
||||
|
||||
`trunk fmt` is the project formatter (`trunk_check` CI rejects unformatted code). For Claude Code users, `.claude/settings.json` ships a PostToolUse hook that runs `trunk fmt --force` on every file the agent writes or edits. The hook is pure sh/grep/sed — no python or jq required — but trunk itself must be able to run:
|
||||
|
||||
- Trunk's launcher (`~/.cache/trunk/launcher/trunk`, or `trunk` on PATH) downloads the CLI version pinned in `.trunk/trunk.yaml` on first use and again whenever that pin is bumped. **The launcher needs `curl` or `wget`**; without one it fails with "Cannot download… please install curl or wget", and the hook surfaces that as a warning on every write.
|
||||
- No curl/wget available (e.g. a minimal WSL image)? Bootstrap by hand with any Python (PlatformIO bundles one at `~/.platformio/penv/bin/python`): download `https://trunk.io/releases/<ver>/trunk-<ver>-linux-x86_64.tar.gz` and place the `trunk` binary at `~/.cache/trunk/cli/<ver>-linux-x86_64/trunk` (chmod +x), where `<ver>` is the `cli.version` from `.trunk/trunk.yaml`.
|
||||
- The hook fails loudly by design (visible warning, non-blocking). Silent no-op formatting hooks hide real breakage — don't re-add `2>/dev/null || true` around the whole thing.
|
||||
- More generally: don't assume a stock Linux userland in hooks or helper scripts — minimal WSL/container images may lack `python3`, `curl`, `wget`, and `jq`. Prefer plain sh + coreutils, or PlatformIO's bundled Python for anything heavier.
|
||||
|
||||
### General Style
|
||||
|
||||
- Follow existing code style - run `trunk fmt` before commits
|
||||
@@ -609,20 +618,35 @@ Most workflows can be triggered manually via `workflow_dispatch` for testing.
|
||||
|
||||
### Native unit tests (C++)
|
||||
|
||||
Unit tests in `test/` directory with 12 test suites:
|
||||
Unit tests in `test/` directory with 17 test suites:
|
||||
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_admin_radio/` - LoRa region/config validation and AdminModule dispatch
|
||||
- `test_atak/` - ATAK integration
|
||||
- `test_crypto/` - Cryptography
|
||||
- `test_default/` - Default configuration
|
||||
- `test_http_content_handler/` - HTTP handling
|
||||
- `test_mac_from_string/` - MAC address parsing
|
||||
- `test_mesh_module/` - Module framework
|
||||
- `test_meshpacket_serializer/` - Packet serialization
|
||||
- `test_mqtt/` - MQTT integration
|
||||
- `test_packet_history/` - Packet history tracking
|
||||
- `test_position_precision/` - Position precision helpers
|
||||
- `test_radio/` - Radio interface
|
||||
- `test_serial/` - Serial communication
|
||||
- `test_traffic_management/` - Traffic management
|
||||
- `test_transmit_history/` - Retransmission tracking
|
||||
- `test_type_conversions/` - NodeDB v25 type conversion (bitfield round-trips, NodeInfoLite)
|
||||
- `test_utf8/` - UTF-8 utilities
|
||||
|
||||
Run with: `pio test -e native`
|
||||
Run command (preferred — avoids pipe-buffering and the Ubuntu externally-managed-environment error):
|
||||
|
||||
```bash
|
||||
~/.platformio/penv/bin/python -m platformio test -e native -f test_your_suite > /tmp/test_out.txt 2>&1
|
||||
grep -E 'error:|PASS|FAIL|succeeded|failed' /tmp/test_out.txt
|
||||
tail -15 /tmp/test_out.txt
|
||||
```
|
||||
|
||||
Do **not** use `pio test … | tail -N` — it discards build errors and shows stale cached results. Do **not** use `pio test … | grep` — line-buffering makes the terminal appear hung until the process exits. Redirect to a file first, then grep.
|
||||
|
||||
Simulation testing: `bin/test-simulator.sh`
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Post Firmware Size Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-size-comment:
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download size report
|
||||
id: download
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
name: size-report
|
||||
path: ./
|
||||
|
||||
- name: Post or update PR comment
|
||||
if: steps.download.outcome == 'success'
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const marker = '<!-- firmware-size-report -->';
|
||||
const body = fs.readFileSync('./size-report.md', 'utf8');
|
||||
const prNumber = parseInt(fs.readFileSync('./pr-number.txt', 'utf8').trim(), 10);
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
});
|
||||
|
||||
const existing = comments.find(c => c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
name: Post Web Flasher Link Comment
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
post-flasher-link:
|
||||
if: >
|
||||
github.event.workflow_run.event == 'pull_request' &&
|
||||
github.event.workflow_run.conclusion != 'cancelled' &&
|
||||
github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Per-board manifests carry the firmware's own metadata (activelySupported,
|
||||
# displayName, ...) generated from each target's custom_meshtastic_* config.
|
||||
- name: Download board manifests
|
||||
uses: actions/download-artifact@v8
|
||||
continue-on-error: true
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
pattern: manifest-*
|
||||
path: ./manifests
|
||||
merge-multiple: true
|
||||
|
||||
- name: Post or update web flasher link comment
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const run = context.payload.workflow_run;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Resolve the PR by matching the run's head SHA against the repo's open
|
||||
// PRs. workflow_run.pull_requests is empty for fork PRs, and
|
||||
// listPullRequestsAssociatedWithCommit won't return an open fork PR by
|
||||
// its head commit — but pulls.list includes fork PRs. Matching on head
|
||||
// SHA also enforces that the run is for the PR's current commit, so stale
|
||||
// re-runs of an outdated commit won't match.
|
||||
const openPrs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
const pr = openPrs.find((p) => p.head.sha === run.head_sha);
|
||||
if (!pr) {
|
||||
core.info(`No open pull request matches commit ${run.head_sha}; skipping.`);
|
||||
return;
|
||||
}
|
||||
const prNumber = pr.number;
|
||||
|
||||
// Restrict to trusted authors. NOTE: author_association is computed for
|
||||
// the GITHUB_TOKEN, which cannot see *private/concealed* org memberships —
|
||||
// those members come back as CONTRIBUTOR, not MEMBER. So gating on MEMBER
|
||||
// alone silently excludes most maintainers. We allow the trusted set the
|
||||
// token can actually identify (members, collaborators, and anyone with a
|
||||
// previously merged PR). For strict members-only you'd need an org-read
|
||||
// App/PAT token to call orgs.checkMembershipForUser.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require at least one per-arch firmware artifact from gather-artifacts
|
||||
const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
|
||||
owner, repo, run_id: run.id, per_page: 100,
|
||||
});
|
||||
const archRe = /^firmware-(esp32|esp32s3|esp32c3|esp32c6|nrf52840|rp2040|rp2350|stm32)-(\d+\.\d+\.\d+\.[0-9a-f]+)$/;
|
||||
const archArtifacts = artifacts.filter((a) => archRe.test(a.name) && !a.expired);
|
||||
if (archArtifacts.length === 0) {
|
||||
core.info('No per-arch firmware artifacts found; skipping.');
|
||||
return;
|
||||
}
|
||||
|
||||
const version = archRe.exec(archArtifacts[0].name)[2];
|
||||
const expiresAt = archArtifacts[0].expires_at
|
||||
? new Date(archArtifacts[0].expires_at).toISOString().slice(0, 10)
|
||||
: null;
|
||||
|
||||
// Read each built board's manifest (.mt.json). activelySupported,
|
||||
// displayName and architecture come straight from the board's
|
||||
// custom_meshtastic_* platformio config, so the list is in sync with
|
||||
// the firmware itself — no external device database needed.
|
||||
const fs = require('fs');
|
||||
let boards = [];
|
||||
try {
|
||||
boards = fs.readdirSync('./manifests')
|
||||
.filter((f) => f.endsWith('.mt.json'))
|
||||
.map((f) => {
|
||||
try { return JSON.parse(fs.readFileSync(`./manifests/${f}`, 'utf8')); }
|
||||
catch { return null; }
|
||||
})
|
||||
.filter((m) => m && m.activelySupported === true && m.platformioTarget)
|
||||
.map((m) => ({
|
||||
board: m.platformioTarget,
|
||||
platform: m.architecture || '',
|
||||
// displayName is maintainer-authored text; escape table-breaking pipes
|
||||
displayName: String(m.displayName || m.platformioTarget).replace(/\|/g, '\\|'),
|
||||
image: Array.isArray(m.images) && m.images[0] ? String(m.images[0]) : '',
|
||||
}))
|
||||
.sort((a, b) => a.board.localeCompare(b.board));
|
||||
} catch (e) {
|
||||
core.warning(`Could not read board manifests: ${e.message}`);
|
||||
}
|
||||
|
||||
const flasherUrl = `https://flasher.meshtastic.org/?pr=${prNumber}`;
|
||||
// Device illustrations are served by the flasher from the same image
|
||||
// names the manifest declares (custom_meshtastic_images). The flasher
|
||||
// serves its SPA shell (HTML, 200) for unknown paths, so confirm each
|
||||
// image really resolves to an image before linking it.
|
||||
const imageBase = 'https://flasher.meshtastic.org/img/devices/';
|
||||
await Promise.all(boards.map(async (b) => {
|
||||
if (!b.image) return;
|
||||
try {
|
||||
const res = await fetch(`${imageBase}${encodeURIComponent(b.image)}`);
|
||||
const type = res.headers.get('content-type') || '';
|
||||
if (!res.ok || !type.startsWith('image/')) b.image = '';
|
||||
} catch { b.image = ''; }
|
||||
}));
|
||||
|
||||
const boardLines = boards
|
||||
.map((b) => {
|
||||
const img = b.image ? `<img src="${imageBase}${encodeURIComponent(b.image)}" alt="" height="34">` : '';
|
||||
return `| ${img} | ${b.displayName} | [\`${b.board}\`](${flasherUrl}&device=${encodeURIComponent(b.board)}) | ${b.platform} |`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
// Shields.io badges. Only non-user-controlled, charset-constrained values
|
||||
// (version, commit sha, counts, dates) go into badge URLs — never board
|
||||
// names or the PR title — so the rendered comment cannot be spoofed.
|
||||
const shieldText = (s) =>
|
||||
encodeURIComponent(String(s).replace(/-/g, '--').replace(/_/g, '__').replace(/ /g, '_'));
|
||||
const shield = (label, message, color) =>
|
||||
`https://img.shields.io/badge/${shieldText(label)}-${shieldText(message)}-${color}`;
|
||||
const buttonUrl =
|
||||
`https://img.shields.io/badge/${shieldText('Flash this PR in the Web Flasher')}-2C2D3C?style=for-the-badge`;
|
||||
const badges = [
|
||||
`})`,
|
||||
`, '2C2D3C')})`,
|
||||
`})`,
|
||||
];
|
||||
if (expiresAt) badges.push(`})`);
|
||||
|
||||
// Only render the board table when there are supported boards to list
|
||||
const boardTable = boards.length > 0 ? [
|
||||
`<details><summary>Supported boards built by this PR (${boards.length})</summary>`,
|
||||
'',
|
||||
'| | Device | Board | Platform |',
|
||||
'| --- | --- | --- | --- |',
|
||||
boardLines,
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
] : [];
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
`[](${flasherUrl})`,
|
||||
'',
|
||||
badges.join(' '),
|
||||
'',
|
||||
'> [!WARNING]',
|
||||
'> This is an automated, unreviewed CI test build. Back up your device configuration',
|
||||
'> before flashing, and only flash devices you are able to recover.',
|
||||
'',
|
||||
...boardTable,
|
||||
`*Build artifacts expire${expiresAt ? ` on ${expiresAt}` : ' after 30 days'}. Updated for \`${run.head_sha.slice(0, 7)}\`.*`,
|
||||
].join('\n');
|
||||
|
||||
// Sticky comment: update in place when the marker is found
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: prNumber, per_page: 100,
|
||||
});
|
||||
const existing = comments.find((c) => c.body?.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
||||
} else {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: prNumber, body });
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
name: Post Web Flasher Build Placeholder
|
||||
|
||||
# Drops an immediate "build in progress" comment when a PR opens, so the web
|
||||
# flasher entry shows up right away. The real CI-driven workflow
|
||||
# (flasher-link-comment.yml) later replaces it in place via the shared marker.
|
||||
#
|
||||
# SECURITY: this uses pull_request_target (write token, runs for fork PRs) but is
|
||||
# safe because it never checks out or runs PR code and posts a fully static body
|
||||
# — no PR title, branch name, or other untrusted input is used anywhere.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
post-placeholder:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Post web flasher build-in-progress placeholder
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const marker = '<!-- web-flasher-link -->';
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
// Trusted authors only (matches the real workflow). author_association
|
||||
// can't reflect private org membership for the token, so concealed
|
||||
// members appear as CONTRIBUTOR — include it, or maintainers are excluded.
|
||||
const allowedAssociations = ['OWNER', 'MEMBER', 'COLLABORATOR', 'CONTRIBUTOR'];
|
||||
if (!allowedAssociations.includes(pr.author_association)) {
|
||||
core.info(`Author association ${pr.author_association} is not trusted; skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only seed a placeholder when no flasher comment exists yet — never
|
||||
// overwrite a real (or existing placeholder) comment.
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: pr.number, per_page: 100,
|
||||
});
|
||||
if (comments.some((c) => c.body?.includes(marker))) {
|
||||
core.info('Flasher comment already exists; nothing to do.');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = [
|
||||
marker,
|
||||
'## ⚡ Try this PR in the Web Flasher',
|
||||
'',
|
||||
'> [!NOTE]',
|
||||
'> Building this pull request… the flash button, badges and supported-board',
|
||||
'> list will appear here automatically once CI finishes.',
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number, body,
|
||||
});
|
||||
@@ -82,8 +82,9 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
check: ${{ fromJson(needs.setup.outputs.check) }}
|
||||
# Use 'arctastic' self-hosted runner pool when checking in the main repo
|
||||
runs-on: ${{ github.repository_owner == 'meshtastic' && 'arctastic' || 'ubuntu-latest' }}
|
||||
# Runs on GitHub-hosted runners so checks don't compete with builds for the
|
||||
# self-hosted 'arctastic' pool (which builds use).
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'meshtastic/firmware' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -245,47 +246,126 @@ jobs:
|
||||
path: ./*.elf
|
||||
retention-days: 30
|
||||
|
||||
shame:
|
||||
firmware-size-report:
|
||||
if: github.repository == 'meshtastic/firmware'
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
filter: blob:none # means we download all the git history but none of the commit (except ones with checkout like the head)
|
||||
fetch-depth: 0
|
||||
- name: Download the current manifests
|
||||
|
||||
- name: Download current manifests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: ./manifests-new/
|
||||
path: ./manifests/
|
||||
pattern: manifest-*
|
||||
merge-multiple: true
|
||||
- name: Upload combined manifests for later commit and global stats crunching.
|
||||
|
||||
- name: Collect current firmware sizes
|
||||
run: python3 bin/collect_sizes.py ./manifests/ ./current-sizes.json
|
||||
|
||||
- name: Upload size report artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
id: upload-manifest
|
||||
with:
|
||||
name: manifests-${{ github.sha }}
|
||||
name: firmware-sizes-${{ github.sha }}
|
||||
overwrite: true
|
||||
path: manifests-new/*.mt.json
|
||||
- name: Find the merge base
|
||||
path: ./current-sizes.json
|
||||
retention-days: 90
|
||||
|
||||
- name: Download baseline sizes from develop
|
||||
if: github.event_name == 'pull_request'
|
||||
run: echo "MERGE_BASE=$(git merge-base "origin/$base" "$head")" >> $GITHUB_ENV
|
||||
continue-on-error: true
|
||||
id: baseline-develop
|
||||
env:
|
||||
base: ${{ github.base_ref }}
|
||||
head: ${{ github.sha }}
|
||||
# Currently broken (for-loop through EVERY artifact -- rate limiting)
|
||||
# - name: Download the old manifests
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: gh run download -R "$repo" --name "manifests-$merge_base" --dir manifest-old/
|
||||
# env:
|
||||
# GH_TOKEN: ${{ github.token }}
|
||||
# merge_base: ${{ env.MERGE_BASE }}
|
||||
# repo: ${{ github.repository }}
|
||||
# - name: Do scan and post comment
|
||||
# if: github.event_name == 'pull_request'
|
||||
# run: python3 bin/shame.py ${{ github.event.pull_request.number }} manifests-old/ manifests-new/
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||
--workflow CI --branch develop --status success \
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
|
||||
if [ -n "$ARTIFACT_NAME" ]; then
|
||||
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||
--name "$ARTIFACT_NAME" --dir ./baseline-develop/
|
||||
cp "./baseline-develop/current-sizes.json" ./develop-sizes.json
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Download baseline sizes from master
|
||||
if: github.event_name == 'pull_request'
|
||||
continue-on-error: true
|
||||
id: baseline-master
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
RUN_ID=$(gh run list -R "${{ github.repository }}" \
|
||||
--workflow CI --branch master --status success \
|
||||
--limit 1 --json databaseId --jq '.[0].databaseId // empty')
|
||||
if [ -n "$RUN_ID" ]; then
|
||||
ARTIFACT_NAME=$(gh api "repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \
|
||||
--jq '.artifacts[] | select(.name | startswith("firmware-sizes-")) | select(.expired == false) | .name' | head -1)
|
||||
if [ -n "$ARTIFACT_NAME" ]; then
|
||||
gh run download "$RUN_ID" -R "${{ github.repository }}" \
|
||||
--name "$ARTIFACT_NAME" --dir ./baseline-master/
|
||||
cp "./baseline-master/current-sizes.json" ./master-sizes.json
|
||||
echo "found=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Generate size comparison report
|
||||
if: github.event_name == 'pull_request'
|
||||
id: report
|
||||
run: |
|
||||
ARGS="./current-sizes.json"
|
||||
if [ -f ./develop-sizes.json ]; then
|
||||
ARGS="$ARGS --baseline develop:./develop-sizes.json"
|
||||
fi
|
||||
if [ -f ./master-sizes.json ]; then
|
||||
ARGS="$ARGS --baseline master:./master-sizes.json"
|
||||
fi
|
||||
REPORT=$(python3 bin/size_report.py $ARGS)
|
||||
if [ -z "$REPORT" ]; then
|
||||
echo "has_report=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_report=true" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo '<!-- firmware-size-report -->'
|
||||
echo '# Firmware Size Report'
|
||||
echo ''
|
||||
echo "$REPORT"
|
||||
echo ''
|
||||
echo '---'
|
||||
echo "*Updated for ${{ github.sha }}*"
|
||||
} > ./size-report.md
|
||||
cat ./size-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Save PR number
|
||||
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||
run: echo "${{ github.event.pull_request.number }}" > ./pr-number.txt
|
||||
|
||||
- name: Upload size report
|
||||
if: github.event_name == 'pull_request' && steps.report.outputs.has_report == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: size-report
|
||||
path: |
|
||||
./size-report.md
|
||||
./pr-number.txt
|
||||
retention-days: 5
|
||||
|
||||
release-artifacts:
|
||||
permissions: # Needed for 'gh release upload'.
|
||||
|
||||
@@ -37,13 +37,33 @@ jobs:
|
||||
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
|
||||
|
||||
- name: Integration test
|
||||
# Cap the whole step: if the simulator ever fails to exit (e.g. the
|
||||
# exit_simulator admin path regresses again) the job must fail fast,
|
||||
# not run to GitHub's 6-hour limit.
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
.pio/build/coverage/meshtasticd -s &
|
||||
PID=$!
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
|
||||
echo "Simulator started, launching python test..."
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
wait
|
||||
# The Python harness sends exit_simulator and exits; the simulator is
|
||||
# expected to terminate on its own. Give it a moment, then verify.
|
||||
# If it is still alive the exit handshake is broken — fail loudly and
|
||||
# do NOT fall through to `wait`, which would otherwise block until the
|
||||
# job's hard timeout.
|
||||
for i in $(seq 1 10); do
|
||||
kill -0 "$PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
|
||||
kill -9 "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
wait "$PID" 2>/dev/null || true
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -141,6 +161,14 @@ jobs:
|
||||
name: platformio-test-report-${{ steps.version.outputs.long }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Drop no-status testsuites from the report
|
||||
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
|
||||
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
|
||||
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
|
||||
# them so the Test Report lists only suites with a real status. Only the copy the
|
||||
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
|
||||
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
|
||||
|
||||
- name: Test Report
|
||||
uses: dorny/test-reporter@v3.0.0
|
||||
with:
|
||||
|
||||
@@ -16,13 +16,18 @@ jobs:
|
||||
submodules: true
|
||||
|
||||
- name: Update submodule
|
||||
if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/develop' }}
|
||||
if: ${{ github.ref_name == 'master' || github.ref_name == 'develop' }}
|
||||
working-directory: protobufs
|
||||
env:
|
||||
# Use the branch that triggered the workflow as the protobuf branch.
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
git submodule update --remote protobufs
|
||||
git fetch --prune origin $GIT_BRANCH
|
||||
git checkout origin/$GIT_BRANCH
|
||||
|
||||
- name: Download nanopb
|
||||
run: |
|
||||
wget https://jpa.kapsi.fi/nanopb/download/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
wget https://github.com/nanopb/nanopb/releases/download/nanopb-0.4.9.1/nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
tar xvzf nanopb-0.4.9.1-linux-x86.tar.gz
|
||||
mv nanopb-0.4.9.1-linux-x86 nanopb-0.4.9
|
||||
|
||||
@@ -33,7 +38,7 @@ jobs:
|
||||
- name: Create pull request
|
||||
uses: peter-evans/create-pull-request@v8
|
||||
with:
|
||||
branch: create-pull-request/update-protobufs
|
||||
branch: create-pull-request/update-protobufs-${{ github.ref_name }}
|
||||
labels: submodules
|
||||
title: Update protobufs and classes
|
||||
commit-message: Update protobufs
|
||||
|
||||
+5
-5
@@ -1,8 +1,8 @@
|
||||
.pio
|
||||
pio
|
||||
pio.tar
|
||||
web
|
||||
web.tar
|
||||
/.pio
|
||||
/pio
|
||||
/pio.tar
|
||||
/web
|
||||
/web.tar
|
||||
|
||||
# ignore vscode IDE settings files
|
||||
.vscode/*
|
||||
|
||||
Vendored
+41
@@ -10,6 +10,47 @@
|
||||
"isDefault": true
|
||||
},
|
||||
"label": "PlatformIO: Build"
|
||||
},
|
||||
{
|
||||
"label": "FleetSuite",
|
||||
"detail": "Build the SPA if needed and open the FleetSuite desktop window",
|
||||
"type": "shell",
|
||||
"command": "./scripts/fleetsuite.sh",
|
||||
"options": { "cwd": "${workspaceFolder}/mcp-server" },
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
},
|
||||
"group": "none"
|
||||
},
|
||||
{
|
||||
"label": "FleetSuite: Dev (HMR)",
|
||||
"detail": "Run the backend + Vite dev server with hot-reload",
|
||||
"type": "shell",
|
||||
"command": "./scripts/fleetsuite.sh --dev",
|
||||
"options": { "cwd": "${workspaceFolder}/mcp-server" },
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
},
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "FleetSuite: Rebuild + Run",
|
||||
"detail": "Force a fresh SPA build, then open the window",
|
||||
"type": "shell",
|
||||
"command": "./scripts/fleetsuite.sh --rebuild",
|
||||
"options": { "cwd": "${workspaceFolder}/mcp-server" },
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "dedicated",
|
||||
"clear": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -10,18 +10,18 @@ This file (`AGENTS.md`) is a short pointer + quick reference for agents that don
|
||||
|
||||
## Quick command reference
|
||||
|
||||
| Action | Command |
|
||||
| -------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `pio test -e native` |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
| Action | Command |
|
||||
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Build a firmware variant | `pio run -e <env>` (e.g. `pio run -e rak4631`, `pio run -e heltec-v3`) |
|
||||
| Build native macOS host binary | `pio run -e native-macos` (Homebrew prereqs + CH341 LoRa setup in `variants/native/portduino/platformio.ini`) |
|
||||
| Clean + rebuild | `pio run -e <env> -t clean && pio run -e <env>` |
|
||||
| Flash a device | `pio run -e <env> -t upload --upload-port <port>` (or use the `pio_flash` MCP tool) |
|
||||
| Run firmware unit tests (native) | `~/.platformio/penv/bin/python -m platformio test -e native > /tmp/test_out.txt 2>&1` then `grep -E 'error:\|PASS\|FAIL\|succeeded\|failed' /tmp/test_out.txt` (redirect first — piping causes line-buffering) |
|
||||
| Run MCP hardware tests | `./mcp-server/run-tests.sh` |
|
||||
| Live TUI test runner | `mcp-server/.venv/bin/meshtastic-mcp-test-tui` |
|
||||
| Format before commit | `trunk fmt` |
|
||||
| Regenerate protobuf bindings | `bin/regen-protos.sh` |
|
||||
| Generate CI matrix | `./bin/generate_ci_matrix.py all [--level pr]` |
|
||||
|
||||
## MCP server (device + test automation)
|
||||
|
||||
@@ -64,7 +64,7 @@ Key rotation to never trigger casually: only the **full** factory reset (`factor
|
||||
- **One MCP call per serial port at a time.** The port lock is exclusive; concurrent calls deadlock. Sequence: open → read/mutate → close, then next device.
|
||||
- **`userPrefs.jsonc` is session state during tests.** The `_session_userprefs` fixture snapshots + restores it; never edit it from inside a test.
|
||||
- **Don't speculate about firmware root causes.** When evidence doesn't support a classification, say "unknown" and list what would disambiguate.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code.
|
||||
- **Run `trunk fmt` before proposing a commit.** The `trunk_check` CI gate will reject unformatted code. Claude Code runs it automatically via the PostToolUse hook in `.claude/settings.json`; trunk's launcher needs `curl` or `wget` to bootstrap its pinned CLI — see **Formatting & the trunk toolchain** in `.github/copilot-instructions.md` for the no-curl bootstrap procedure.
|
||||
- **`confirm=True` on destructive MCP tools is a real gate, not a formality.** Don't bypass it via auto-approve settings.
|
||||
- **Keep code comments minimal — one or two lines, max.** Comment only when the _why_ isn't obvious from the code; never restate what the next line does. No multi-paragraph block comments explaining straightforward changes. The diff and commit message carry the rationale; the code carries the behavior.
|
||||
- **Use `Throttle` for time-based rate limiting, not raw `millis()` math.** `src/mesh/Throttle.h` provides `Throttle::isWithinTimespanMs(lastMs, intervalMs)` (returns true while inside the cooldown) and `Throttle::execute(&lastMs, intervalMs, func)` (function-pointer form that updates the timestamp on fire). Use these for any "did N ms pass since X" check — raw `millis() > lastMs + N` is rollover-unsafe (breaks after ~49.7 days) and inconsistent with the rest of the codebase. The helpers compute `now - lastMs` with unsigned subtraction, which wraps correctly.
|
||||
@@ -108,7 +108,7 @@ Sequence these; don't parallelize on the same port.
|
||||
| `src/modules/` | Feature modules; `Telemetry/Sensor/` has 50+ I2C sensor drivers |
|
||||
| `variants/` | 200+ hardware variant definitions (`variant.h` + `platformio.ini` per board) |
|
||||
| `protobufs/` | `.proto` definitions; regenerate with `bin/regen-protos.sh` |
|
||||
| `test/` | Firmware unit tests (12 suites; `pio test -e native`) |
|
||||
| `test/` | Firmware unit tests (17 suites; `pio test -e native`) |
|
||||
| `mcp-server/` | Python MCP server + pytest hardware integration tests |
|
||||
| `mcp-server/tests/` | Tiered pytest suite: `unit/`, `mesh/`, `telemetry/`, `monitor/`, `recovery/`, `ui/`, `fleet/`, `admin/`, `provisioning/` |
|
||||
| `.claude/commands/` | Claude Code slash command bodies |
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
curl wget g++ zip git ca-certificates pkg-config \
|
||||
python3-pip python3-grpc-tools \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
@@ -53,7 +53,7 @@ ENV TZ=Etc/UTC
|
||||
USER root
|
||||
|
||||
RUN apt-get update && apt-get --no-install-recommends -y install \
|
||||
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libi2c0 libuv1t64 libusb-1.0-0-dev \
|
||||
libc-bin libc6 libgpiod3 libyaml-cpp0.8 libjsoncpp26 libi2c0 libuv1t64 libusb-1.0-0-dev \
|
||||
liborcania2.3 libulfius2.7t64 libssl3t64 \
|
||||
libx11-6 libinput10 libxkbcommon-x11-0 libsdl2-2.0-0 \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ ENV PIP_ROOT_USER_ACTION=ignore
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install --no-install-recommends -y \
|
||||
g++ git ca-certificates pkg-config \
|
||||
libgpiod-dev libyaml-cpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libgpiod-dev libyaml-cpp-dev libjsoncpp-dev libbluetooth-dev libi2c-dev libuv1-dev \
|
||||
libusb-1.0-0-dev libulfius-dev liborcania-dev libssl-dev \
|
||||
libx11-dev libinput-dev libxkbcommon-x11-dev libsqlite3-dev libsdl2-dev \
|
||||
&& apt-get clean && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
+2
-2
@@ -16,7 +16,7 @@ ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
RUN apk --no-cache add \
|
||||
bash g++ libstdc++-dev linux-headers zip git ca-certificates libbsd-dev \
|
||||
py3-pip py3-grpcio-tools \
|
||||
libgpiod-dev yaml-cpp-dev bluez-dev \
|
||||
libgpiod-dev yaml-cpp-dev jsoncpp-dev bluez-dev \
|
||||
libusb-dev i2c-tools-dev libuv-dev openssl-dev pkgconf argp-standalone \
|
||||
libx11-dev libinput-dev libxkbcommon-dev sqlite-dev sdl2-dev \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
@@ -48,7 +48,7 @@ LABEL org.opencontainers.image.title="Meshtastic" \
|
||||
USER root
|
||||
|
||||
RUN apk --no-cache add \
|
||||
shadow libstdc++ libbsd libgpiod yaml-cpp libusb \
|
||||
shadow libstdc++ libbsd libgpiod yaml-cpp jsoncpp libusb \
|
||||
i2c-tools libuv libx11 libinput libxkbcommon sdl2 \
|
||||
&& rm -rf /var/cache/apk/* \
|
||||
&& mkdir -p /var/lib/meshtasticd \
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Collect firmware binary sizes from manifest (.mt.json) files into a single report."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def collect_sizes(manifest_dir):
|
||||
"""Scan manifest_dir for .mt.json files and return {board: size_bytes} dict."""
|
||||
sizes = {}
|
||||
for fname in sorted(os.listdir(manifest_dir)):
|
||||
if not fname.endswith(".mt.json"):
|
||||
continue
|
||||
path = os.path.join(manifest_dir, fname)
|
||||
with open(path) as f:
|
||||
data = json.load(f)
|
||||
board = data.get("platformioTarget", fname.replace(".mt.json", ""))
|
||||
# Find the main firmware .bin size (largest .bin, excluding OTA/littlefs/bleota)
|
||||
bin_size = None
|
||||
for entry in data.get("files", []):
|
||||
name = entry.get("name", "")
|
||||
if name.startswith("firmware-") and name.endswith(".bin"):
|
||||
bin_size = entry["bytes"]
|
||||
break
|
||||
# Fallback: any .bin that isn't ota/littlefs/bleota
|
||||
if bin_size is None:
|
||||
for entry in data.get("files", []):
|
||||
name = entry.get("name", "")
|
||||
if name.endswith(".bin") and not any(
|
||||
x in name for x in ["littlefs", "bleota", "ota"]
|
||||
):
|
||||
bin_size = entry["bytes"]
|
||||
break
|
||||
if bin_size is not None:
|
||||
sizes[board] = bin_size
|
||||
return sizes
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print(f"Usage: {sys.argv[0]} <manifest_dir> <output.json>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
manifest_dir = sys.argv[1]
|
||||
output_path = sys.argv[2]
|
||||
|
||||
sizes = collect_sizes(manifest_dir)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(sizes, f, indent=2, sort_keys=True)
|
||||
|
||||
print(f"Collected sizes for {len(sizes)} targets -> {output_path}")
|
||||
@@ -5,7 +5,9 @@ Meta:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
### RAK13300 in Slot 2 pins
|
||||
|
||||
### RAK13300 in Slot 2
|
||||
Module: sx1262
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
@@ -13,5 +15,7 @@ Lora:
|
||||
Enable_Pins:
|
||||
- 26
|
||||
- 23
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
@@ -5,14 +5,18 @@ Meta:
|
||||
- raspberry-pi
|
||||
|
||||
Lora:
|
||||
### RAK13302 in Slot 2 pins
|
||||
|
||||
### RAK13302 in Slot 2
|
||||
Module: sx1262
|
||||
IRQ: 18 #IO6
|
||||
Reset: 24 # IO4
|
||||
Busy: 19 # IO5
|
||||
# Ant_sw: 23 # IO3
|
||||
# Ant_sw: 23 # IO3
|
||||
Enable_Pins:
|
||||
- 26
|
||||
- 23
|
||||
DIO3_TCXO_VOLTAGE: true
|
||||
DIO2_AS_RF_SWITCH: true
|
||||
spidev: spidev0.1
|
||||
# CS: 7
|
||||
TX_GAIN_LORA: [9, 9, 10, 11, 9, 8, 9, 10, 10, 10, 11, 12, 12, 12, 12, 12, 12, 12, 12, 10, 9, 8]
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run native PlatformIO unit tests and emit a single, unambiguous RED/AMBER/GREEN verdict.
|
||||
#
|
||||
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
|
||||
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
|
||||
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
|
||||
# correct logic once, and cross-checks the number of suites that actually ran against the
|
||||
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
|
||||
#
|
||||
# Usage:
|
||||
# ./bin/run-tests.sh # run all suites, full RAG + count cross-check
|
||||
# ./bin/run-tests.sh -f test_utf8 # run one suite (no count cross-check)
|
||||
# ./bin/run-tests.sh -e native # override env (default: coverage)
|
||||
# ./bin/run-tests.sh --quiet # only print the final RESULT line
|
||||
#
|
||||
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER.
|
||||
#
|
||||
# The final line is machine-readable, e.g.:
|
||||
# RESULT: GREEN 19/19 suites passed
|
||||
# RESULT: AMBER 17/19 suites ran (missing: test_radio test_serial) — all that ran passed
|
||||
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
|
||||
# RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
|
||||
# all passed; the coverage build aborts at exit on an ASan/LSan fault — often shown only
|
||||
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
ENV="coverage"
|
||||
FILTER=""
|
||||
QUIET=false
|
||||
PASSTHRU=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-f)
|
||||
FILTER="$2"
|
||||
PASSTHRU+=("-f" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-e)
|
||||
ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
PASSTHRU+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Locate pio (PATH, then the standard PlatformIO venv).
|
||||
PIO="$(command -v pio || command -v platformio || echo "$HOME/.platformio/penv/bin/pio")"
|
||||
if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
|
||||
echo "RESULT: RED pio not found (looked in PATH and ~/.platformio/penv/bin)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOG="$(mktemp -t meshtest.XXXXXX.log)"
|
||||
MARKER=""
|
||||
PROGRESS_PID=""
|
||||
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
|
||||
|
||||
# Canonical suite set = the directories in test/. This is the source of truth for
|
||||
# "what should run"; a filtered run only expects its filtered suite.
|
||||
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
|
||||
EXPECTED_COUNT=${#ALL_SUITES[@]}
|
||||
|
||||
# Cached object-count for this env, written after each completed build (in the gitignored build
|
||||
# dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles),
|
||||
# only a rough upper bound for an incremental run.
|
||||
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
|
||||
|
||||
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
|
||||
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
|
||||
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
|
||||
|
||||
# --- Progress heartbeat ------------------------------------------------------
|
||||
# Emit ONE status line every few seconds: build = objects (re)compiled this run / cached total +
|
||||
# best-effort ETA; test = suites finished / expected. Appends to $PROGRESS_FILE always (tail it to
|
||||
# check on a backgrounded run); also live-updates the tty when $5=1 (interactive --quiet). Never
|
||||
# touches $LOG, which is parsed for the verdict, so piped/CI captures stay clean.
|
||||
progress_monitor() {
|
||||
local marker="$1" objtotal="$2" testtotal="$3" pfile="$4" totty="$5" start now el done ran eta line
|
||||
start=$(date +%s)
|
||||
while :; do
|
||||
now=$(date +%s)
|
||||
el=$((now - start))
|
||||
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
|
||||
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
|
||||
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
|
||||
else
|
||||
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
|
||||
if ((objtotal > 0 && done > 0)); then
|
||||
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
|
||||
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
|
||||
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
|
||||
else
|
||||
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
|
||||
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
|
||||
[[ $totty == 1 ]] && printf '\r\033[K%s' "$line" >/dev/tty 2>/dev/null # live line (human)
|
||||
sleep 4
|
||||
done
|
||||
}
|
||||
|
||||
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
|
||||
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
|
||||
# streamed compile lines already show progress and a \r line would just fight them).
|
||||
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
|
||||
: >"$PROGRESS_FILE" 2>/dev/null || true
|
||||
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
|
||||
TOTTY=0
|
||||
{ $QUIET && [[ -t 1 ]]; } && TOTTY=1
|
||||
progress_monitor "$MARKER" "$(cat "$BASELINE_FILE" 2>/dev/null || echo 0)" \
|
||||
"$([[ -n $FILTER ]] && echo 1 || echo "$EXPECTED_COUNT")" "$PROGRESS_FILE" "$TOTTY" &
|
||||
PROGRESS_PID=$!
|
||||
|
||||
if ! $QUIET; then
|
||||
echo "Running: $PIO test -e $ENV ${PASSTHRU[*]-} (expecting $EXPECTED_COUNT suites)"
|
||||
fi
|
||||
echo "progress: tail -f $PROGRESS_FILE" >&2
|
||||
|
||||
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
|
||||
else
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
|
||||
fi
|
||||
PIO_RC=${PIPESTATUS[0]}
|
||||
|
||||
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
|
||||
if [[ -n $PROGRESS_PID ]]; then
|
||||
kill "$PROGRESS_PID" 2>/dev/null
|
||||
wait "$PROGRESS_PID" 2>/dev/null
|
||||
PROGRESS_PID=""
|
||||
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
|
||||
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
|
||||
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
|
||||
fi
|
||||
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
|
||||
|
||||
# --- Outcome detection -------------------------------------------------------
|
||||
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
|
||||
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
|
||||
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
|
||||
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
|
||||
# fail: Unity per-assertion ":FAIL:" | pio per-suite "[FAILED]" | summary "M failed"
|
||||
# error: pio build/crash "[ERRORED]" | Unity "M Failures" | compiler "error:"
|
||||
# Match \b after :PASS/:FAIL so ":PASSED"/":FAILED" forms are also caught either way.
|
||||
FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-9]* Failures|error:|undefined reference|Segmentation fault|terminate called|SIGHUP|SIGSEGV|SIGABRT'
|
||||
# Positive proof tests actually ran & passed (absence != success). Accept any pass spelling:
|
||||
# the per-test/per-suite tokens OR a success summary line.
|
||||
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
|
||||
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
|
||||
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
|
||||
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
|
||||
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
|
||||
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
|
||||
# startup noise that prints on every sanitizer run (it'd mislabel a normal [FAILED] as a leak).
|
||||
# Formats per LLVM/Google sanitizer docs: ASan/LSan emit "==PID==ERROR: <San>: ...", UBSan emits
|
||||
# "file:line:col: runtime error: ...", TSan emits "WARNING: ThreadSanitizer: ..."; all close with
|
||||
# a "SUMMARY: <San>: ..." line (LSan-under-ASan reports its SUMMARY as "AddressSanitizer").
|
||||
SAN_RE='(ERROR|WARNING): (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|SUMMARY: (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|Direct leak of|Indirect leak of|detected memory leaks|heap-use-after-free|heap-buffer-overflow|stack-buffer-overflow|attempting double-free|LeakSanitizer has encountered a fatal error|runtime error:'
|
||||
|
||||
# Suites that produced a per-suite verdict. pio emits "coverage:test_x [PASSED|FAILED|ERRORED]";
|
||||
# a SKIPPED suite (hardware-only on native) is "accounted for" too, so it doesn't read as missing.
|
||||
mapfile -t RAN_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" |
|
||||
sed -E "s/^${ENV}:(test_[a-z0-9_]+) .*/\1/" | sort -u)
|
||||
RAN_COUNT=${#RAN_SUITES[@]}
|
||||
# Suites pio explicitly skipped (don't count these as "missing" in the canonical cross-check).
|
||||
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
|
||||
grep -oE "test_[a-z0-9_]+" | sort -u)
|
||||
|
||||
verdict_red() {
|
||||
local detail bin
|
||||
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
|
||||
echo ""
|
||||
echo "RED — failures detected:"
|
||||
[[ -n $detail ]] && echo "$detail"
|
||||
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
|
||||
|
||||
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
|
||||
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
|
||||
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
|
||||
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
|
||||
|
||||
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
|
||||
if grep -qE "$SAN_RE" "$LOG"; then
|
||||
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
|
||||
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
|
||||
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
|
||||
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
|
||||
# sanitizer fault — point at how to surface it rather than calling it a generic crash.
|
||||
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
|
||||
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
|
||||
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: RED $(grep -oE '[0-9]+ failed' "$LOG" | tail -1 || echo 'build/crash error')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# RED: pio non-zero, any failure marker, or no positive summary at all (build died early).
|
||||
if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
|
||||
verdict_red
|
||||
fi
|
||||
if ! grep -qE "$PASS_RE" "$LOG"; then
|
||||
echo ""
|
||||
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# AMBER: everything that ran passed, but (full run only) a canonical suite neither ran NOR was
|
||||
# explicitly skipped — i.e. it silently went missing. SKIPPED suites are accounted for.
|
||||
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
|
||||
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
|
||||
missing=()
|
||||
for s in "${ALL_SUITES[@]}"; do
|
||||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
|
||||
done
|
||||
echo ""
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) — all that ran passed"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# GREEN.
|
||||
if [[ -n $FILTER ]]; then
|
||||
echo "RESULT: GREEN ${RAN_COUNT} suite(s) passed (filtered: $FILTER)"
|
||||
else
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed"
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,95 +0,0 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from github import Github
|
||||
|
||||
def parseFile(path):
|
||||
with open(path, "r") as f:
|
||||
data = json.loads(f)
|
||||
for file in data["files"]:
|
||||
if file["name"].endswith(".bin"):
|
||||
return file["name"], file["bytes"]
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print(f"expected usage: {sys.argv[0]} <PR number> <path to old-manifests> <path to new-manifests>")
|
||||
sys.exit(1)
|
||||
|
||||
pr_number = int(sys.argv[1])
|
||||
|
||||
token = os.getenv("GITHUB_TOKEN")
|
||||
if not token:
|
||||
raise EnvironmentError("GITHUB_TOKEN not found in environment.")
|
||||
|
||||
repo_name = os.getenv("GITHUB_REPOSITORY") # "owner/repo"
|
||||
if not repo_name:
|
||||
raise EnvironmentError("GITHUB_REPOSITORY not found in environment.")
|
||||
|
||||
oldFiles = sys.argv[2]
|
||||
old = set(os.path.join(oldFiles, f) for f in os.listdir(oldFiles) if os.path.isfile(f))
|
||||
newFiles = sys.argv[3]
|
||||
new = set(os.path.join(newFiles, f) for f in os.listdir(newFiles) if os.path.isfile(f))
|
||||
|
||||
startMarkdown = "# Target Size Changes\n\n"
|
||||
markdown = ""
|
||||
|
||||
newlyIntroduced = new - old
|
||||
if len(newlyIntroduced) > 0:
|
||||
markdown += "## Newly Introduced Targets\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Size |\n"
|
||||
markdown += "| ---- | ---- |\n"
|
||||
for f in newlyIntroduced:
|
||||
name, size = parseFile(f)
|
||||
markdown += f"| `{name}` | {size}b |\n"
|
||||
|
||||
# do not log removed targets
|
||||
# PRs only run a small subset of builds, so removed targets are not meaningful
|
||||
# since they are very likely to just be not ran in PR CI
|
||||
|
||||
both = old & new
|
||||
degradations = []
|
||||
improvements = []
|
||||
for f in both:
|
||||
oldName, oldSize = parseFile(f)
|
||||
_, newSize = parseFile(f)
|
||||
if oldSize != newSize:
|
||||
if newSize < oldSize:
|
||||
improvements.append((oldName, oldSize, newSize))
|
||||
else:
|
||||
degradations.append((oldName, oldSize, newSize))
|
||||
|
||||
if len(degradations) > 0:
|
||||
markdown += "\n## Degradation\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
||||
for oldName, oldSize, newSize in degradations:
|
||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
||||
|
||||
if len(improvements) > 0:
|
||||
markdown += "\n## Improvement\n\n"
|
||||
# create a table
|
||||
markdown += "| File | Difference | Old Size | New Size |\n"
|
||||
markdown += "| ---- | ---------- | -------- | -------- |\n"
|
||||
for oldName, oldSize, newSize in improvements:
|
||||
markdown += f"| `{oldName}` | **{oldSize - newSize}b** | {oldSize}b | {newSize}b |\n"
|
||||
|
||||
if len(markdown) == 0:
|
||||
markdown = "No changes in target sizes detected."
|
||||
|
||||
g = Github(token)
|
||||
repo = g.get_repo(repo_name)
|
||||
pr = repo.get_pull(pr_number)
|
||||
|
||||
existing_comment = None
|
||||
for comment in pr.get_issue_comments():
|
||||
if comment.body.startswith(startMarkdown):
|
||||
existing_comment = comment
|
||||
break
|
||||
|
||||
final_markdown = startMarkdown + markdown
|
||||
|
||||
if existing_comment:
|
||||
existing_comment.edit(body=final_markdown)
|
||||
else:
|
||||
pr.create_issue_comment(body=final_markdown)
|
||||
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Compare firmware size reports and generate a markdown summary.
|
||||
|
||||
Usage:
|
||||
size_report.py <new_sizes.json> [--baseline <label>:<old_sizes.json>]...
|
||||
|
||||
Examples:
|
||||
# Compare PR against develop and master baselines
|
||||
size_report.py pr.json --baseline develop:develop.json --baseline master:master.json
|
||||
|
||||
# Single baseline comparison
|
||||
size_report.py pr.json --baseline develop:develop.json
|
||||
|
||||
# No baselines — shows sizes with blank delta columns
|
||||
size_report.py pr.json
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
|
||||
def load_sizes(path):
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def format_delta(n):
|
||||
"""Format byte delta with sign and human-friendly suffix."""
|
||||
sign = "+" if n > 0 else ""
|
||||
if abs(n) >= 1024:
|
||||
return f"{sign}{n:,} ({sign}{n / 1024:.1f} KB)"
|
||||
return f"{sign}{n:,}"
|
||||
|
||||
|
||||
def generate_markdown(new_sizes, baselines, top_n=5):
|
||||
"""Generate a single table with current size and delta columns per baseline.
|
||||
|
||||
baselines: list of (label, old_sizes_dict), may be empty
|
||||
"""
|
||||
labels = [label for label, _ in baselines]
|
||||
|
||||
# Build rows: (board, current_size, [(delta, abs_delta) per baseline])
|
||||
rows = []
|
||||
for board in sorted(new_sizes):
|
||||
current = new_sizes[board]
|
||||
deltas = []
|
||||
for _, old_sizes in baselines:
|
||||
old = old_sizes.get(board)
|
||||
if old is not None:
|
||||
d = current - old
|
||||
deltas.append((d, abs(d)))
|
||||
else:
|
||||
deltas.append((None, 0))
|
||||
# Sort key: max abs delta across baselines (biggest changes first)
|
||||
max_abs = max((ad for _, ad in deltas), default=0)
|
||||
rows.append((board, current, deltas, max_abs))
|
||||
|
||||
rows.sort(key=lambda r: r[3], reverse=True)
|
||||
|
||||
# Summary line
|
||||
sections = []
|
||||
summary_parts = [f"{len(new_sizes)} targets"]
|
||||
for i, (label, old_sizes) in enumerate(baselines):
|
||||
increases = sum(
|
||||
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] > 0
|
||||
)
|
||||
decreases = sum(
|
||||
1 for _, _, deltas, _ in rows if deltas[i][0] is not None and deltas[i][0] < 0
|
||||
)
|
||||
net = sum(
|
||||
deltas[i][0] for _, _, deltas, _ in rows if deltas[i][0] is not None
|
||||
)
|
||||
parts = []
|
||||
if increases:
|
||||
parts.append(f"{increases} increased")
|
||||
if decreases:
|
||||
parts.append(f"{decreases} decreased")
|
||||
if parts:
|
||||
parts.append(f"net {format_delta(net)}")
|
||||
summary_parts.append(f"vs `{label}`: {', '.join(parts)}")
|
||||
else:
|
||||
summary_parts.append(f"vs `{label}`: no changes")
|
||||
|
||||
if not baselines:
|
||||
summary_parts.append("no baseline available yet")
|
||||
|
||||
sections.append(f"**{' | '.join(summary_parts)}**\n")
|
||||
|
||||
# Table header
|
||||
header = "| Target | Size |"
|
||||
separator = "|--------|-----:|"
|
||||
for label in labels:
|
||||
header += f" vs `{label}` |"
|
||||
separator += "----------:|"
|
||||
sections.append(header)
|
||||
sections.append(separator)
|
||||
|
||||
def format_row(board, current, deltas):
|
||||
row = f"| `{board}` | {current:,} |"
|
||||
for d, _ in deltas:
|
||||
if d is None:
|
||||
row += " |"
|
||||
elif d == 0:
|
||||
row += " 0 |"
|
||||
else:
|
||||
icon = "📈" if d > 0 else "📉"
|
||||
row += f" {icon} {format_delta(d)} |"
|
||||
return row
|
||||
|
||||
# Top N rows always visible
|
||||
top = rows[:top_n]
|
||||
for board, current, deltas, _ in top:
|
||||
sections.append(format_row(board, current, deltas))
|
||||
|
||||
# Remaining rows in expandable section
|
||||
rest = rows[top_n:]
|
||||
if rest:
|
||||
sections.append("")
|
||||
sections.append(
|
||||
f"<details><summary>Show {len(rest)} more target(s)</summary>\n"
|
||||
)
|
||||
sections.append(header)
|
||||
sections.append(separator)
|
||||
for board, current, deltas, _ in rest:
|
||||
sections.append(format_row(board, current, deltas))
|
||||
sections.append("\n</details>")
|
||||
|
||||
sections.append("")
|
||||
return "\n".join(sections)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare firmware size reports")
|
||||
parser.add_argument("new_sizes", help="Path to new sizes JSON")
|
||||
parser.add_argument(
|
||||
"--baseline",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="LABEL:PATH",
|
||||
help="Baseline to compare against (e.g. develop:develop.json)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--top",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Number of top changes to show before collapsing (default: 5)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
new_sizes = load_sizes(args.new_sizes)
|
||||
|
||||
# Silence output when no targets were built — repo maintainer choice
|
||||
if not new_sizes:
|
||||
return
|
||||
|
||||
baselines = []
|
||||
for b in args.baseline:
|
||||
if ":" not in b:
|
||||
print(f"Error: baseline must be LABEL:PATH, got '{b}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
label, path = b.split(":", 1)
|
||||
baselines.append((label, load_sizes(path)))
|
||||
|
||||
md = generate_markdown(new_sizes, baselines, top_n=args.top)
|
||||
print(md)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
"""Tests for bin/collect_sizes.py and bin/size_report.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
SCRIPTS_DIR = os.path.join(os.path.dirname(__file__), "..", "bin")
|
||||
|
||||
|
||||
def make_manifest(target, firmware_bytes, extra_files=None):
|
||||
"""Create a minimal .mt.json manifest dict."""
|
||||
files = [{"name": f"firmware-{target}-2.6.0.bin", "bytes": firmware_bytes}]
|
||||
if extra_files:
|
||||
files.extend(extra_files)
|
||||
return {
|
||||
"platformioTarget": target,
|
||||
"version": "2.6.0.test",
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def write_manifests(tmpdir, manifests):
|
||||
"""Write manifest dicts as .mt.json files into tmpdir."""
|
||||
for target, data in manifests.items():
|
||||
path = os.path.join(tmpdir, f"firmware-{target}.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
|
||||
def run_script(script, args):
|
||||
"""Run a Python script and return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(SCRIPTS_DIR, script)] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def test_collect_sizes_basic():
|
||||
"""collect_sizes picks up firmware-*.bin entries from manifests."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
manifests = {
|
||||
"heltec-v3": make_manifest("heltec-v3", 1048576),
|
||||
"rak4631": make_manifest("rak4631", 524288),
|
||||
"tbeam": make_manifest("tbeam", 786432),
|
||||
}
|
||||
write_manifests(tmpdir, manifests)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||
assert "3 targets" in stdout
|
||||
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert sizes == {"heltec-v3": 1048576, "rak4631": 524288, "tbeam": 786432}
|
||||
|
||||
|
||||
def test_collect_sizes_fallback_bin():
|
||||
"""collect_sizes falls back to non-firmware-prefixed .bin if no firmware-*.bin."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
# Manifest with only a generic .bin (no firmware- prefix)
|
||||
data = {
|
||||
"platformioTarget": "custom-board",
|
||||
"files": [
|
||||
{"name": "littlefs-custom-board.bin", "bytes": 100000},
|
||||
{"name": "custom-board.bin", "bytes": 500000},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmpdir, "firmware-custom-board.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0, f"collect_sizes failed: {stderr}"
|
||||
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert sizes == {"custom-board": 500000}
|
||||
|
||||
|
||||
def test_collect_sizes_skips_ota_littlefs():
|
||||
"""collect_sizes ignores ota/littlefs/bleota .bin files in fallback."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
data = {
|
||||
"platformioTarget": "board-x",
|
||||
"files": [
|
||||
{"name": "littlefs-board-x.bin", "bytes": 100000},
|
||||
{"name": "bleota-board-x.bin", "bytes": 50000},
|
||||
{"name": "mt-board-x-ota.bin", "bytes": 60000},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmpdir, "firmware-board-x.mt.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
# No valid firmware .bin found, board should be absent
|
||||
assert sizes == {}
|
||||
|
||||
|
||||
def test_collect_sizes_ignores_non_mt_json():
|
||||
"""collect_sizes skips non .mt.json files."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
outfile = os.path.join(tmpdir, "sizes.json")
|
||||
# Write a valid manifest
|
||||
manifests = {"rak4631": make_manifest("rak4631", 500000)}
|
||||
write_manifests(tmpdir, manifests)
|
||||
# Write a decoy file
|
||||
with open(os.path.join(tmpdir, "readme.txt"), "w") as f:
|
||||
f.write("not a manifest")
|
||||
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [tmpdir, outfile])
|
||||
assert rc == 0
|
||||
with open(outfile) as f:
|
||||
sizes = json.load(f)
|
||||
assert list(sizes.keys()) == ["rak4631"]
|
||||
|
||||
|
||||
def test_size_report_no_baseline():
|
||||
"""size_report with no baselines shows sizes only."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "new.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1000000, "rak4631": 500000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script("size_report.py", [sizes_file])
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "2 targets" in stdout
|
||||
assert "no baseline available yet" in stdout
|
||||
assert "`heltec-v3`" in stdout
|
||||
assert "`rak4631`" in stdout
|
||||
|
||||
|
||||
def test_size_report_with_baseline():
|
||||
"""size_report shows deltas against a baseline."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
old_file = os.path.join(tmpdir, "old.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1050000, "rak4631": 500000, "tbeam": 800000}, f)
|
||||
with open(old_file, "w") as f:
|
||||
json.dump({"heltec-v3": 1000000, "rak4631": 500000, "tbeam": 810000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "3 targets" in stdout
|
||||
assert "1 increased" in stdout
|
||||
assert "1 decreased" in stdout
|
||||
# heltec-v3 grew by 50000
|
||||
assert "📈" in stdout
|
||||
# tbeam shrank by 10000
|
||||
assert "📉" in stdout
|
||||
# rak4631 unchanged
|
||||
assert "vs `develop`" in stdout
|
||||
|
||||
|
||||
def test_size_report_multiple_baselines():
|
||||
"""size_report handles multiple baselines."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
dev_file = os.path.join(tmpdir, "develop.json")
|
||||
master_file = os.path.join(tmpdir, "master.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"board-a": 100000}, f)
|
||||
with open(dev_file, "w") as f:
|
||||
json.dump({"board-a": 95000}, f)
|
||||
with open(master_file, "w") as f:
|
||||
json.dump({"board-a": 90000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py",
|
||||
[new_file, "--baseline", f"develop:{dev_file}", "--baseline", f"master:{master_file}"],
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "vs `develop`" in stdout
|
||||
assert "vs `master`" in stdout
|
||||
|
||||
|
||||
def test_size_report_new_target_no_baseline_entry():
|
||||
"""size_report handles targets not present in baseline (new boards)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
new_file = os.path.join(tmpdir, "new.json")
|
||||
old_file = os.path.join(tmpdir, "old.json")
|
||||
with open(new_file, "w") as f:
|
||||
json.dump({"new-board": 300000, "existing": 500000}, f)
|
||||
with open(old_file, "w") as f:
|
||||
json.dump({"existing": 500000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [new_file, "--baseline", f"develop:{old_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "`new-board`" in stdout
|
||||
assert "no changes" in stdout # only existing is compared, delta=0
|
||||
|
||||
|
||||
def test_size_report_all_unchanged():
|
||||
"""size_report shows 'no changes' when all sizes match."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"board-a": 100000, "board-b": 200000}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [sizes_file, "--baseline", f"develop:{sizes_file}"]
|
||||
)
|
||||
assert rc == 0, f"size_report failed: {stderr}"
|
||||
assert "no changes" in stdout
|
||||
|
||||
|
||||
def test_collect_sizes_bad_args():
|
||||
"""collect_sizes exits with error on wrong arg count."""
|
||||
rc, stdout, stderr = run_script("collect_sizes.py", [])
|
||||
assert rc == 1
|
||||
assert "Usage" in stderr
|
||||
|
||||
|
||||
def test_size_report_bad_baseline_format():
|
||||
"""size_report exits with error on malformed --baseline."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sizes_file = os.path.join(tmpdir, "sizes.json")
|
||||
with open(sizes_file, "w") as f:
|
||||
json.dump({"x": 1}, f)
|
||||
|
||||
rc, stdout, stderr = run_script(
|
||||
"size_report.py", [sizes_file, "--baseline", "no-colon-here"]
|
||||
)
|
||||
assert rc == 1
|
||||
assert "LABEL:PATH" in stderr
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
tests = [v for k, v in globals().items() if k.startswith("test_")]
|
||||
passed = 0
|
||||
failed = 0
|
||||
for test in tests:
|
||||
try:
|
||||
test()
|
||||
print(f" PASS: {test.__name__}")
|
||||
passed += 1
|
||||
except AssertionError as e:
|
||||
print(f" FAIL: {test.__name__}: {e}")
|
||||
failed += 1
|
||||
except Exception as e:
|
||||
print(f" ERROR: {test.__name__}: {type(e).__name__}: {e}")
|
||||
failed += 1
|
||||
|
||||
print(f"\n{passed} passed, {failed} failed out of {passed + failed}")
|
||||
sys.exit(1 if failed else 0)
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52832_s132_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52832_XXAA -DNRF52",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x8029"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x802A"]
|
||||
],
|
||||
"usb_product": "Feather nRF52832 Express",
|
||||
"mcu": "nrf52832",
|
||||
"variant": "WisCore_RAK4600_Board",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS132",
|
||||
"sd_name": "s132",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B7"
|
||||
},
|
||||
"zephyr": {
|
||||
"variant": "nrf52_adafruit_feather"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52832_xxAA",
|
||||
"svd_path": "nrf52.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino", "zephyr"],
|
||||
"name": "Adafruit Bluefruit nRF52832 Feather",
|
||||
"upload": {
|
||||
"maximum_ram_size": 65536,
|
||||
"maximum_size": 524288,
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
|
||||
},
|
||||
"url": "https://www.adafruit.com/product/3406",
|
||||
"vendor": "Adafruit"
|
||||
}
|
||||
Vendored
+1
@@ -14,6 +14,7 @@ Build-Depends: debhelper-compat (= 13),
|
||||
g++,
|
||||
pkg-config,
|
||||
libyaml-cpp-dev,
|
||||
libjsoncpp-dev,
|
||||
libgpiod-dev,
|
||||
libbluetooth-dev,
|
||||
libusb-1.0-0-dev,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821)
|
||||
#
|
||||
# Whole-image LTO for nrf52840 (~-60KB; ~-23KB beyond src-only LTO), EXCEPT the objects
|
||||
# that own interrupt/exception handlers.
|
||||
#
|
||||
# Every ISR is referenced only from the assembly vector table (gcc_startup_nrf52840.S),
|
||||
# which LTO cannot see -> whole-program LTO judges the handlers dead, removes them, and
|
||||
# the weak `b .` Default_Handler stubs prevail -> the IRQ lands in an infinite loop and the
|
||||
# chip hangs (or the peripheral silently stalls). Compiling the handler-bearing objects
|
||||
# WITHOUT LTO lets ordinary linking keep the strong handlers; everything else stays LTO'd:
|
||||
# - framework core (/FrameworkArduino/, /cores/nRF5/): every nrfx ISR + the FreeRTOS
|
||||
# SVC/PendSV port.
|
||||
# - TinyUSB nrf port (Adafruit_TinyUSB_nrf.cpp): USBD_IRQHandler (USB data path).
|
||||
# - library .cpp files that own a vector ISR (would otherwise be silently dropped):
|
||||
# bluefruit.cpp -> SD_EVT/SWI2_EGU2 (SoftDevice BLE-event delivery -- advertising
|
||||
# hangs without it)
|
||||
# Wire_nRF52.cpp -> SPIM0/TWIM0 + SPIM1/TWIM1 (interrupt-driven I2C/SPI)
|
||||
# PDM.cpp -> PDM_IRQHandler (PDM microphone)
|
||||
# RotaryEncoder.cpp -> QDEC_IRQHandler (hardware quadrature/rotary encoder)
|
||||
#
|
||||
# A post-link guard (bottom of this file) fails the build if a critical handler was dropped
|
||||
# anyway -- so a future deps bump or a new ISR-owning library becomes a red build, not a field
|
||||
# hang. To hunt a dropped ISR by hand: nm the .elf for `_IRQHandler$` symbols marked `W`, then
|
||||
# grep the libs/framework for who defines them.
|
||||
#
|
||||
# HW-validated: RAK4631 (SX1262) + muzi-base (LR1121).
|
||||
import glob
|
||||
import os
|
||||
|
||||
Import("env")
|
||||
|
||||
env.Append(LINKFLAGS=["-flto", "-flto-partition=1to1"])
|
||||
|
||||
# The -fno-lto re-compiles below run with the global env, which lacks the framework's
|
||||
# bundled-library include dirs -- and those libs cross-include each other (Wire pulls in
|
||||
# Adafruit_TinyUSB.h, which pulls in SPI.h, ...). Add every bundled-lib dir (+ its src/) so
|
||||
# the re-compiles resolve without chasing headers one at a time.
|
||||
_fw = env.PioPlatform().get_package_dir("framework-arduinoadafruitnrf52") or ""
|
||||
_extra_inc = []
|
||||
for _d in sorted(glob.glob(os.path.join(_fw, "libraries", "*"))):
|
||||
if os.path.isdir(_d):
|
||||
_extra_inc.append(_d)
|
||||
if os.path.isdir(os.path.join(_d, "src")):
|
||||
_extra_inc.append(os.path.join(_d, "src"))
|
||||
|
||||
FRAMEWORK = ("/FrameworkArduino/", "/cores/nRF5/")
|
||||
USB_ISR = "Adafruit_TinyUSB_nrf" # USBD_IRQHandler
|
||||
# Library .cpp files that define vector-table ISRs (the rest of their lib stays LTO'd):
|
||||
LIB_ISR = ("/bluefruit.cpp", "/Wire_nRF52.cpp", "/PDM.cpp", "/RotaryEncoder.cpp")
|
||||
|
||||
|
||||
def _no_lto(node):
|
||||
try:
|
||||
path = node.get_abspath()
|
||||
except Exception:
|
||||
path = str(node)
|
||||
path = path.replace(
|
||||
"\\", "/"
|
||||
) # normalize Windows backslashes so matches work cross-platform
|
||||
if (
|
||||
USB_ISR in path
|
||||
or any(s in path for s in FRAMEWORK)
|
||||
or any(s in path for s in LIB_ISR)
|
||||
):
|
||||
return env.Object(
|
||||
node,
|
||||
CCFLAGS=env["CCFLAGS"] + ["-fno-lto"],
|
||||
CPPPATH=env["CPPPATH"] + _extra_inc,
|
||||
)
|
||||
return node
|
||||
|
||||
|
||||
env.AddBuildMiddleware(_no_lto)
|
||||
|
||||
|
||||
# --- post-link guard: catch a dropped ISR handler at build time (CI footgun protection) ----
|
||||
# After every link, fail the build if one of these critical vector-table handlers resolved to
|
||||
# the weak `b .` Default_Handler stub -- i.e. LTO (or a deps bump, or a new ISR-owning library
|
||||
# that nobody added to LIB_ISR) silently dropped it. A dropped handler hangs the chip the
|
||||
# instant that IRQ fires; this turns a field hang into a red build. CI builds every nrf52840
|
||||
# target, so this runs on every PR automatically. If a board deliberately stops using one of
|
||||
# these, edit the tuples on purpose.
|
||||
_REQUIRED_STRONG = (
|
||||
"SWI2_EGU2_IRQHandler", # SoftDevice BLE event (SD_EVT) -- advertising & connections
|
||||
"GPIOTE_IRQHandler", # GPIO interrupts: radio DIO + buttons
|
||||
"RTC1_IRQHandler", # FreeRTOS scheduler tick
|
||||
)
|
||||
# Owned by the TinyUSB stack, so only required when the board builds with USB at all.
|
||||
# Boards without native USB wiring (e.g. wio-sdk-wm1110's CH340 UART) strip TinyUSB via
|
||||
# disable_adafruit_usb.py / unflagging USE_TINYUSB, leaving these legitimately weak.
|
||||
_REQUIRED_STRONG_USB = (
|
||||
"USBD_IRQHandler", # USB CDC (serial console + 1200bps DFU trigger)
|
||||
"POWER_CLOCK_IRQHandler", # USB power events (VBUS detect/ready) via TinyUSB hal
|
||||
)
|
||||
|
||||
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
|
||||
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
|
||||
if not os.path.isfile(_NM):
|
||||
_NM = "arm-none-eabi-nm" # fall back to PATH
|
||||
|
||||
|
||||
def _assert_isr_handlers_survived(source, target, env):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
# Resolve the ELF at build time; target[0] is the buildprog alias, not the file.
|
||||
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
||||
out = subprocess.check_output([_NM, elf], universal_newlines=True)
|
||||
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
|
||||
print("nrf52_lto: WARNING - ISR-handler guard skipped (nm failed: %s)" % exc)
|
||||
return
|
||||
# nm line: "<addr> <type> <symbol>". type 'T'/'t' = strong (good); 'W'/'w' = weak stub.
|
||||
kind = {}
|
||||
for line in out.split("\n"):
|
||||
f = line.split()
|
||||
if len(f) >= 3 and f[-1].endswith("_IRQHandler"):
|
||||
kind[f[-1]] = f[-2]
|
||||
required = list(_REQUIRED_STRONG)
|
||||
defines = [
|
||||
str(d[0] if isinstance(d, tuple) else d) for d in env.get("CPPDEFINES", [])
|
||||
]
|
||||
if "USE_TINYUSB" in defines:
|
||||
required += _REQUIRED_STRONG_USB
|
||||
dropped = [h for h in required if kind.get(h, "W").upper() != "T"]
|
||||
if dropped:
|
||||
sys.stderr.write(
|
||||
"\n*** nrf52 LTO guard: interrupt handler(s) DROPPED: %s ***\n"
|
||||
"Each resolved to the weak Default_Handler stub, so the chip hangs when that IRQ\n"
|
||||
"fires. Compile the .cpp that defines the handler with -fno-lto by adding it to\n"
|
||||
"LIB_ISR in extra_scripts/nrf52_lto.py. Find the owner of FOO_IRQHandler with:\n"
|
||||
" grep -rl FOO_IRQHandler <framework-arduinoadafruitnrf52>/{libraries,cores}\n\n"
|
||||
% ", ".join(dropped)
|
||||
)
|
||||
from SCons.Script import Exit
|
||||
|
||||
Exit(1) # canonical SCons build-abort -> red build
|
||||
print(
|
||||
"nrf52_lto: ISR-handler guard OK -- %d critical handlers strong" % len(required)
|
||||
)
|
||||
|
||||
|
||||
# Attach to the phony "buildprog" alias, NOT the .elf file node: SCons can skip a post-action
|
||||
# on a file target during an incremental relink (observed), but the buildprog alias runs every
|
||||
# build -- so the guard fires on local incremental rebuilds and clean CI builds alike.
|
||||
env.AddPostAction("buildprog", _assert_isr_handlers_survived)
|
||||
@@ -33,3 +33,10 @@ tests/reproducers/
|
||||
# UI-tier camera captures + per-test transcripts. Regenerated every run;
|
||||
# left on disk for human review between runs.
|
||||
tests/ui_captures/
|
||||
|
||||
# Web stack (FleetSuite): node deps + the built SPA Vite emits into the
|
||||
# package (regenerated via `cd web-ui && npm run build`). The SQLite registry
|
||||
# lives under .mtlog/ (already ignored above).
|
||||
web-ui/node_modules/
|
||||
web-ui/dist/
|
||||
src/meshtastic_mcp/web/static/
|
||||
|
||||
+37
-17
@@ -174,7 +174,7 @@ rather than auto-`sudo`'ing mid-run.
|
||||
| `MESHTASTIC_NRFUTIL_BIN` | `$PATH` | Override nrfutil |
|
||||
| `MESHTASTIC_PICOTOOL_BIN` | `$PATH` | Override picotool |
|
||||
| `MESHTASTIC_MCP_SEED` | `mcp-<user>-<host>` | PSK seed for test-harness session (CI override) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (TUI tails it) |
|
||||
| `MESHTASTIC_MCP_FLASH_LOG` | `<mcp-server>/tests/flash.log` | Tee target for pio/esptool/nrfutil subprocess output (web UI tails it) |
|
||||
| `MESHTASTIC_MCP_TCP_HOST` | unset | `host` or `host:port` of a `meshtasticd` daemon to surface as a TCP device (see "TCP / native-host nodes" below) |
|
||||
|
||||
## TCP / native-host nodes
|
||||
@@ -333,31 +333,51 @@ captures just become 1×1 black PNGs.
|
||||
**Meshtastic debug** section attached on failure with a 200-line firmware
|
||||
log tail + device-state dump. Open this first on failures.
|
||||
- `junit.xml` — CI-parseable.
|
||||
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the TUI.
|
||||
- `reportlog.jsonl` — `pytest-reportlog` event stream; consumed by the web UI.
|
||||
- `fwlog.jsonl` — firmware log mirror (`meshtastic.log.line` pubsub → JSONL).
|
||||
- `flash.log` — tee of all pio / esptool / nrfutil / picotool subprocess
|
||||
output during the run (driven by `MESHTASTIC_MCP_FLASH_LOG`).
|
||||
|
||||
### Live TUI
|
||||
### Web UI (FleetSuite)
|
||||
|
||||
A Vue 3 + Tailwind desktop web app replaces the old Textual TUI. It serves a
|
||||
FastAPI backend (REST + WebSocket) that reuses the library modules and a
|
||||
SQLite registry of devices and cameras. See [web-ui/README.md](web-ui/README.md).
|
||||
|
||||
**One command** (bootstraps the venv, web deps, npm packages, and SPA build on
|
||||
first run, then launches):
|
||||
|
||||
```bash
|
||||
.venv/bin/meshtastic-mcp-test-tui
|
||||
.venv/bin/meshtastic-mcp-test-tui tests/mesh # pytest args pass through
|
||||
./scripts/fleetsuite.sh # open the native desktop window
|
||||
./scripts/fleetsuite.sh --browser # serve only → http://127.0.0.1:8765
|
||||
./scripts/fleetsuite.sh --dev # backend + Vite dev server with hot-reload
|
||||
./scripts/fleetsuite.sh --rebuild # force a fresh SPA build first
|
||||
```
|
||||
|
||||
Textual-based wrapper over `run-tests.sh` with a live test tree, tier
|
||||
counters, pytest output pane, firmware-log pane, and a device-status strip.
|
||||
Key bindings: `r` re-run focused, `f` filter, `d` failure detail, `g` open
|
||||
`report.html`, `x` export reproducer bundle, `l` cycle fw-log filter, `q`
|
||||
quit (SIGINT → SIGTERM → SIGKILL escalation).
|
||||
In **VS Code**: Run Task → **FleetSuite** (or _FleetSuite: Dev (HMR)_). Or do it
|
||||
by hand:
|
||||
|
||||
Set `MESHTASTIC_UI_TUI_CAMERA=1` to mount a bottom-of-screen **UI camera**
|
||||
panel. Left side: the latest capture PNG rendered as Unicode half-blocks
|
||||
(via `rich-pixels`, works in any terminal — no kitty/sixel required).
|
||||
Right side: live transcript tail ("step 3 — frame 4/8 name=nodelist_nodes
|
||||
— OCR: Nodes 2/2") so you can see every event-injection and its result
|
||||
as each UI test runs. Requires the `[ui]` extras for image rendering; the
|
||||
transcript alone works without them.
|
||||
```bash
|
||||
.venv/bin/pip install -e '.[web]' # FastAPI + uvicorn + aiosqlite + pywebview
|
||||
(cd web-ui && npm install && npm run build) # build the SPA into web/static
|
||||
.venv/bin/meshtastic-mcp-web # opens a native pywebview window
|
||||
.venv/bin/meshtastic-mcp-web --browser # serve only → http://127.0.0.1:8765
|
||||
```
|
||||
|
||||
Two views: **Fleet** (per-device cards keyed by USB serial that follow a device
|
||||
across changing ports, with live serial logs, packet/telemetry, test history,
|
||||
full device control, and an assignable live USB camera feed) and **Test Suite**
|
||||
(run/stop `run-tests.sh`, live tier counters + test tree, pytest/flash/firmware
|
||||
log panes, run history). The header shows the firmware branch/SHA/dirty live.
|
||||
|
||||
**Native nodes (Docker).** The Fleet view can run Linux-native `meshtasticd`
|
||||
nodes in Docker (no radio, sim mode) and manage them as TCP devices — run /
|
||||
stop / restart / remove, live `docker logs`, config/send-text/inject-NodeDB over
|
||||
`tcp://`. By default it builds the checkout's own image from `Dockerfile`; set
|
||||
`MESHTASTIC_NATIVE_IMAGE` (e.g. `meshtastic/meshtasticd:latest`) to use a
|
||||
prebuilt image instead.
|
||||
|
||||
For development with HMR, run `scripts/web-dev.sh` (backend + Vite together).
|
||||
|
||||
### Slash commands
|
||||
|
||||
|
||||
+14
-13
@@ -17,11 +17,16 @@ test = [
|
||||
"pytest-timeout>=2.3",
|
||||
"coverage[toml]>=7",
|
||||
"pyyaml>=6",
|
||||
# textual is required by the `meshtastic-mcp-test-tui` script (see
|
||||
# `src/meshtastic_mcp/cli/test_tui.py`). Bundled into `test` rather than a
|
||||
# separate `[tui]` extra because v1 expects test operators are the only
|
||||
# consumers; revisit if install cost pushes back.
|
||||
"textual>=0.50",
|
||||
]
|
||||
# Web stack (FastAPI backend + pywebview desktop window) that replaces the
|
||||
# Textual TUI. Serves the built Vue SPA in `web/static`. Camera streaming
|
||||
# reuses the `[ui]` extra (opencv-python-headless).
|
||||
web = [
|
||||
"fastapi>=0.110",
|
||||
"uvicorn[standard]>=0.27",
|
||||
"aiosqlite>=0.19",
|
||||
"pywebview>=5.0",
|
||||
"requests>=2.31",
|
||||
]
|
||||
# UI test tier + `capture_screen` MCP tool. Optional because the ML OCR
|
||||
# model alone is ~100 MB and camera hardware is user-supplied.
|
||||
@@ -32,19 +37,15 @@ ui = [
|
||||
"numpy>=1.26",
|
||||
"easyocr>=1.7",
|
||||
"Pillow>=10.0",
|
||||
# Renders the latest camera capture as Unicode half-blocks in the TUI
|
||||
# (MESHTASTIC_UI_TUI_CAMERA=1). Terminal-agnostic — no kitty / sixel
|
||||
# dependency. Pure Python, tiny.
|
||||
"rich-pixels>=3.0",
|
||||
]
|
||||
ui-min = ["opencv-python-headless>=4.9", "numpy>=1.26"]
|
||||
|
||||
[project.scripts]
|
||||
meshtastic-mcp = "meshtastic_mcp.__main__:main"
|
||||
# Live TUI wrapping run-tests.sh — shells out to the same script the plain
|
||||
# CLI uses, tails pytest-reportlog for per-test state, and polls the device
|
||||
# list at startup + post-run (port lock forces it to stay idle during the run).
|
||||
meshtastic-mcp-test-tui = "meshtastic_mcp.cli.test_tui:main"
|
||||
# Web stack replacing the Textual TUI: a FastAPI backend (REST + WebSocket)
|
||||
# that reuses the library modules and serves the Vue SPA, opened in a
|
||||
# pywebview window (`--browser` to serve only).
|
||||
meshtastic-mcp-web = "meshtastic_mcp.web.__main__:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
|
||||
@@ -191,10 +191,12 @@ echo
|
||||
# PASS/FAIL — every hardware test would SKIP with "role not present". We
|
||||
# narrow to tests/unit explicitly so the summary reads as "no hardware,
|
||||
# unit suite only" instead of "big skip count looks suspicious".
|
||||
# Keep terminal output condensed (`-q -r fE`) so skip-heavy runs do not print
|
||||
# each skipped test in full; skip counts still appear in pytest's summary.
|
||||
if [[ -z $DETECTED && $# -eq 0 ]]; then
|
||||
echo "[pre-flight] no supported devices detected; running unit tier only."
|
||||
echo
|
||||
exec "$VENV_PY" -m pytest tests/unit -v --report-log=tests/reportlog.jsonl
|
||||
exec "$VENV_PY" -m pytest tests/unit -q -r fE --report-log=tests/reportlog.jsonl
|
||||
fi
|
||||
|
||||
# Default pytest args when the user passed none. Power users can invoke
|
||||
@@ -210,11 +212,13 @@ fi
|
||||
# skipping half the hardware tests with "not baked with session profile"
|
||||
# errors. Power users who know their hardware is current and want to shave
|
||||
# those seconds can pass `--assume-baked` explicitly.
|
||||
# Defaults also use condensed reporting (`-q -r fE`) to avoid listing every
|
||||
# skipped test verbatim while still surfacing failures/errors and summary data.
|
||||
if [[ $# -eq 0 ]]; then
|
||||
set -- tests/ \
|
||||
--html=tests/report.html --self-contained-html \
|
||||
--junitxml=tests/junit.xml \
|
||||
-v --tb=short
|
||||
-q -r fE --tb=short
|
||||
fi
|
||||
|
||||
# UI tier requires opencv-python-headless (and ideally easyocr). If it's
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
# Single entrypoint for FleetSuite — the web UI for the Meshtastic test harness.
|
||||
# Ensures deps, builds the SPA, and launches the app. One command, from anywhere.
|
||||
#
|
||||
# ./scripts/fleetsuite.sh # build SPA if needed, open the desktop window
|
||||
# ./scripts/fleetsuite.sh --browser # serve only → http://127.0.0.1:8765
|
||||
# ./scripts/fleetsuite.sh --dev # backend + Vite dev server with hot-reload
|
||||
# ./scripts/fleetsuite.sh --rebuild # force a fresh SPA build first
|
||||
#
|
||||
# First run bootstraps the venv + web deps + npm packages automatically.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
DEV=0
|
||||
BROWSER=0
|
||||
REBUILD=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dev) DEV=1 ;;
|
||||
--browser) BROWSER=1 ;;
|
||||
--rebuild) REBUILD=1 ;;
|
||||
-h | --help)
|
||||
sed -n '2,11p' "${BASH_SOURCE[0]}"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown arg: $arg (try --help)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
PY="$ROOT/.venv/bin/python"
|
||||
STATIC="$ROOT/src/meshtastic_mcp/web/static/index.html"
|
||||
|
||||
note() { printf '\033[36m[fleetsuite]\033[0m %s\n' "$*"; }
|
||||
|
||||
# 1. Python venv + web extra ------------------------------------------------
|
||||
if [[ ! -x $PY ]]; then
|
||||
note "creating venv (.venv)…"
|
||||
python3 -m venv "$ROOT/.venv"
|
||||
fi
|
||||
if ! "$PY" -c 'import fastapi, aiosqlite, uvicorn, webview' >/dev/null 2>&1; then
|
||||
note "installing the [web] extra…"
|
||||
"$PY" -m pip install --quiet --upgrade pip
|
||||
"$PY" -m pip install --quiet -e "$ROOT[web]"
|
||||
fi
|
||||
|
||||
# 2. Dev mode: backend + Vite with HMR --------------------------------------
|
||||
if [[ $DEV == 1 ]]; then
|
||||
note "dev mode (backend :8765 + Vite HMR)"
|
||||
exec "$ROOT/scripts/web-dev.sh"
|
||||
fi
|
||||
|
||||
# 3. Frontend deps + production build ----------------------------------------
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "npm not found — install Node.js (https://nodejs.org) to build the UI." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! -d "$ROOT/web-ui/node_modules" ]]; then
|
||||
note "installing web-ui npm packages…"
|
||||
(cd "$ROOT/web-ui" && npm install)
|
||||
fi
|
||||
if [[ $REBUILD == 1 || ! -f $STATIC ]]; then
|
||||
note "building the SPA…"
|
||||
(cd "$ROOT/web-ui" && npm run build)
|
||||
fi
|
||||
|
||||
# 4. Launch ------------------------------------------------------------------
|
||||
if [[ $BROWSER == 1 ]]; then
|
||||
note "serving at http://127.0.0.1:8765 (Ctrl-C to stop)"
|
||||
exec "$ROOT/.venv/bin/meshtastic-mcp-web" --browser
|
||||
fi
|
||||
note "opening FleetSuite window…"
|
||||
exec "$ROOT/.venv/bin/meshtastic-mcp-web"
|
||||
Executable
+32
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the FleetSuite backend (uvicorn, :8765) and the Vite dev server together,
|
||||
# with HMR. The Vite server proxies /api and /ws to the backend. Ctrl-C stops
|
||||
# both. For a single-process production run instead, build the SPA once
|
||||
# (`cd web-ui && npm run build`) and launch `meshtastic-mcp-web`.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
PY="${ROOT}/.venv/bin/python"
|
||||
if [[ ! -x $PY ]]; then
|
||||
echo "No venv at .venv — run: python3 -m venv .venv && .venv/bin/pip install -e '.[web,test]'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
trap - INT TERM
|
||||
[[ -n ${BACK_PID-} ]] && kill "$BACK_PID" 2>/dev/null || true
|
||||
[[ -n ${UI_PID-} ]] && kill "$UI_PID" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
echo "[web-dev] starting backend on http://127.0.0.1:8765 …"
|
||||
"$PY" -m uvicorn meshtastic_mcp.web.app:create_app --factory --port 8765 --reload &
|
||||
BACK_PID=$!
|
||||
|
||||
echo "[web-dev] starting Vite dev server …"
|
||||
(cd web-ui && npm run dev) &
|
||||
UI_PID=$!
|
||||
|
||||
wait
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
"""FleetSuite — the FastAPI + WebSocket backend that serves the Vue SPA and
|
||||
drives the Meshtastic test harness. Replaces the old Textual TUI.
|
||||
|
||||
Layout:
|
||||
db/ — aiosqlite registry (devices, cameras, flash, runs, builds, settings)
|
||||
services/ — identity reconciliation, control gating, firmware builds, the
|
||||
pytest runner, the Datadog forwarder
|
||||
ws/ — the single broadcast hub backing ``/ws``
|
||||
app.py — ``create_app()`` factory (REST + ``/ws``, serves ``web/static``)
|
||||
__main__.py — ``main()``: serve + open a pywebview window (``--browser`` to skip it)
|
||||
"""
|
||||
@@ -0,0 +1,79 @@
|
||||
"""FleetSuite entrypoint (the ``meshtastic-mcp-web`` console script).
|
||||
|
||||
Default: serve the API + built SPA on 127.0.0.1:8765 and open a pywebview
|
||||
desktop window pointed at it. ``--browser`` serves only (no window) so you can
|
||||
open it in any browser — also the mode used in headless/CI and by agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import uvicorn
|
||||
|
||||
HOST = "127.0.0.1"
|
||||
PORT = 8765
|
||||
|
||||
|
||||
def _serve(server: uvicorn.Server) -> None:
|
||||
server.run()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="meshtastic-mcp-web", description=__doc__)
|
||||
parser.add_argument(
|
||||
"--browser",
|
||||
action="store_true",
|
||||
help="serve only (no desktop window) — open http://127.0.0.1:8765 yourself",
|
||||
)
|
||||
parser.add_argument("--host", default=HOST)
|
||||
parser.add_argument("--port", type=int, default=PORT)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s"
|
||||
)
|
||||
|
||||
config = uvicorn.Config(
|
||||
"meshtastic_mcp.web.app:create_app",
|
||||
factory=True,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level="info",
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
if args.browser:
|
||||
server.run()
|
||||
return
|
||||
|
||||
# Desktop window: run uvicorn on a background thread, open pywebview on the
|
||||
# main thread (some platforms require the GUI loop to own the main thread).
|
||||
try:
|
||||
import webview # type: ignore
|
||||
except Exception: # noqa: BLE001
|
||||
logging.warning("pywebview unavailable — falling back to --browser mode")
|
||||
server.run()
|
||||
return
|
||||
|
||||
thread = threading.Thread(target=_serve, args=(server,), daemon=True)
|
||||
thread.start()
|
||||
|
||||
# Wait for the server to bind before pointing the window at it.
|
||||
deadline = time.monotonic() + 15
|
||||
while not server.started and time.monotonic() < deadline:
|
||||
time.sleep(0.1)
|
||||
|
||||
webview.create_window(
|
||||
"FleetSuite", f"http://{args.host}:{args.port}", width=1400, height=900
|
||||
)
|
||||
webview.start()
|
||||
server.should_exit = True
|
||||
thread.join(timeout=5)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,611 @@
|
||||
"""FastAPI application factory for FleetSuite.
|
||||
|
||||
``create_app()`` wires the registry, the broadcast hub, and the services
|
||||
together in a lifespan, mounts the REST API + the single ``/ws`` socket, and
|
||||
serves the built Vue SPA from ``web/static``. Blocking library calls (serial
|
||||
I/O, pio, git) are dispatched to a thread so the event loop stays responsive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Body, FastAPI, HTTPException, Request, WebSocket
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from meshtastic_mcp import (
|
||||
admin,
|
||||
boards,
|
||||
fixtures,
|
||||
flash as flash_lib,
|
||||
info as mt_info,
|
||||
log_query,
|
||||
)
|
||||
|
||||
from .db import repo_builds as rb
|
||||
from .db import repo_cameras as rc
|
||||
from .db import repo_devices as rd
|
||||
from .db import repo_flash as rf
|
||||
from .db import repo_runs as rr
|
||||
from .db.database import Database, default_db_path
|
||||
from .services import (
|
||||
builder,
|
||||
camera_stream,
|
||||
control,
|
||||
datadog,
|
||||
discovery,
|
||||
firmware,
|
||||
identity,
|
||||
native,
|
||||
power,
|
||||
serial_monitor,
|
||||
test_runner,
|
||||
)
|
||||
from .services.control import ControlBusy
|
||||
from .services.power import AmbiguousPort, NoPort
|
||||
from .ws.hub import Connection, Hub
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web")
|
||||
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
def _busy_guard(exc: ControlBusy) -> HTTPException:
|
||||
return HTTPException(status_code=409, detail=str(exc))
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="FleetSuite", version="0.1.0")
|
||||
|
||||
# --- lifespan: own the db + services for the process lifetime ----------
|
||||
@app.on_event("startup")
|
||||
async def _startup() -> None:
|
||||
db = await Database(default_db_path()).connect()
|
||||
hub = Hub()
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
|
||||
app.state.db = db
|
||||
app.state.hub = hub
|
||||
app.state.orch = builder.BuildOrchestrator(db, hub)
|
||||
app.state.runner = test_runner.TestRunner(db, hub)
|
||||
app.state.forwarder = datadog.DDForwarder(db, hub)
|
||||
await app.state.forwarder.reload()
|
||||
app.state.serialmon = serial_monitor.SerialMonitor(db, hub)
|
||||
# Discovery auto-enriches devices, suspending their serial monitor for
|
||||
# the connect — so it needs the monitor handle.
|
||||
app.state.discovery = discovery.DeviceDiscovery(
|
||||
db, hub, serialmon=app.state.serialmon
|
||||
)
|
||||
app.state.discovery.start()
|
||||
log.info("FleetSuite started — registry at %s", db.path)
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def _shutdown() -> None:
|
||||
disc = getattr(app.state, "discovery", None)
|
||||
if disc:
|
||||
await disc.stop()
|
||||
sm = getattr(app.state, "serialmon", None)
|
||||
if sm:
|
||||
await sm.shutdown()
|
||||
db = getattr(app.state, "db", None)
|
||||
if db:
|
||||
await db.close()
|
||||
|
||||
api = APIRouter(prefix="/api")
|
||||
_mount_devices(api)
|
||||
_mount_cameras(api)
|
||||
_mount_firmware(api)
|
||||
_mount_builds(api)
|
||||
_mount_datadog(api)
|
||||
_mount_tests(api)
|
||||
_mount_native(api)
|
||||
_mount_boards(api)
|
||||
_mount_hubs(api)
|
||||
app.include_router(api)
|
||||
_mount_ws(app)
|
||||
|
||||
@app.exception_handler(ControlBusy)
|
||||
async def _busy(_req: Request, exc: ControlBusy):
|
||||
return JSONResponse(status_code=409, content={"detail": str(exc)})
|
||||
|
||||
if STATIC_DIR.is_dir():
|
||||
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="spa")
|
||||
else:
|
||||
log.warning("no built SPA at %s — run `npm run build` in web-ui/", STATIC_DIR)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# --- helpers ---------------------------------------------------------------
|
||||
async def _device_or_404(db: Database, serial: str) -> dict:
|
||||
row = await rd.get(db, serial)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"unknown device: {serial}")
|
||||
return row
|
||||
|
||||
|
||||
def _gate_idle() -> None:
|
||||
try:
|
||||
control._ensure_idle()
|
||||
except ControlBusy as exc:
|
||||
raise _busy_guard(exc)
|
||||
|
||||
|
||||
async def _port_action(request: Request, serial: str, fn, *args):
|
||||
"""Run a blocking port-bound library call, suspending any live serial
|
||||
monitor for the device so the USB port is free, then resuming it."""
|
||||
_gate_idle()
|
||||
sm = request.app.state.serialmon
|
||||
await sm.suspend(serial)
|
||||
try:
|
||||
return await asyncio.to_thread(fn, *args)
|
||||
finally:
|
||||
await sm.resume(serial)
|
||||
|
||||
|
||||
# --- devices ---------------------------------------------------------------
|
||||
def _mount_devices(api: APIRouter) -> None:
|
||||
@api.get("/devices")
|
||||
async def list_devices(request: Request):
|
||||
return await rd.list_all(request.app.state.db)
|
||||
|
||||
@api.patch("/devices/{serial}")
|
||||
async def patch_device(serial: str, request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await _device_or_404(db, serial)
|
||||
if "friendly_name" in body:
|
||||
dev = await rd.set_friendly_name(db, serial, body["friendly_name"])
|
||||
else:
|
||||
dev = await rd.get(db, serial)
|
||||
await hub.publish("device.update", dev)
|
||||
return dev
|
||||
|
||||
@api.put("/devices/{serial}/env")
|
||||
async def set_env(serial: str, request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await _device_or_404(db, serial)
|
||||
env = body.get("env")
|
||||
# A provided env pins it; clearing it releases the pin to auto-detect.
|
||||
dev = await rd.set_env(db, serial, env, locked=env is not None)
|
||||
await hub.publish("device.update", dev)
|
||||
return dev
|
||||
|
||||
@api.post("/devices/{serial}/refresh")
|
||||
async def refresh(serial: str, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
row = await _device_or_404(db, serial)
|
||||
port = row.get("current_port")
|
||||
info = await _port_action(request, serial, mt_info.device_info, port)
|
||||
hw_model = info.get("hw_model")
|
||||
env = identity.env_for_hw_model(hw_model) if hw_model else None
|
||||
dev = await rd.update_enrichment(
|
||||
db,
|
||||
serial,
|
||||
node_num=info.get("my_node_num"),
|
||||
env=env,
|
||||
hw_model=hw_model,
|
||||
firmware_version=info.get("firmware_version"),
|
||||
region=info.get("region"),
|
||||
)
|
||||
await hub.publish("device.update", dev)
|
||||
return {"device": dev}
|
||||
|
||||
@api.get("/devices/{serial}/flash-stats")
|
||||
async def flash_stats(serial: str, request: Request):
|
||||
return await rf.comparison(request.app.state.db, serial)
|
||||
|
||||
@api.post("/devices/{serial}/flash")
|
||||
async def flash_device(serial: str, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
row = await _device_or_404(db, serial)
|
||||
env = control.env_for_device(row)
|
||||
port = row.get("current_port")
|
||||
if not env or not port:
|
||||
raise HTTPException(status_code=400, detail="no env/port resolved")
|
||||
loop = asyncio.get_running_loop()
|
||||
start = loop.time()
|
||||
result = await _port_action(
|
||||
request, serial, lambda: flash_lib.flash(env, port, confirm=True)
|
||||
)
|
||||
duration = round(loop.time() - start, 2)
|
||||
ok = result.get("exit_code") == 0
|
||||
fw = firmware.firmware_ref()
|
||||
await rf.record(
|
||||
db,
|
||||
device_serial=serial,
|
||||
env=env,
|
||||
fw_sha=fw.get("sha"),
|
||||
from_artifact=False,
|
||||
duration_s=duration,
|
||||
ok=ok,
|
||||
)
|
||||
if ok:
|
||||
await rd.record_flashed(db, serial, branch=fw.get("branch"), sha=fw.get("sha"))
|
||||
await hub.publish("device.update", await rd.get(db, serial))
|
||||
return {"ok": ok, "duration_s": duration, **result}
|
||||
|
||||
@api.post("/devices/{serial}/reboot")
|
||||
async def reboot(serial: str, request: Request):
|
||||
row = await _device_or_404(request.app.state.db, serial)
|
||||
return await _port_action(request, serial, admin.reboot, row.get("current_port"), True, 5)
|
||||
|
||||
@api.post("/devices/{serial}/factory-reset")
|
||||
async def factory_reset(serial: str, request: Request):
|
||||
row = await _device_or_404(request.app.state.db, serial)
|
||||
return await _port_action(
|
||||
request, serial, admin.factory_reset, row.get("current_port"), True
|
||||
)
|
||||
|
||||
@api.post("/devices/{serial}/send-text")
|
||||
async def send_text(serial: str, request: Request, body: dict = Body(...)):
|
||||
row = await _device_or_404(request.app.state.db, serial)
|
||||
text = body.get("text", "")
|
||||
return await _port_action(
|
||||
request, serial, admin.send_text, text, None, 0, False, row.get("current_port")
|
||||
)
|
||||
|
||||
@api.post("/devices/{serial}/inject-nodedb")
|
||||
async def inject_nodedb(serial: str, request: Request, body: dict = Body(...)):
|
||||
row = await _device_or_404(request.app.state.db, serial)
|
||||
size = int(body.get("size", 500))
|
||||
return await _port_action(request, serial, _inject, size, row.get("current_port"))
|
||||
|
||||
@api.get("/devices/{serial}/config")
|
||||
async def get_config(serial: str, request: Request, section: str | None = None):
|
||||
row = await _device_or_404(request.app.state.db, serial)
|
||||
return await _port_action(
|
||||
request, serial, admin.get_config, section, row.get("current_port")
|
||||
)
|
||||
|
||||
@api.put("/devices/{serial}/config")
|
||||
async def set_config(serial: str, request: Request, body: dict = Body(...)):
|
||||
row = await _device_or_404(request.app.state.db, serial)
|
||||
path = body.get("path")
|
||||
if not path:
|
||||
raise HTTPException(status_code=400, detail="missing config path")
|
||||
return await _port_action(
|
||||
request, serial, admin.set_config, path, body.get("value"), row.get("current_port")
|
||||
)
|
||||
|
||||
@api.get("/devices/{serial}/packets")
|
||||
async def device_packets(
|
||||
serial: str, request: Request, start: str = "-30m", max: int = 100
|
||||
):
|
||||
await _device_or_404(request.app.state.db, serial)
|
||||
# Recorder packets are mesh-wide, not keyed by USB port — return the
|
||||
# recent window so the per-device tab has live traffic to show.
|
||||
window = await asyncio.to_thread(
|
||||
lambda: log_query.packets_window(start, "now", max=max)
|
||||
)
|
||||
return {"packets": window.get("packets", [])}
|
||||
|
||||
@api.get("/devices/{serial}/test-results")
|
||||
async def device_test_results(serial: str, request: Request, limit: int = 100):
|
||||
rows = await rr.results_for_device(request.app.state.db, serial)
|
||||
return rows[:limit]
|
||||
|
||||
# --- per-device USB power (uhubctl) ----------------------------------
|
||||
@api.put("/devices/{serial}/hub-port")
|
||||
async def set_hub_port(serial: str, request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await _device_or_404(db, serial)
|
||||
loc = body.get("location")
|
||||
port = body.get("port")
|
||||
dev = await rd.set_hub_port(
|
||||
db, serial, location=loc, port=int(port) if port is not None else None
|
||||
)
|
||||
await hub.publish("device.update", dev)
|
||||
return dev
|
||||
|
||||
@api.post("/devices/{serial}/locate")
|
||||
async def locate_device(serial: str, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await _device_or_404(db, serial)
|
||||
try:
|
||||
res = await power.locate(db, serial)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
if res["located"]:
|
||||
await hub.publish("device.update", res["device"])
|
||||
return res
|
||||
|
||||
@api.post("/devices/{serial}/power/{action}")
|
||||
async def power_action(serial: str, action: str, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await _device_or_404(db, serial)
|
||||
if action not in ("on", "off", "cycle"):
|
||||
raise HTTPException(status_code=404, detail="unknown power action")
|
||||
_gate_idle()
|
||||
# Free the port from any live serial monitor before toggling VBUS.
|
||||
await request.app.state.serialmon.suspend(serial)
|
||||
try:
|
||||
result = await power.power_device(db, serial, action)
|
||||
except AmbiguousPort as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={"error": str(exc), "candidates": exc.candidates},
|
||||
)
|
||||
except (NoPort, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except RuntimeError as exc: # uhubctl errors (permissions, hub gone)
|
||||
raise HTTPException(status_code=502, detail=str(exc))
|
||||
finally:
|
||||
if action != "off":
|
||||
await request.app.state.serialmon.resume(serial)
|
||||
return result
|
||||
|
||||
|
||||
def _inject(size: int, port: str | None) -> dict:
|
||||
return fixtures.push_fake_nodedb(
|
||||
size, target="hardware", port=port, confirm=True, reboot_after=True
|
||||
)
|
||||
|
||||
|
||||
# --- cameras ---------------------------------------------------------------
|
||||
def _mount_cameras(api: APIRouter) -> None:
|
||||
@api.get("/cameras")
|
||||
async def list_cameras(request: Request):
|
||||
return await rc.list_all(request.app.state.db)
|
||||
|
||||
@api.get("/cameras/discover")
|
||||
async def discover_cameras(request: Request):
|
||||
# Indices already bound to a FleetSuite camera — don't re-open those.
|
||||
in_use = {
|
||||
str(c["device_index"])
|
||||
for c in await rc.list_all(request.app.state.db)
|
||||
if c.get("device_index") is not None
|
||||
}
|
||||
return await asyncio.to_thread(camera_stream.discover, in_use)
|
||||
|
||||
@api.post("/cameras")
|
||||
async def add_camera(request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
cid = await rc.add(
|
||||
db, name=body.get("name", "camera"), device_index=str(body.get("device_index", "0"))
|
||||
)
|
||||
cam = await rc.get(db, cid)
|
||||
await hub.publish("camera.update", cam)
|
||||
return cam
|
||||
|
||||
@api.delete("/cameras/{cid}", status_code=204)
|
||||
async def remove_camera(cid: int, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await rc.remove(db, cid)
|
||||
await hub.publish("camera.update", {"id": cid, "deleted": True})
|
||||
|
||||
@api.post("/cameras/{cid}/assign")
|
||||
async def assign_camera(cid: int, request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
cam = await rc.assign(db, cid, body.get("device_serial"))
|
||||
if cam is None:
|
||||
raise HTTPException(status_code=404, detail="unknown camera")
|
||||
await hub.publish("camera.update", cam)
|
||||
return cam
|
||||
|
||||
@api.post("/cameras/{cid}/rotation")
|
||||
async def rotate_camera(cid: int, request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
cam = await rc.set_rotation(db, cid, int(body.get("rotation", 0)))
|
||||
if cam is None:
|
||||
raise HTTPException(status_code=404, detail="unknown camera")
|
||||
await hub.publish("camera.update", cam)
|
||||
return cam
|
||||
|
||||
@api.post("/cameras/{cid}/mirror")
|
||||
async def mirror_camera(cid: int, request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
cam = await rc.set_mirror(db, cid, bool(body.get("mirror", False)))
|
||||
if cam is None:
|
||||
raise HTTPException(status_code=404, detail="unknown camera")
|
||||
await hub.publish("camera.update", cam)
|
||||
return cam
|
||||
|
||||
@api.get("/cameras/{cid}/status")
|
||||
async def camera_status(cid: int, request: Request):
|
||||
cam = await rc.get(request.app.state.db, cid)
|
||||
if cam is None:
|
||||
raise HTTPException(status_code=404, detail="unknown camera")
|
||||
return await asyncio.to_thread(camera_stream.probe, str(cam.get("device_index")))
|
||||
|
||||
@api.get("/cameras/{cid}/stream.mjpg")
|
||||
async def camera_stream_ep(cid: int, request: Request):
|
||||
cam = await rc.get(request.app.state.db, cid)
|
||||
if cam is None:
|
||||
raise HTTPException(status_code=404, detail="unknown camera")
|
||||
probe = await asyncio.to_thread(camera_stream.probe, str(cam.get("device_index")))
|
||||
if not probe["ok"]:
|
||||
raise HTTPException(status_code=503, detail=probe["error"])
|
||||
return StreamingResponse(
|
||||
camera_stream.mjpeg(str(cam.get("device_index"))),
|
||||
media_type=f"multipart/x-mixed-replace; boundary={camera_stream.BOUNDARY}",
|
||||
)
|
||||
|
||||
|
||||
# --- firmware --------------------------------------------------------------
|
||||
def _mount_firmware(api: APIRouter) -> None:
|
||||
@api.get("/firmware")
|
||||
async def get_firmware():
|
||||
return await asyncio.to_thread(firmware.firmware_ref)
|
||||
|
||||
|
||||
# --- builds ----------------------------------------------------------------
|
||||
def _mount_builds(api: APIRouter) -> None:
|
||||
@api.get("/builds")
|
||||
async def list_builds(request: Request):
|
||||
db = request.app.state.db
|
||||
return {
|
||||
"docker": await asyncio.to_thread(builder.docker_available),
|
||||
"builds": await rb.list_all(db),
|
||||
}
|
||||
|
||||
@api.post("/builds")
|
||||
async def enqueue_builds(request: Request, body: dict = Body(default={})):
|
||||
db = request.app.state.db
|
||||
orch = request.app.state.orch
|
||||
fw = await asyncio.to_thread(firmware.firmware_ref)
|
||||
if not fw.get("available"):
|
||||
raise HTTPException(status_code=400, detail="no firmware checkout")
|
||||
sha, branch = fw["sha"], fw.get("branch")
|
||||
|
||||
envs = body.get("envs")
|
||||
if not envs:
|
||||
# Prebuild the envs every online, env-resolved device needs.
|
||||
envs = sorted(
|
||||
{control.env_for_device(d) for d in await rd.online_with_env(db)}
|
||||
- {None}
|
||||
)
|
||||
if not envs:
|
||||
return []
|
||||
return await orch.enqueue(
|
||||
list(envs), sha=sha, branch=branch, force=bool(body.get("force"))
|
||||
)
|
||||
|
||||
|
||||
# --- datadog ---------------------------------------------------------------
|
||||
def _mount_datadog(api: APIRouter) -> None:
|
||||
@api.get("/datadog")
|
||||
async def get_datadog(request: Request):
|
||||
return request.app.state.forwarder.status()
|
||||
|
||||
@api.put("/datadog")
|
||||
async def put_datadog(request: Request, body: dict = Body(...)):
|
||||
db = request.app.state.db
|
||||
fwd = request.app.state.forwarder
|
||||
cfg = await datadog.load_config(db)
|
||||
for key in ("enabled", "site", "scrub", "collector", "host", "ship_debug"):
|
||||
if key in body:
|
||||
setattr(cfg, key, body[key])
|
||||
# Only overwrite the key if a (non-empty) one was supplied.
|
||||
if body.get("api_key"):
|
||||
cfg.api_key = body["api_key"]
|
||||
await datadog.save_config(db, cfg)
|
||||
await fwd.reload()
|
||||
status = fwd.status()
|
||||
await request.app.state.hub.publish("datadog.update", status)
|
||||
return status
|
||||
|
||||
@api.post("/datadog/test")
|
||||
async def test_datadog(request: Request):
|
||||
fwd = request.app.state.forwarder
|
||||
await fwd.reload()
|
||||
return await asyncio.to_thread(fwd.test_key)
|
||||
|
||||
|
||||
# --- tests -----------------------------------------------------------------
|
||||
def _mount_tests(api: APIRouter) -> None:
|
||||
@api.get("/tests/status")
|
||||
async def tests_status():
|
||||
return test_runner.status()
|
||||
|
||||
@api.get("/tests/runs")
|
||||
async def tests_runs(request: Request):
|
||||
return await rr.list_runs(request.app.state.db)
|
||||
|
||||
@api.post("/tests/start")
|
||||
async def tests_start(request: Request, body: dict = Body(default={})):
|
||||
runner = request.app.state.runner
|
||||
try:
|
||||
return await runner.start(list(body.get("args", [])))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
|
||||
@api.post("/tests/stop", status_code=204)
|
||||
async def tests_stop(request: Request):
|
||||
await request.app.state.runner.stop()
|
||||
|
||||
|
||||
# --- native ----------------------------------------------------------------
|
||||
def _mount_native(api: APIRouter) -> None:
|
||||
@api.get("/native")
|
||||
async def native_info(request: Request):
|
||||
return await native.info(request.app.state.db)
|
||||
|
||||
@api.post("/native")
|
||||
async def native_create(request: Request, body: dict = Body(...)):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
name = (body.get("name") or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="missing name")
|
||||
try:
|
||||
dev = await native.create(db, name=name, tcp_port=int(body.get("tcp_port", 4403)))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await hub.publish("device.update", dev)
|
||||
return dev
|
||||
|
||||
@api.post("/native/{name}/{action}")
|
||||
async def native_lifecycle(name: str, action: str, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
if action not in ("start", "stop", "restart"):
|
||||
raise HTTPException(status_code=404, detail="unknown action")
|
||||
try:
|
||||
dev = await native.lifecycle(db, name, action)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
await hub.publish("device.update", dev)
|
||||
return dev
|
||||
|
||||
@api.delete("/native/{name}", status_code=204)
|
||||
async def native_delete(name: str, request: Request):
|
||||
db, hub = request.app.state.db, request.app.state.hub
|
||||
await native.remove(db, name)
|
||||
await hub.publish("device.update", {"serial_number": f"native:{name}", "deleted": True})
|
||||
|
||||
|
||||
# --- boards ----------------------------------------------------------------
|
||||
def _mount_boards(api: APIRouter) -> None:
|
||||
@api.get("/boards")
|
||||
async def list_boards(query: str | None = None, architecture: str | None = None):
|
||||
return await asyncio.to_thread(
|
||||
boards.list_boards, architecture, False, query, None
|
||||
)
|
||||
|
||||
|
||||
# --- hubs (uhubctl) --------------------------------------------------------
|
||||
def _mount_hubs(api: APIRouter) -> None:
|
||||
@api.get("/hubs")
|
||||
async def list_hubs():
|
||||
if not power.available():
|
||||
return {"available": False, "hubs": []}
|
||||
try:
|
||||
hubs = await asyncio.to_thread(power.list_hubs)
|
||||
except RuntimeError as exc: # uhubctl present but failed (permissions)
|
||||
return {"available": True, "hubs": [], "error": str(exc)}
|
||||
return {"available": True, "hubs": hubs}
|
||||
|
||||
|
||||
# --- websocket -------------------------------------------------------------
|
||||
def _mount_ws(app: FastAPI) -> None:
|
||||
@app.websocket("/ws")
|
||||
async def ws(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
hub: Hub = websocket.app.state.hub
|
||||
sm = websocket.app.state.serialmon
|
||||
conn = Connection(send=websocket.send_json)
|
||||
hub.add(conn)
|
||||
# Track this peer's live serial monitors so we can release them on drop.
|
||||
serials: set[str] = set()
|
||||
try:
|
||||
while True:
|
||||
msg = await websocket.receive_json()
|
||||
action = msg.get("action")
|
||||
topic = msg.get("topic")
|
||||
if action == "subscribe" and topic:
|
||||
hub.subscribe(conn, topic)
|
||||
if topic.startswith("serial.") and topic not in serials:
|
||||
serials.add(topic)
|
||||
await sm.acquire(topic[len("serial.") :])
|
||||
elif action == "unsubscribe" and topic:
|
||||
hub.unsubscribe(conn, topic)
|
||||
if topic in serials:
|
||||
serials.discard(topic)
|
||||
await sm.release(topic[len("serial.") :])
|
||||
except Exception: # noqa: BLE001 - normal on client disconnect
|
||||
pass
|
||||
finally:
|
||||
hub.remove(conn)
|
||||
for topic in serials:
|
||||
await sm.release(topic[len("serial.") :])
|
||||
@@ -0,0 +1,3 @@
|
||||
"""SQLite registry for FleetSuite (aiosqlite). One ``Database`` owns the
|
||||
connection; the ``repo_*`` modules are stateless helpers that take it as their
|
||||
first argument."""
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Thin async wrapper over a single aiosqlite connection.
|
||||
|
||||
``row_factory`` is set to ``aiosqlite.Row`` so every fetch behaves like a dict
|
||||
(``row["col"]``); the ``repo_*`` modules copy rows into plain dicts before
|
||||
handing them back so callers can add computed keys.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
|
||||
# --- schema -----------------------------------------------------------------
|
||||
# One file, created on connect. Plain `IF NOT EXISTS` — the registry is a cache
|
||||
# of discovered hardware and recorded history, not a source of truth, so a
|
||||
# wipe-and-rediscover is always safe.
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
serial_number TEXT PRIMARY KEY,
|
||||
node_num INTEGER,
|
||||
friendly_name TEXT,
|
||||
hw_model TEXT,
|
||||
vid TEXT,
|
||||
pid TEXT,
|
||||
role TEXT,
|
||||
current_port TEXT,
|
||||
firmware_version TEXT,
|
||||
region TEXT,
|
||||
env TEXT,
|
||||
env_locked INTEGER NOT NULL DEFAULT 0,
|
||||
flashed_fw_branch TEXT,
|
||||
flashed_fw_sha TEXT,
|
||||
flashed_at REAL,
|
||||
online INTEGER NOT NULL DEFAULT 0,
|
||||
first_seen REAL NOT NULL DEFAULT 0,
|
||||
last_seen REAL NOT NULL DEFAULT 0,
|
||||
kind TEXT NOT NULL DEFAULT 'usb', -- 'usb' | 'native'
|
||||
tcp_port INTEGER,
|
||||
hub_location TEXT, -- uhubctl hub location (e.g. 1-1.3)
|
||||
hub_port INTEGER -- uhubctl port number on that hub
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cameras (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'usb',
|
||||
device_index TEXT,
|
||||
backend TEXT,
|
||||
rotation INTEGER NOT NULL DEFAULT 0,
|
||||
mirror INTEGER NOT NULL DEFAULT 0,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
created_at REAL NOT NULL DEFAULT 0,
|
||||
device_serial TEXT,
|
||||
assigned_at REAL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS flash_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
device_serial TEXT NOT NULL,
|
||||
env TEXT,
|
||||
fw_sha TEXT,
|
||||
from_artifact INTEGER NOT NULL DEFAULT 0,
|
||||
duration_s REAL,
|
||||
ok INTEGER NOT NULL DEFAULT 1,
|
||||
ts REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
started_at REAL NOT NULL DEFAULT 0,
|
||||
finished_at REAL,
|
||||
exit_code INTEGER,
|
||||
args TEXT,
|
||||
seed TEXT,
|
||||
fw_branch TEXT,
|
||||
fw_sha TEXT,
|
||||
fw_dirty INTEGER NOT NULL DEFAULT 0,
|
||||
passed INTEGER NOT NULL DEFAULT 0,
|
||||
failed INTEGER NOT NULL DEFAULT 0,
|
||||
skipped INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
run_id INTEGER NOT NULL,
|
||||
nodeid TEXT NOT NULL,
|
||||
tier TEXT,
|
||||
outcome TEXT,
|
||||
duration_s REAL,
|
||||
device_serial TEXT,
|
||||
longrepr TEXT,
|
||||
ts REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_device ON results(device_serial);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_run ON results(run_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS builds (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
env TEXT NOT NULL,
|
||||
fw_sha TEXT NOT NULL,
|
||||
fw_branch TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'queued',
|
||||
duration_s REAL,
|
||||
artifact_dir TEXT,
|
||||
error TEXT,
|
||||
created_at REAL NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_builds_key ON builds(env, fw_sha);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
def default_db_path() -> Path:
|
||||
"""Where the registry lives for a normal (non-test) run."""
|
||||
env = os.environ.get("MESHTASTIC_MCP_WEB_DB")
|
||||
if env:
|
||||
return Path(env)
|
||||
return Path.home() / ".meshtastic_mcp" / "fleetsuite.db"
|
||||
|
||||
|
||||
class Database:
|
||||
"""Owns one aiosqlite connection. ``await connect()`` before use, and
|
||||
``await close()`` when done. Helpers commit after every write — the access
|
||||
pattern is low-volume bench traffic, not a hot loop."""
|
||||
|
||||
def __init__(self, path: Path | str) -> None:
|
||||
self.path = Path(path)
|
||||
self._conn: aiosqlite.Connection | None = None
|
||||
|
||||
async def connect(self) -> "Database":
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = await aiosqlite.connect(str(self.path))
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._conn.execute("PRAGMA journal_mode=WAL;")
|
||||
await self._conn.executescript(SCHEMA)
|
||||
await self._migrate()
|
||||
await self._conn.commit()
|
||||
return self
|
||||
|
||||
async def _migrate(self) -> None:
|
||||
"""Additive column migrations for schema added after a db was first
|
||||
created. ``ALTER TABLE ADD COLUMN`` is idempotent here — a duplicate on
|
||||
a fresh db (already created with the column) is caught and ignored."""
|
||||
additions = (
|
||||
("cameras", "mirror", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("devices", "hub_location", "TEXT"),
|
||||
("devices", "hub_port", "INTEGER"),
|
||||
)
|
||||
for table, col, decl in additions:
|
||||
try:
|
||||
await self._conn.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN {col} {decl}"
|
||||
)
|
||||
except Exception: # noqa: BLE001 - column already present
|
||||
pass
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._conn is not None:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def conn(self) -> aiosqlite.Connection:
|
||||
if self._conn is None:
|
||||
raise RuntimeError("Database.connect() not called")
|
||||
return self._conn
|
||||
|
||||
async def execute(self, sql: str, params: tuple = ()) -> aiosqlite.Cursor:
|
||||
cur = await self.conn.execute(sql, params)
|
||||
await self.conn.commit()
|
||||
return cur
|
||||
|
||||
async def fetchone(self, sql: str, params: tuple = ()):
|
||||
async with self.conn.execute(sql, params) as cur:
|
||||
return await cur.fetchone()
|
||||
|
||||
async def fetchall(self, sql: str, params: tuple = ()):
|
||||
async with self.conn.execute(sql, params) as cur:
|
||||
return await cur.fetchall()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Firmware-build ledger, keyed by (env, fw_sha). The orchestrator records one
|
||||
row per build attempt; ``get`` returns the latest for a key so a cache hit can
|
||||
be distinguished from a fresh queue."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .database import Database
|
||||
|
||||
_COLS = (
|
||||
"id, env, fw_sha, fw_branch, status, duration_s, artifact_dir, error, "
|
||||
"created_at"
|
||||
)
|
||||
|
||||
|
||||
def _to_dict(row) -> dict | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
async def create(
|
||||
db: Database, *, env: str, fw_sha: str, fw_branch: str | None, status: str
|
||||
) -> int:
|
||||
cur = await db.execute(
|
||||
"INSERT INTO builds (env, fw_sha, fw_branch, status, created_at) "
|
||||
"VALUES (?,?,?,?,?)",
|
||||
(env, fw_sha, fw_branch, status, time.time()),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
async def set_status(
|
||||
db: Database,
|
||||
build_id: int,
|
||||
*,
|
||||
status: str,
|
||||
duration_s: float | None = None,
|
||||
artifact_dir: str | None = None,
|
||||
error: str | None = None,
|
||||
) -> dict | None:
|
||||
await db.execute(
|
||||
"UPDATE builds SET status=?, duration_s=?, artifact_dir=?, error=? "
|
||||
"WHERE id=?",
|
||||
(status, duration_s, artifact_dir, error, build_id),
|
||||
)
|
||||
return await get_by_id(db, build_id)
|
||||
|
||||
|
||||
async def get_by_id(db: Database, build_id: int) -> dict | None:
|
||||
row = await db.fetchone(f"SELECT {_COLS} FROM builds WHERE id=?", (build_id,))
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def get(db: Database, env: str, fw_sha: str) -> dict | None:
|
||||
"""Latest build row for a key, or None if it was never attempted."""
|
||||
row = await db.fetchone(
|
||||
f"SELECT {_COLS} FROM builds WHERE env=? AND fw_sha=? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(env, fw_sha),
|
||||
)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def list_all(db: Database, limit: int = 100) -> list[dict]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT {_COLS} FROM builds ORDER BY id DESC LIMIT ?", (limit,)
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Camera registry. A camera is an independent entity (a USB capture device)
|
||||
that can be *assigned* to a device serial; rotation is a property of the camera
|
||||
mount, so it survives reassignment."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .database import Database
|
||||
|
||||
_COLS = (
|
||||
"id, name, type, device_index, backend, rotation, mirror, enabled, "
|
||||
"created_at, device_serial, assigned_at"
|
||||
)
|
||||
|
||||
|
||||
def _to_dict(row) -> dict | None:
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def _normalize_rotation(rotation: int) -> int:
|
||||
"""Snap to the nearest quarter-turn and wrap into [0, 360)."""
|
||||
return int(round((rotation % 360) / 90.0)) * 90 % 360
|
||||
|
||||
|
||||
async def add(db: Database, *, name: str, device_index: str) -> int:
|
||||
cur = await db.execute(
|
||||
"INSERT INTO cameras (name, type, device_index, rotation, enabled, "
|
||||
"created_at) VALUES (?, 'usb', ?, 0, 1, ?)",
|
||||
(name, device_index, time.time()),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
async def get(db: Database, cid: int) -> dict | None:
|
||||
row = await db.fetchone(f"SELECT {_COLS} FROM cameras WHERE id=?", (cid,))
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def list_all(db: Database) -> list[dict]:
|
||||
rows = await db.fetchall(f"SELECT {_COLS} FROM cameras ORDER BY id")
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
async def remove(db: Database, cid: int) -> None:
|
||||
await db.execute("DELETE FROM cameras WHERE id=?", (cid,))
|
||||
|
||||
|
||||
async def assign(db: Database, cid: int, device_serial: str | None) -> dict | None:
|
||||
await db.execute(
|
||||
"UPDATE cameras SET device_serial=?, assigned_at=? WHERE id=?",
|
||||
(device_serial, time.time() if device_serial else None, cid),
|
||||
)
|
||||
return await get(db, cid)
|
||||
|
||||
|
||||
async def for_device(db: Database, serial: str) -> dict | None:
|
||||
row = await db.fetchone(
|
||||
f"SELECT {_COLS} FROM cameras WHERE device_serial=? LIMIT 1", (serial,)
|
||||
)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def set_rotation(db: Database, cid: int, rotation: int) -> dict | None:
|
||||
await db.execute(
|
||||
"UPDATE cameras SET rotation=? WHERE id=?",
|
||||
(_normalize_rotation(rotation), cid),
|
||||
)
|
||||
return await get(db, cid)
|
||||
|
||||
|
||||
async def set_mirror(db: Database, cid: int, mirror: bool) -> dict | None:
|
||||
"""Horizontal flip — a property of the camera mount, like rotation."""
|
||||
await db.execute(
|
||||
"UPDATE cameras SET mirror=? WHERE id=?", (int(bool(mirror)), cid)
|
||||
)
|
||||
return await get(db, cid)
|
||||
|
||||
|
||||
async def set_backend(db: Database, cid: int, backend: str | None) -> None:
|
||||
await db.execute("UPDATE cameras SET backend=? WHERE id=?", (backend, cid))
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Device registry — the heart of the harness's identity model.
|
||||
|
||||
A device is keyed by its USB serial number so its row (and everything attached
|
||||
to it — friendly name, camera, pinned env, flash/test history) *follows* it
|
||||
across ports and reboots. Discovery never deletes rows; it only flips ``online``
|
||||
so a unplugged device greys out instead of vanishing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from ..services import identity
|
||||
from .database import Database
|
||||
|
||||
_COLS = (
|
||||
"serial_number, node_num, friendly_name, hw_model, vid, pid, role, "
|
||||
"current_port, firmware_version, region, env, env_locked, "
|
||||
"flashed_fw_branch, flashed_fw_sha, flashed_at, online, first_seen, "
|
||||
"last_seen, kind, tcp_port, hub_location, hub_port"
|
||||
)
|
||||
|
||||
|
||||
def _to_dict(row) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
serial = d.get("serial_number") or ""
|
||||
d["has_stable_id"] = identity.has_stable_id(serial)
|
||||
# A device is "stale" once it has gone offline — the card stays but greys.
|
||||
d["stale"] = not bool(d.get("online"))
|
||||
return d
|
||||
|
||||
|
||||
async def get(db: Database, serial: str) -> dict | None:
|
||||
row = await db.fetchone(
|
||||
f"SELECT {_COLS} FROM devices WHERE serial_number=?", (serial,)
|
||||
)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def list_all(db: Database) -> list[dict]:
|
||||
rows = await db.fetchall(f"SELECT {_COLS} FROM devices ORDER BY first_seen")
|
||||
return [_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def upsert_from_discovery(
|
||||
db: Database,
|
||||
*,
|
||||
serial_number: str,
|
||||
current_port: str,
|
||||
vid: str | None,
|
||||
pid: str | None,
|
||||
role: str | None,
|
||||
) -> dict:
|
||||
"""Insert a freshly-seen device or refresh an existing one.
|
||||
|
||||
Returns the device dict with two transient flags the discovery loop uses to
|
||||
decide what to broadcast: ``_is_new`` and ``_port_changed``. Never clobbers
|
||||
operator-owned fields (friendly_name, pinned env) on re-discovery.
|
||||
"""
|
||||
now = time.time()
|
||||
existing = await get(db, serial_number)
|
||||
if existing is None:
|
||||
await db.execute(
|
||||
"INSERT INTO devices "
|
||||
"(serial_number, current_port, vid, pid, role, kind, online, "
|
||||
" first_seen, last_seen, env_locked) "
|
||||
"VALUES (?,?,?,?,?,'usb',1,?,?,0)",
|
||||
(serial_number, current_port, vid, pid, role, now, now),
|
||||
)
|
||||
row = await get(db, serial_number)
|
||||
row["_is_new"] = True
|
||||
row["_port_changed"] = False
|
||||
return row
|
||||
|
||||
port_changed = existing["current_port"] != current_port
|
||||
await db.execute(
|
||||
"UPDATE devices SET current_port=?, vid=?, pid=?, role=?, online=1, "
|
||||
"last_seen=? WHERE serial_number=?",
|
||||
(current_port, vid, pid, role, now, serial_number),
|
||||
)
|
||||
row = await get(db, serial_number)
|
||||
row["_is_new"] = False
|
||||
row["_port_changed"] = port_changed
|
||||
return row
|
||||
|
||||
|
||||
async def upsert_native(
|
||||
db: Database, *, name: str, tcp_port: int, online: bool = True
|
||||
) -> dict:
|
||||
"""A Docker ``meshtasticd`` node — surfaced as a device with a synthetic
|
||||
``native:<name>`` serial so the same UI/card machinery applies."""
|
||||
now = time.time()
|
||||
serial = f"native:{name}"
|
||||
port = f"tcp://127.0.0.1:{tcp_port}"
|
||||
existing = await get(db, serial)
|
||||
if existing is None:
|
||||
await db.execute(
|
||||
"INSERT INTO devices "
|
||||
"(serial_number, friendly_name, role, current_port, kind, "
|
||||
" tcp_port, online, first_seen, last_seen, env_locked) "
|
||||
"VALUES (?,?, 'native', ?, 'native', ?, ?, ?, ?, 0)",
|
||||
(serial, name, port, tcp_port, int(online), now, now),
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"UPDATE devices SET tcp_port=?, current_port=?, online=?, "
|
||||
"last_seen=? WHERE serial_number=?",
|
||||
(tcp_port, port, int(online), now, serial),
|
||||
)
|
||||
return await get(db, serial)
|
||||
|
||||
|
||||
async def set_friendly_name(db: Database, serial: str, name: str) -> dict | None:
|
||||
await db.execute(
|
||||
"UPDATE devices SET friendly_name=? WHERE serial_number=?", (name, serial)
|
||||
)
|
||||
return await get(db, serial)
|
||||
|
||||
|
||||
async def set_env(
|
||||
db: Database, serial: str, env: str | None, *, locked: bool
|
||||
) -> dict | None:
|
||||
"""Pin (``locked=True``) or release (``locked=False``) the pio env. A pinned
|
||||
env is protected from auto-enrichment; releasing it lets detection win."""
|
||||
await db.execute(
|
||||
"UPDATE devices SET env=?, env_locked=? WHERE serial_number=?",
|
||||
(env, int(locked), serial),
|
||||
)
|
||||
return await get(db, serial)
|
||||
|
||||
|
||||
async def update_enrichment(
|
||||
db: Database,
|
||||
serial: str,
|
||||
*,
|
||||
node_num: int | None = None,
|
||||
env: str | None = None,
|
||||
hw_model: str | None = None,
|
||||
firmware_version: str | None = None,
|
||||
region: str | None = None,
|
||||
) -> dict | None:
|
||||
"""Apply data read off a connected device. ``env`` is only written when the
|
||||
operator has NOT pinned one (``env_locked=0``); the rest always apply."""
|
||||
row = await get(db, serial)
|
||||
if row is None:
|
||||
return None
|
||||
|
||||
sets = ["node_num=COALESCE(?, node_num)"]
|
||||
params: list = [node_num]
|
||||
for col, val in (
|
||||
("hw_model", hw_model),
|
||||
("firmware_version", firmware_version),
|
||||
("region", region),
|
||||
):
|
||||
sets.append(f"{col}=COALESCE(?, {col})")
|
||||
params.append(val)
|
||||
if env is not None and not row["env_locked"]:
|
||||
sets.append("env=?")
|
||||
params.append(env)
|
||||
|
||||
params.append(serial)
|
||||
await db.execute(
|
||||
f"UPDATE devices SET {', '.join(sets)} WHERE serial_number=?", tuple(params)
|
||||
)
|
||||
return await get(db, serial)
|
||||
|
||||
|
||||
async def set_hub_port(
|
||||
db: Database, serial: str, *, location: str | None, port: int | None
|
||||
) -> dict | None:
|
||||
"""Pin (or clear) which uhubctl hub location + port this device sits on, so
|
||||
power actions target the right physical port. Survives port/USB changes —
|
||||
it's the hub slot, not the tty."""
|
||||
await db.execute(
|
||||
"UPDATE devices SET hub_location=?, hub_port=? WHERE serial_number=?",
|
||||
(location, port, serial),
|
||||
)
|
||||
return await get(db, serial)
|
||||
|
||||
|
||||
async def record_flashed(
|
||||
db: Database, serial: str, *, branch: str | None, sha: str | None
|
||||
) -> None:
|
||||
await db.execute(
|
||||
"UPDATE devices SET flashed_fw_branch=?, flashed_fw_sha=?, flashed_at=? "
|
||||
"WHERE serial_number=?",
|
||||
(branch, sha, time.time(), serial),
|
||||
)
|
||||
|
||||
|
||||
async def mark_offline_except(db: Database, keep: set[str]) -> list[str]:
|
||||
"""Flip every currently-online device not in ``keep`` to offline. Returns
|
||||
the serials that *transitioned* (so the caller can broadcast just those)."""
|
||||
rows = await db.fetchall("SELECT serial_number FROM devices WHERE online=1")
|
||||
newly = [r["serial_number"] for r in rows if r["serial_number"] not in keep]
|
||||
for serial in newly:
|
||||
await db.execute(
|
||||
"UPDATE devices SET online=0 WHERE serial_number=?", (serial,)
|
||||
)
|
||||
return newly
|
||||
|
||||
|
||||
async def online_by_role(db: Database, role: str) -> dict | None:
|
||||
row = await db.fetchone(
|
||||
f"SELECT {_COLS} FROM devices WHERE online=1 AND role=? "
|
||||
"ORDER BY last_seen DESC LIMIT 1",
|
||||
(role,),
|
||||
)
|
||||
return _to_dict(row)
|
||||
|
||||
|
||||
async def online_with_env(db: Database) -> list[dict]:
|
||||
"""Online, real (USB) devices that have a resolved env — the candidates the
|
||||
test runner bakes per-board variant overrides from. Native nodes (no flash
|
||||
target) and un-enriched devices (no env yet) are excluded."""
|
||||
rows = await db.fetchall(
|
||||
f"SELECT {_COLS} FROM devices "
|
||||
"WHERE online=1 AND kind='usb' AND env IS NOT NULL"
|
||||
)
|
||||
return [_to_dict(r) for r in rows]
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Flash-timing log. Each flash records whether it came from a prebuilt
|
||||
artifact or a from-scratch host rebuild, so the UI can show how much the
|
||||
artifact cache saves on a given device."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from .database import Database
|
||||
|
||||
_COLS = "id, device_serial, env, fw_sha, from_artifact, duration_s, ok, ts"
|
||||
|
||||
|
||||
async def record(
|
||||
db: Database,
|
||||
*,
|
||||
device_serial: str,
|
||||
env: str | None,
|
||||
fw_sha: str | None,
|
||||
from_artifact: bool,
|
||||
duration_s: float,
|
||||
ok: bool,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
"INSERT INTO flash_events "
|
||||
"(device_serial, env, fw_sha, from_artifact, duration_s, ok, ts) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(
|
||||
device_serial,
|
||||
env,
|
||||
fw_sha,
|
||||
int(from_artifact),
|
||||
duration_s,
|
||||
int(ok),
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def _latest(db: Database, serial: str, from_artifact: bool) -> dict | None:
|
||||
row = await db.fetchone(
|
||||
f"SELECT {_COLS} FROM flash_events "
|
||||
"WHERE device_serial=? AND from_artifact=? AND ok=1 "
|
||||
"ORDER BY ts DESC LIMIT 1",
|
||||
(serial, int(from_artifact)),
|
||||
)
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
async def comparison(db: Database, serial: str) -> dict:
|
||||
"""Latest successful artifact-flash vs latest successful host-rebuild flash,
|
||||
plus the speedup ratio when both exist."""
|
||||
artifact = await _latest(db, serial, True)
|
||||
rebuild = await _latest(db, serial, False)
|
||||
speedup = None
|
||||
if artifact and rebuild and artifact["duration_s"]:
|
||||
speedup = round(rebuild["duration_s"] / artifact["duration_s"], 1)
|
||||
return {"artifact": artifact, "rebuild": rebuild, "speedup": speedup}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Test-run history. A run is a single pytest invocation; results are the
|
||||
per-test leaves, optionally attributed to the device they exercised."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from .database import Database
|
||||
|
||||
_RUN_COLS = (
|
||||
"id, started_at, finished_at, exit_code, args, seed, fw_branch, fw_sha, "
|
||||
"fw_dirty, passed, failed, skipped"
|
||||
)
|
||||
|
||||
|
||||
def _run_to_dict(row) -> dict | None:
|
||||
if row is None:
|
||||
return None
|
||||
d = dict(row)
|
||||
try:
|
||||
d["args"] = json.loads(d["args"]) if d.get("args") else []
|
||||
except (ValueError, TypeError):
|
||||
d["args"] = []
|
||||
return d
|
||||
|
||||
|
||||
async def create_run(
|
||||
db: Database,
|
||||
*,
|
||||
args: list[str],
|
||||
seed: str | None,
|
||||
fw_branch: str | None,
|
||||
fw_sha: str | None,
|
||||
fw_dirty: bool,
|
||||
) -> int:
|
||||
cur = await db.execute(
|
||||
"INSERT INTO runs (started_at, args, seed, fw_branch, fw_sha, fw_dirty) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(time.time(), json.dumps(args or []), seed, fw_branch, fw_sha, int(fw_dirty)),
|
||||
)
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
async def get_run(db: Database, run_id: int) -> dict | None:
|
||||
row = await db.fetchone(f"SELECT {_RUN_COLS} FROM runs WHERE id=?", (run_id,))
|
||||
return _run_to_dict(row)
|
||||
|
||||
|
||||
async def list_runs(db: Database, limit: int = 50) -> list[dict]:
|
||||
rows = await db.fetchall(
|
||||
f"SELECT {_RUN_COLS} FROM runs ORDER BY id DESC LIMIT ?", (limit,)
|
||||
)
|
||||
return [_run_to_dict(r) for r in rows]
|
||||
|
||||
|
||||
async def finish_run(db: Database, run_id: int, *, exit_code: int | None) -> None:
|
||||
await db.execute(
|
||||
"UPDATE runs SET finished_at=?, exit_code=? WHERE id=?",
|
||||
(time.time(), exit_code, run_id),
|
||||
)
|
||||
|
||||
|
||||
async def add_result(
|
||||
db: Database,
|
||||
run_id: int,
|
||||
*,
|
||||
nodeid: str,
|
||||
tier: str | None,
|
||||
outcome: str,
|
||||
duration_s: float | None,
|
||||
device_serial: str | None,
|
||||
longrepr: str | None,
|
||||
) -> None:
|
||||
await db.execute(
|
||||
"INSERT INTO results "
|
||||
"(run_id, nodeid, tier, outcome, duration_s, device_serial, longrepr, ts) "
|
||||
"VALUES (?,?,?,?,?,?,?,?)",
|
||||
(run_id, nodeid, tier, outcome, duration_s, device_serial, longrepr, time.time()),
|
||||
)
|
||||
col = {"passed": "passed", "failed": "failed", "skipped": "skipped"}.get(outcome)
|
||||
if col:
|
||||
await db.execute(
|
||||
f"UPDATE runs SET {col}={col}+1 WHERE id=?", (run_id,)
|
||||
)
|
||||
|
||||
|
||||
async def results_for_device(db: Database, serial: str) -> list[dict]:
|
||||
"""Every recorded result for a device, newest first, joined to its run so
|
||||
each row carries the firmware sha it ran against."""
|
||||
rows = await db.fetchall(
|
||||
"SELECT r.id, r.run_id, r.nodeid, r.tier, r.outcome, r.duration_s, "
|
||||
"r.device_serial, r.longrepr, r.ts, runs.fw_sha, runs.fw_branch "
|
||||
"FROM results r JOIN runs ON r.run_id = runs.id "
|
||||
"WHERE r.device_serial=? ORDER BY r.id DESC",
|
||||
(serial,),
|
||||
)
|
||||
return [dict(r) for r in rows]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Tiny key/value store for JSON-serialisable config blobs (e.g. the Datadog
|
||||
forwarder settings)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .database import Database
|
||||
|
||||
|
||||
async def set_json(db: Database, key: str, obj) -> None:
|
||||
await db.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?, ?) "
|
||||
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
(key, json.dumps(obj)),
|
||||
)
|
||||
|
||||
|
||||
async def get_json(db: Database, key: str) -> dict | None:
|
||||
row = await db.fetchone("SELECT value FROM settings WHERE key=?", (key,))
|
||||
if row is None or row["value"] is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(row["value"])
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
@@ -0,0 +1,4 @@
|
||||
"""FleetSuite service layer — identity reconciliation, control gating, the
|
||||
build orchestrator, the pytest runner, and the Datadog forwarder. These hold
|
||||
the harness's domain logic; the REST/WS layer in ``app.py`` is a thin shell
|
||||
over them."""
|
||||
@@ -0,0 +1,180 @@
|
||||
"""Firmware build orchestrator.
|
||||
|
||||
Builds are keyed by ``(env, fw_sha)`` and cached on disk under
|
||||
``$MESHTASTIC_MCP_ARTIFACT_DIR/<sha>/<env>``. ``enqueue`` returns immediately
|
||||
with a row per env (``status: queued`` for a fresh build, ``cached: True`` when
|
||||
an artifact already exists); the actual compile runs in a background task so the
|
||||
HTTP request never blocks. The compile itself is injectable (``build_fn``) so
|
||||
the queue/cache/dedup logic is testable without Docker or a real toolchain.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from ..db import repo_builds as rb
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.builder")
|
||||
|
||||
# Signature of an injectable build function: compile ``env`` into ``out``,
|
||||
# streaming progress through ``log_cb``; return True on success.
|
||||
BuildFn = Callable[[str, Path, Callable[[str], None]], bool]
|
||||
|
||||
|
||||
def _artifact_root() -> Path:
|
||||
return Path(
|
||||
os.environ.get(
|
||||
"MESHTASTIC_MCP_ARTIFACT_DIR",
|
||||
str(Path.home() / ".meshtastic_mcp" / "artifacts"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def artifact_dir(sha: str, env: str) -> Path:
|
||||
return _artifact_root() / sha / env
|
||||
|
||||
|
||||
def cached_artifact_dir(sha: str, env: str) -> Path | None:
|
||||
"""The cached artifact dir for a key if a successful build left one, else
|
||||
None. 'Successful' is proven by the presence of a flashable image."""
|
||||
d = artifact_dir(sha, env)
|
||||
if d.is_dir() and (
|
||||
any(d.glob("*.factory.bin")) or any(d.glob("*.bin")) or any(d.glob("*.uf2"))
|
||||
):
|
||||
return d
|
||||
return None
|
||||
|
||||
|
||||
def docker_available() -> bool:
|
||||
if not shutil.which("docker"):
|
||||
return False
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
return (
|
||||
subprocess.run(
|
||||
["docker", "info"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
|
||||
def default_build_fn(env: str, out: Path, log_cb: Callable[[str], None]) -> bool:
|
||||
"""Host pio build, copying the resulting images into ``out``. Best-effort —
|
||||
used when no build_fn is injected."""
|
||||
try:
|
||||
from meshtastic_mcp import config as mcfg, pio
|
||||
|
||||
root = mcfg.firmware_root()
|
||||
log_cb(f"pio run -e {env}")
|
||||
result = pio.run(["run", "-e", env], cwd=root, timeout=900, check=False)
|
||||
log_cb(pio.tail_lines(result.stdout + result.stderr, 40))
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
build_dir = root / ".pio" / "build" / env
|
||||
copied = 0
|
||||
for pattern in ("*.factory.bin", "*.bin", "*.uf2", "*.hex"):
|
||||
for f in build_dir.glob(pattern):
|
||||
shutil.copy2(f, out / f.name)
|
||||
copied += 1
|
||||
log_cb(f"copied {copied} artifact(s)")
|
||||
return copied > 0
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_cb(f"build error: {exc}")
|
||||
return False
|
||||
|
||||
|
||||
class BuildOrchestrator:
|
||||
def __init__(self, db, hub, build_fn: BuildFn | None = None) -> None:
|
||||
self.db = db
|
||||
self.hub = hub
|
||||
self.build_fn = build_fn or default_build_fn
|
||||
self._inflight: dict[str, asyncio.Task] = {}
|
||||
|
||||
async def enqueue(
|
||||
self, envs: list[str], *, sha: str, branch: str | None, force: bool = False
|
||||
) -> list[dict]:
|
||||
"""Queue a build per env (skipping ones already cached or in flight).
|
||||
``force`` rebuilds even when an artifact is cached. Returns a status row
|
||||
per requested env."""
|
||||
results: list[dict] = []
|
||||
for env in envs:
|
||||
key = f"{env}@{sha}"
|
||||
|
||||
cached = None if force else cached_artifact_dir(sha, env)
|
||||
if cached is not None:
|
||||
row = await rb.get(self.db, env, sha)
|
||||
if row is None or row["status"] not in ("success", "cached"):
|
||||
bid = await rb.create(
|
||||
self.db, env=env, fw_sha=sha, fw_branch=branch, status="cached"
|
||||
)
|
||||
row = await rb.set_status(
|
||||
self.db,
|
||||
bid,
|
||||
status="cached",
|
||||
duration_s=0.0,
|
||||
artifact_dir=str(cached),
|
||||
)
|
||||
results.append({**row, "cached": True})
|
||||
await self.hub.publish("build.update", {**row, "cached": True})
|
||||
continue
|
||||
|
||||
if key in self._inflight and not self._inflight[key].done():
|
||||
row = await rb.get(self.db, env, sha)
|
||||
if row:
|
||||
results.append(row)
|
||||
continue
|
||||
|
||||
bid = await rb.create(
|
||||
self.db, env=env, fw_sha=sha, fw_branch=branch, status="queued"
|
||||
)
|
||||
row = await rb.get_by_id(self.db, bid)
|
||||
results.append(row)
|
||||
await self.hub.publish("build.update", row)
|
||||
self._inflight[key] = asyncio.create_task(
|
||||
self._run_build(bid, env, sha, key)
|
||||
)
|
||||
return results
|
||||
|
||||
async def _run_build(self, build_id: int, env: str, sha: str, key: str) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
row = await rb.set_status(self.db, build_id, status="building")
|
||||
await self.hub.publish("build.update", row)
|
||||
|
||||
out = artifact_dir(sha, env)
|
||||
logs: list[str] = []
|
||||
|
||||
def log_cb(line: str) -> None:
|
||||
logs.append(line)
|
||||
self.hub.publish_threadsafe(
|
||||
"build.update", {"id": build_id, "env": env, "log": line}
|
||||
)
|
||||
|
||||
start = loop.time()
|
||||
try:
|
||||
ok = await asyncio.to_thread(self.build_fn, env, out, log_cb)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logs.append(f"exception: {exc}")
|
||||
ok = False
|
||||
duration = round(loop.time() - start, 2)
|
||||
|
||||
row = await rb.set_status(
|
||||
self.db,
|
||||
build_id,
|
||||
status="success" if ok else "failed",
|
||||
duration_s=duration,
|
||||
artifact_dir=str(out) if ok else None,
|
||||
error=None if ok else "\n".join(logs[-20:]) or "build failed",
|
||||
)
|
||||
await self.hub.publish("build.update", row)
|
||||
self._inflight.pop(key, None)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""MJPEG camera streaming.
|
||||
|
||||
Opens a capture device by OpenCV index and yields a ``multipart/x-mixed-replace``
|
||||
JPEG stream for an ``<img>`` tag. Rotation is applied client-side (CSS), so the
|
||||
stream is raw. OpenCV (the ``[ui]`` extra) is optional — without it, ``probe``
|
||||
reports the error and the stream endpoint 503s instead of crashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.camera_stream")
|
||||
|
||||
BOUNDARY = "frame"
|
||||
_FPS = 10.0
|
||||
_MAX_INDEX = 10 # don't probe past this
|
||||
|
||||
|
||||
def _import_cv2():
|
||||
try:
|
||||
import cv2 # type: ignore
|
||||
|
||||
try: # quiet the noisy "can't open camera" warnings during probing
|
||||
cv2.utils.logging.setLogLevel(cv2.utils.logging.LOG_LEVEL_SILENT)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return cv2
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _enumerate_names() -> list[tuple[int, str]]:
|
||||
"""Best-effort camera names from the OS, WITHOUT activating any device.
|
||||
Returns ``[(index, name), ...]``. The index is the OpenCV capture index
|
||||
(enumeration order on macOS, the videoN number on Linux)."""
|
||||
if sys.platform == "darwin":
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["system_profiler", "SPCameraDataType", "-json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
items = json.loads(out.stdout).get("SPCameraDataType", [])
|
||||
return [(i, it.get("_name", f"camera {i}")) for i, it in enumerate(items)]
|
||||
except Exception: # noqa: BLE001
|
||||
return []
|
||||
if sys.platform.startswith("linux"):
|
||||
out: list[tuple[int, str]] = []
|
||||
for node in sorted(Path("/sys/class/video4linux").glob("video*")):
|
||||
try:
|
||||
idx = int(node.name[len("video") :])
|
||||
name = (node / "name").read_text().strip()
|
||||
out.append((idx, name or f"video{idx}"))
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def _probe_resolution(cv2, index: int) -> dict | None:
|
||||
"""Open a capture index briefly to confirm it works + read its resolution.
|
||||
Activates the camera momentarily; returns None if it won't open/read."""
|
||||
try:
|
||||
cap = cv2.VideoCapture(index)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
try:
|
||||
if not cap.isOpened():
|
||||
return None
|
||||
ok, _ = cap.read()
|
||||
if not ok:
|
||||
return None
|
||||
return {
|
||||
"width": int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0),
|
||||
"height": int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0),
|
||||
}
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
|
||||
def discover(skip: set[str] | None = None, probe_resolution: bool = True) -> dict:
|
||||
"""Enumerate attached cameras.
|
||||
|
||||
Name enumeration needs no OpenCV (works even without the ``[ui]`` extra), so
|
||||
discovery is useful before streaming is installed. When cv2 IS present, each
|
||||
not-in-use index is briefly opened to confirm it works and read its
|
||||
resolution. ``skip`` is the set of indices already bound to a FleetSuite
|
||||
camera — they're marked ``in_use`` and not re-opened (their stream owns them).
|
||||
"""
|
||||
skip = skip or set()
|
||||
cv2 = _import_cv2()
|
||||
named = _enumerate_names()
|
||||
|
||||
# If the OS gave us no names but cv2 is here, fall back to index probing.
|
||||
if not named and cv2 is not None:
|
||||
named = [(i, f"camera {i}") for i in range(_MAX_INDEX)]
|
||||
|
||||
cameras: list[dict] = []
|
||||
for index, name in named:
|
||||
if index >= _MAX_INDEX:
|
||||
continue
|
||||
entry: dict = {"index": index, "name": name, "in_use": str(index) in skip}
|
||||
if cv2 is not None and not entry["in_use"] and probe_resolution:
|
||||
res = _probe_resolution(cv2, index)
|
||||
if res is None:
|
||||
# Named by the OS but cv2 couldn't open it — surface, don't drop.
|
||||
entry["unavailable"] = True
|
||||
else:
|
||||
entry.update(res)
|
||||
cameras.append(entry)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"cv2": cv2 is not None,
|
||||
"cameras": cameras,
|
||||
}
|
||||
|
||||
|
||||
def probe(device_index: str) -> dict:
|
||||
"""Can we open this device? Returns ``{ok, error}``."""
|
||||
cv2 = _import_cv2()
|
||||
if cv2 is None:
|
||||
return {"ok": False, "error": "opencv not installed (pip install -e '.[ui]')"}
|
||||
try:
|
||||
cap = cv2.VideoCapture(int(device_index))
|
||||
except (ValueError, TypeError):
|
||||
return {"ok": False, "error": f"invalid device index: {device_index!r}"}
|
||||
try:
|
||||
if not cap.isOpened():
|
||||
return {"ok": False, "error": "device did not open (in use or absent?)"}
|
||||
ok, _ = cap.read()
|
||||
return {"ok": bool(ok), "error": None if ok else "no frame from device"}
|
||||
finally:
|
||||
cap.release()
|
||||
|
||||
|
||||
async def mjpeg(device_index: str) -> AsyncIterator[bytes]:
|
||||
"""Async MJPEG frame generator. Reads happen in a worker thread so the event
|
||||
loop is never blocked on the camera."""
|
||||
cv2 = _import_cv2()
|
||||
if cv2 is None:
|
||||
return
|
||||
cap = await asyncio.to_thread(cv2.VideoCapture, int(device_index))
|
||||
try:
|
||||
if not cap.isOpened():
|
||||
return
|
||||
while True:
|
||||
ok, frame = await asyncio.to_thread(cap.read)
|
||||
if not ok:
|
||||
break
|
||||
enc_ok, buf = await asyncio.to_thread(cv2.imencode, ".jpg", frame)
|
||||
if enc_ok:
|
||||
jpg = buf.tobytes()
|
||||
yield (
|
||||
b"--" + BOUNDARY.encode() + b"\r\n"
|
||||
b"Content-Type: image/jpeg\r\n"
|
||||
b"Content-Length: " + str(len(jpg)).encode() + b"\r\n\r\n"
|
||||
+ jpg + b"\r\n"
|
||||
)
|
||||
await asyncio.sleep(1.0 / _FPS)
|
||||
finally:
|
||||
cap.release()
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Device-control safety gate.
|
||||
|
||||
The central invariant: no ``connect()``-based action (flash, reboot, config,
|
||||
send-text, factory-reset, nodedb inject) may run while a test run holds the
|
||||
ports. Every control endpoint funnels through ``_ensure_idle`` /
|
||||
``_ensure_port_free`` first, which raise ``ControlBusy`` (surfaced as HTTP 409)
|
||||
when the runner is active.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from . import identity, test_runner
|
||||
|
||||
|
||||
class ControlBusy(RuntimeError):
|
||||
"""Raised when a control action is attempted while a test run is active."""
|
||||
|
||||
|
||||
def env_for_device(d: dict) -> str | None:
|
||||
"""The pio env to flash/bake for a device: its resolved env if it has one,
|
||||
otherwise the coarse role default."""
|
||||
return d.get("env") or identity.env_for_role(d.get("role"))
|
||||
|
||||
|
||||
def _ensure_idle() -> None:
|
||||
if test_runner.is_running():
|
||||
raise ControlBusy("a test run is in progress")
|
||||
|
||||
|
||||
def _ensure_port_free(port: str | None) -> None:
|
||||
# While a run is active it owns every port — nothing else may touch one.
|
||||
if test_runner.is_running():
|
||||
raise ControlBusy(f"port {port} is held by an active test run")
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Datadog forwarder.
|
||||
|
||||
Pure mappers (``log_to_dd`` / ``telemetry_to_metrics``) turn recorder rows into
|
||||
dashboard-compatible Datadog payloads; ``_read_live`` is a cursor-based tail of
|
||||
the recorder's JSONL streams; ``DDConfig`` persists settings (with the API key
|
||||
masked on the way out). The shipping loop lives in ``DDForwarder``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import asdict, dataclass, fields
|
||||
from pathlib import Path
|
||||
|
||||
import logging
|
||||
|
||||
from ..db import repo_settings as rs
|
||||
from .scrub import Scrubber
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.datadog")
|
||||
|
||||
_ANSI = re.compile(r"\x1b\[[0-9;]*m")
|
||||
|
||||
_DDSOURCE = "meshtastic-firmware"
|
||||
|
||||
_STATUS = {
|
||||
"ERROR": "error",
|
||||
"CRIT": "error",
|
||||
"CRITICAL": "error",
|
||||
"WARN": "warning",
|
||||
"WARNING": "warning",
|
||||
"INFO": "info",
|
||||
"DEBUG": "debug",
|
||||
"TRACE": "debug",
|
||||
"HEAP": "debug",
|
||||
}
|
||||
|
||||
|
||||
def _strip_ansi(s: str) -> str:
|
||||
return _ANSI.sub("", s or "")
|
||||
|
||||
|
||||
def _status_for(level: str | None) -> str:
|
||||
if not level:
|
||||
# Un-leveled output is a panic/backtrace — treat as error.
|
||||
return "error"
|
||||
return _STATUS.get(level.upper(), "info")
|
||||
|
||||
|
||||
# --- log mapping ------------------------------------------------------------
|
||||
def log_to_dd(
|
||||
rec: dict,
|
||||
*,
|
||||
host: str,
|
||||
base_tags: list[str],
|
||||
port_tags: dict[str, list[str]],
|
||||
scrubber: Scrubber,
|
||||
ship_debug: bool,
|
||||
) -> dict | None:
|
||||
"""Map a recorder log row to a Datadog log intake payload.
|
||||
|
||||
Returns None for a DEBUG line when ``ship_debug`` is False. Un-leveled lines
|
||||
(panics/backtraces) always ship.
|
||||
"""
|
||||
level = rec.get("level")
|
||||
if level and level.upper() == "DEBUG" and not ship_debug:
|
||||
return None
|
||||
|
||||
message = scrubber.scrub(_strip_ansi(rec.get("line", "")))
|
||||
|
||||
tags = list(base_tags)
|
||||
port = rec.get("port")
|
||||
if port:
|
||||
tags.append(f"port:{port}")
|
||||
tags.extend(port_tags.get(port, []))
|
||||
if level:
|
||||
tags.append(f"level:{level.lower()}")
|
||||
tag = rec.get("tag")
|
||||
if tag:
|
||||
tags.append(f"thread:{tag.lower()}")
|
||||
|
||||
payload = {
|
||||
"ddsource": _DDSOURCE,
|
||||
"service": _DDSOURCE,
|
||||
"hostname": host,
|
||||
"message": message,
|
||||
"ddtags": ",".join(tags),
|
||||
"status": _status_for(level),
|
||||
}
|
||||
if level is not None:
|
||||
payload["level"] = level
|
||||
if rec.get("ts") is not None:
|
||||
payload["timestamp"] = int(round(rec["ts"] * 1000))
|
||||
if rec.get("heap_free") is not None:
|
||||
payload["heap_free"] = rec["heap_free"]
|
||||
return payload
|
||||
|
||||
|
||||
# --- metric mapping ---------------------------------------------------------
|
||||
def telemetry_to_metrics(
|
||||
rec: dict,
|
||||
*,
|
||||
host: str,
|
||||
base_tags: list[str],
|
||||
port_tags: dict[str, list[str]],
|
||||
) -> list[dict]:
|
||||
"""Map a telemetry row to Datadog GAUGE series (one per numeric field).
|
||||
Boolean and string fields are dropped."""
|
||||
variant = rec.get("variant", "")
|
||||
fields_ = rec.get("fields", {}) or {}
|
||||
ts = int(rec.get("ts", 0) or 0)
|
||||
|
||||
tags = list(base_tags) + [f"variant:{variant}"]
|
||||
port = rec.get("port")
|
||||
if port:
|
||||
tags.extend(port_tags.get(port, []))
|
||||
|
||||
out: list[dict] = []
|
||||
for key, value in fields_.items():
|
||||
# bool is a subclass of int — exclude it explicitly.
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"metric": f"mesh.{variant}.{key}",
|
||||
"type": 3, # GAUGE
|
||||
"points": [{"timestamp": ts, "value": float(value)}],
|
||||
"resources": [{"type": "host", "name": host}],
|
||||
"tags": list(tags),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
# --- cursor-based JSONL tail ------------------------------------------------
|
||||
def _read_live(path: Path, cursor: dict, max_lines: int) -> tuple[list[dict], dict]:
|
||||
"""Read newly-appended complete JSON lines from ``path``.
|
||||
|
||||
``cursor`` is ``{"ino": int, "pos": int}``. A partial trailing line (no
|
||||
newline yet) is left for the next cycle. A cursor whose inode no longer
|
||||
matches, or whose position is past EOF (rotation/truncation), resets to the
|
||||
start. Returns ``(rows, next_cursor)``.
|
||||
"""
|
||||
path = Path(path)
|
||||
try:
|
||||
st = path.stat()
|
||||
except OSError:
|
||||
return [], dict(cursor)
|
||||
ino = st.st_ino
|
||||
|
||||
pos = cursor.get("pos", 0) if cursor.get("ino") == ino else 0
|
||||
if pos > st.st_size: # truncated or rotated under us
|
||||
pos = 0
|
||||
|
||||
with open(path, "rb") as fh:
|
||||
fh.seek(pos)
|
||||
data = fh.read()
|
||||
|
||||
last_nl = data.rfind(b"\n")
|
||||
if last_nl == -1:
|
||||
# No complete line available — leave the cursor where it was.
|
||||
return [], {"ino": ino, "pos": pos}
|
||||
|
||||
complete = data[: last_nl + 1]
|
||||
consumed = pos + len(complete)
|
||||
|
||||
rows: list[dict] = []
|
||||
for raw in complete.split(b"\n"):
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(raw))
|
||||
except ValueError:
|
||||
continue
|
||||
if len(rows) >= max_lines:
|
||||
break
|
||||
return rows, {"ino": ino, "pos": consumed}
|
||||
|
||||
|
||||
# --- intake host derivation -------------------------------------------------
|
||||
def _browser_intake_origin(site: str) -> str:
|
||||
"""The RUM/browser-logs intake origin for a Datadog site.
|
||||
|
||||
``us5.datadoghq.com`` → ``https://browser-intake-us5-datadoghq.com``
|
||||
``datadoghq.eu`` → ``https://browser-intake-datadoghq.eu``
|
||||
"""
|
||||
head, _, tld = site.rpartition(".")
|
||||
head = head.replace(".", "-")
|
||||
return f"https://browser-intake-{head}.{tld}" if head else f"https://browser-intake-{tld}"
|
||||
|
||||
|
||||
def _logs_intake_url(site: str) -> str:
|
||||
return f"https://http-intake.logs.{site}/api/v2/logs"
|
||||
|
||||
|
||||
def _metrics_intake_url(site: str) -> str:
|
||||
return f"https://api.{site}/api/v2/series"
|
||||
|
||||
|
||||
# --- config -----------------------------------------------------------------
|
||||
@dataclass
|
||||
class DDConfig:
|
||||
enabled: bool = False
|
||||
api_key: str = ""
|
||||
site: str = "datadoghq.com"
|
||||
scrub: str = "coarse"
|
||||
collector: str = ""
|
||||
host: str = ""
|
||||
ship_debug: bool = False
|
||||
|
||||
def masked(self) -> dict:
|
||||
"""Config for the UI — the API key is never exposed, only a hint and a
|
||||
client-token flag (Datadog client tokens start with ``pub``)."""
|
||||
return {
|
||||
"enabled": self.enabled,
|
||||
"site": self.site,
|
||||
"scrub": self.scrub,
|
||||
"collector": self.collector,
|
||||
"host": self.host,
|
||||
"ship_debug": self.ship_debug,
|
||||
"has_key": bool(self.api_key),
|
||||
"key_hint": self.api_key[-4:] if self.api_key else "",
|
||||
"is_client_token": self.api_key.startswith("pub") if self.api_key else False,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, d: dict | None) -> "DDConfig":
|
||||
d = d or {}
|
||||
allowed = {f.name for f in fields(cls)}
|
||||
return cls(**{k: v for k, v in d.items() if k in allowed})
|
||||
|
||||
|
||||
async def load_config(db) -> DDConfig:
|
||||
return DDConfig.from_dict(await rs.get_json(db, "datadog"))
|
||||
|
||||
|
||||
async def save_config(db, cfg: DDConfig) -> None:
|
||||
await rs.set_json(db, "datadog", asdict(cfg))
|
||||
|
||||
|
||||
# --- forwarder (runtime) ----------------------------------------------------
|
||||
def _recorder_dir() -> Path:
|
||||
return Path(os.environ.get("MESHTASTIC_MCP_RECORDER_DIR", ".mtlog"))
|
||||
|
||||
|
||||
class DDForwarder:
|
||||
"""Background loop that tails the recorder streams and ships to Datadog.
|
||||
Started/reconfigured from the ``/api/datadog`` routes; a no-op until a key
|
||||
and ``enabled`` are set."""
|
||||
|
||||
def __init__(self, db, hub) -> None:
|
||||
self.db = db
|
||||
self.hub = hub
|
||||
self.cfg = DDConfig()
|
||||
self.stats = {
|
||||
"running": False,
|
||||
"sent_logs": 0,
|
||||
"sent_metrics": 0,
|
||||
"cycles": 0,
|
||||
"last_error": None,
|
||||
"last_cycle_ts": None,
|
||||
}
|
||||
self._cursors: dict[str, dict] = {}
|
||||
|
||||
def status(self) -> dict:
|
||||
return {"config": self.cfg.masked(), "stats": dict(self.stats)}
|
||||
|
||||
async def reload(self) -> None:
|
||||
self.cfg = await load_config(self.db)
|
||||
|
||||
def test_key(self) -> dict:
|
||||
"""Validate the configured API key against Datadog. Best-effort —
|
||||
returns ``{ok, error}`` and never raises."""
|
||||
if not self.cfg.api_key:
|
||||
return {"ok": False, "error": "no API key configured"}
|
||||
try:
|
||||
import requests
|
||||
|
||||
resp = requests.get(
|
||||
f"https://api.{self.cfg.site}/api/v1/validate",
|
||||
headers={"DD-API-KEY": self.cfg.api_key},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.ok and resp.json().get("valid"):
|
||||
return {"ok": True, "error": None}
|
||||
return {"ok": False, "error": f"validation failed ({resp.status_code})"}
|
||||
except Exception as exc: # noqa: BLE001 - surfaced to the UI
|
||||
return {"ok": False, "error": str(exc)}
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Background USB discovery loop.
|
||||
|
||||
Polls the serial bus, reconciles each likely-Meshtastic device into the
|
||||
registry (keyed by stable serial, surrogate key otherwise), flips vanished
|
||||
devices offline, and broadcasts the deltas on ``device.update``.
|
||||
|
||||
Auto-enrichment: when a device is newly seen (or hops ports, or hasn't been
|
||||
read yet), the loop fires a one-shot ``device_info`` in the background to sniff
|
||||
its firmware version, hw_model → exact pio env, region, and node num — so those
|
||||
populate on their own at plug-in, FleetLog-style. It is gated hard for safety:
|
||||
skipped entirely while a test run holds the ports, serialized so only one
|
||||
device is connected at a time, the device's live serial monitor is suspended
|
||||
for the moment of the connect, pinned envs are never clobbered, and failures
|
||||
back off instead of re-hammering every poll. Disable with
|
||||
``MESHTASTIC_MCP_AUTO_ENRICH=0``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
|
||||
from meshtastic_mcp import devices as devices_lib
|
||||
|
||||
from ..db import repo_devices as rd
|
||||
from . import identity
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.discovery")
|
||||
|
||||
POLL_SECONDS = 4.0
|
||||
ENRICH_BACKOFF_S = 60.0 # after a failed connect, wait this long before retrying
|
||||
|
||||
|
||||
class DeviceDiscovery:
|
||||
def __init__(self, db, hub, serialmon=None) -> None:
|
||||
self.db = db
|
||||
self.hub = hub
|
||||
self.serialmon = serialmon
|
||||
self.auto_enrich = os.environ.get("MESHTASTIC_MCP_AUTO_ENRICH", "1") != "0"
|
||||
self._task: asyncio.Task | None = None
|
||||
self._enrich_lock = asyncio.Lock() # one device on the wire at a time
|
||||
self._enriched: dict[str, str] = {} # serial -> port last enriched at
|
||||
self._failed: dict[str, float] = {} # serial -> monotonic time to retry after
|
||||
self._enriching: set[str] = set() # in-flight, to dedupe schedules
|
||||
|
||||
def start(self) -> None:
|
||||
if self._task is None:
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
async def scan_once(self) -> None:
|
||||
"""One discovery pass. Runs the (blocking) enumeration in a thread."""
|
||||
try:
|
||||
found = await asyncio.to_thread(devices_lib.list_devices)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.debug("discovery enumeration failed: %s", exc)
|
||||
return
|
||||
|
||||
seen: set[str] = set()
|
||||
for dev in found:
|
||||
if dev.get("blacklisted") or not dev.get("likely_meshtastic", False):
|
||||
continue
|
||||
key, _stable = identity.device_key(dev)
|
||||
role = identity.role_for_vid(dev.get("vid"))
|
||||
seen.add(key)
|
||||
row = await rd.upsert_from_discovery(
|
||||
self.db,
|
||||
serial_number=key,
|
||||
current_port=dev.get("port"),
|
||||
vid=dev.get("vid"),
|
||||
pid=dev.get("pid"),
|
||||
role=role,
|
||||
)
|
||||
changed = bool(row.pop("_is_new", False)) | bool(
|
||||
row.pop("_port_changed", False)
|
||||
)
|
||||
if changed:
|
||||
await self.hub.publish("device.update", row)
|
||||
self._maybe_enrich(row, changed)
|
||||
|
||||
# Keep native nodes alive across scans — they aren't on the USB bus.
|
||||
natives = {
|
||||
d["serial_number"]
|
||||
for d in await rd.list_all(self.db)
|
||||
if d.get("kind") == "native"
|
||||
}
|
||||
newly_offline = await rd.mark_offline_except(self.db, seen | natives)
|
||||
for serial in newly_offline:
|
||||
self._enriched.pop(serial, None) # re-verify if it comes back
|
||||
row = await rd.get(self.db, serial)
|
||||
if row:
|
||||
await self.hub.publish("device.update", row)
|
||||
|
||||
# --- auto-enrichment --------------------------------------------------
|
||||
def _maybe_enrich(self, row: dict, changed: bool) -> None:
|
||||
"""Decide whether a discovered device needs a background enrichment and,
|
||||
if so, schedule one. Cheap, synchronous gate; the actual connect happens
|
||||
in :meth:`_enrich`."""
|
||||
if not self.auto_enrich:
|
||||
return
|
||||
serial = row.get("serial_number")
|
||||
if not serial or row.get("kind") == "native":
|
||||
return
|
||||
# Lazy import avoids a module-load cycle (test_runner ← control ← ...).
|
||||
from . import test_runner
|
||||
|
||||
if test_runner.is_running():
|
||||
return
|
||||
if serial in self._enriching:
|
||||
return
|
||||
retry_at = self._failed.get(serial)
|
||||
if retry_at is not None and time.monotonic() < retry_at:
|
||||
return
|
||||
# Enrich once per (serial, port). A completed-but-incomplete read sets a
|
||||
# backoff (handled in _enrich), so we never reconnect every poll.
|
||||
needs = changed or self._enriched.get(serial) != row.get("current_port")
|
||||
if needs:
|
||||
asyncio.create_task(self._enrich(serial))
|
||||
|
||||
async def _enrich(self, serial: str) -> None:
|
||||
from meshtastic_mcp import info as mt_info
|
||||
|
||||
from . import test_runner
|
||||
|
||||
if serial in self._enriching:
|
||||
return
|
||||
self._enriching.add(serial)
|
||||
try:
|
||||
async with self._enrich_lock: # serialize: one port open at a time
|
||||
if test_runner.is_running():
|
||||
return
|
||||
row = await rd.get(self.db, serial)
|
||||
if (
|
||||
row is None
|
||||
or not row.get("online")
|
||||
or row.get("kind") == "native"
|
||||
):
|
||||
return
|
||||
port = row.get("current_port")
|
||||
if not port:
|
||||
return
|
||||
|
||||
# Free the port from any live serial monitor for the connect.
|
||||
if self.serialmon is not None:
|
||||
await self.serialmon.suspend(serial)
|
||||
try:
|
||||
info = await asyncio.to_thread(mt_info.device_info, port)
|
||||
finally:
|
||||
if self.serialmon is not None:
|
||||
await self.serialmon.resume(serial)
|
||||
|
||||
hw_model = info.get("hw_model")
|
||||
env = identity.env_for_hw_model(hw_model) if hw_model else None
|
||||
updated = await rd.update_enrichment(
|
||||
self.db,
|
||||
serial,
|
||||
node_num=info.get("my_node_num"),
|
||||
env=env,
|
||||
hw_model=str(hw_model) if hw_model else None,
|
||||
firmware_version=info.get("firmware_version"),
|
||||
region=info.get("region"),
|
||||
)
|
||||
if info.get("firmware_version"):
|
||||
# Full read — terminal for this port.
|
||||
self._enriched[serial] = port
|
||||
self._failed.pop(serial, None)
|
||||
log.info(
|
||||
"enriched %s: fw=%s hw=%s env=%s",
|
||||
serial,
|
||||
info.get("firmware_version"),
|
||||
hw_model,
|
||||
env,
|
||||
)
|
||||
else:
|
||||
# Connected but metadata wasn't ready yet — retry after backoff.
|
||||
self._failed[serial] = time.monotonic() + ENRICH_BACKOFF_S
|
||||
if updated:
|
||||
await self.hub.publish("device.update", updated)
|
||||
except Exception as exc: # noqa: BLE001 - a flaky device shouldn't kill the loop
|
||||
self._failed[serial] = time.monotonic() + ENRICH_BACKOFF_S
|
||||
log.debug("enrichment of %s failed (backing off): %s", serial, exc)
|
||||
finally:
|
||||
self._enriching.discard(serial)
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await self.scan_once()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.debug("discovery loop error: %s", exc)
|
||||
await asyncio.sleep(POLL_SECONDS)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Current firmware git ref — branch, sha, dirty flag, and the tip commit's
|
||||
subject/date. Read straight from git in the firmware root; degrades to
|
||||
``{"available": False}`` outside a checkout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _root() -> Path:
|
||||
try:
|
||||
from meshtastic_mcp import config as mcfg
|
||||
|
||||
return mcfg.firmware_root()
|
||||
except Exception: # noqa: BLE001
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def _git(*args: str) -> str | None:
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=str(_root()),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
return None
|
||||
return out.stdout.strip()
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def firmware_ref() -> dict:
|
||||
sha = _git("rev-parse", "HEAD")
|
||||
if not sha:
|
||||
return {"available": False}
|
||||
branch = _git("rev-parse", "--abbrev-ref", "HEAD")
|
||||
status = _git("status", "--porcelain")
|
||||
subject = _git("log", "-1", "--pretty=%s")
|
||||
committed_at = _git("log", "-1", "--pretty=%cI")
|
||||
return {
|
||||
"available": True,
|
||||
"branch": branch,
|
||||
"sha": sha,
|
||||
"short_sha": sha[:7],
|
||||
"dirty": bool(status),
|
||||
"subject": subject,
|
||||
"committed_at": committed_at,
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Device identity reconciliation.
|
||||
|
||||
Two jobs: (1) map a USB VID to a coarse *role* and a role to a default pio
|
||||
*env*, and resolve the precise env from a board's hw_model when we know it;
|
||||
(2) derive a *stable key* for a device so a unit with a real serial number is
|
||||
tracked across ports, while a serial-less unit gets a (port-derived) surrogate
|
||||
that is explicitly NOT stable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
|
||||
from meshtastic_mcp import boards
|
||||
|
||||
# USB vendor IDs we recognise → coarse role. Case-insensitive; compared as the
|
||||
# lowercased hex string (e.g. "0x239a").
|
||||
_VID_ROLE = {
|
||||
"0x239a": "nrf52", # Adafruit / RAK nRF52840
|
||||
"0x303a": "esp32s3", # Espressif native USB
|
||||
"0x10c4": "esp32s3", # Silicon Labs CP210x UART bridge
|
||||
"0x1a86": "esp32s3", # WCH CH340/CH9102 UART bridge
|
||||
}
|
||||
|
||||
# Coarse role → the default pio env to fall back on when we can't resolve the
|
||||
# exact board variant from its hw_model.
|
||||
_ROLE_ENV = {
|
||||
"nrf52": "rak4631",
|
||||
"esp32s3": "heltec-v3",
|
||||
}
|
||||
|
||||
_NOSERIAL_PREFIX = "noserial:"
|
||||
|
||||
|
||||
def role_for_vid(vid: str | None) -> str | None:
|
||||
if not vid:
|
||||
return None
|
||||
return _VID_ROLE.get(vid.lower())
|
||||
|
||||
|
||||
def env_for_role(role: str | None) -> str | None:
|
||||
if not role:
|
||||
return None
|
||||
return _ROLE_ENV.get(role)
|
||||
|
||||
|
||||
def env_for_hw_model(hw_model: str | None) -> str | None:
|
||||
"""Resolve the exact pio env for a hardware model slug (e.g. ``HELTEC_V4``).
|
||||
|
||||
Prefers the *base* env (``heltec-v4``) over decorated variants
|
||||
(``heltec-v4-tft``): when several envs declare the same hw_model slug, the
|
||||
one whose name is the canonical slugification wins, else the shortest.
|
||||
Returns None for an unknown slug.
|
||||
"""
|
||||
if not hw_model:
|
||||
return None
|
||||
target = hw_model.upper()
|
||||
candidates = [
|
||||
b["env"]
|
||||
for b in boards.list_boards()
|
||||
if (b.get("hw_model_slug") or "").upper() == target and b.get("env")
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
canonical = hw_model.lower().replace("_", "-")
|
||||
if canonical in candidates:
|
||||
return canonical
|
||||
return min(candidates, key=len)
|
||||
|
||||
|
||||
def device_key(d: dict) -> tuple[str, bool]:
|
||||
"""Return ``(key, is_stable)`` for a discovered-device dict.
|
||||
|
||||
A real serial number is a stable key. Without one, we synthesise a
|
||||
``noserial:<hash>`` surrogate from vid/pid/port so the device is still
|
||||
addressable within a session — but it is NOT stable across replug.
|
||||
"""
|
||||
serial = d.get("serial_number")
|
||||
if serial:
|
||||
return str(serial), True
|
||||
raw = f"{d.get('vid')}:{d.get('pid')}:{d.get('port')}"
|
||||
digest = hashlib.sha1(raw.encode()).hexdigest()[:12]
|
||||
return f"{_NOSERIAL_PREFIX}{digest}", False
|
||||
|
||||
|
||||
def has_stable_id(key: str | None) -> bool:
|
||||
return bool(key) and not str(key).startswith(_NOSERIAL_PREFIX)
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Native (Docker ``meshtasticd``) node lifecycle.
|
||||
|
||||
Each native node is a container publishing the simulator's TCP API (4403) on a
|
||||
host port, mirrored into the device registry as a ``native:<name>`` device so
|
||||
the same card/UI applies. Best-effort: every Docker call is guarded and the
|
||||
service reports ``docker: False`` rather than raising when Docker is absent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
|
||||
from ..db import repo_devices as rd
|
||||
from . import builder
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.native")
|
||||
|
||||
IMAGE = "meshtastic/meshtasticd:latest"
|
||||
_PREFIX = "fleetsuite-native-"
|
||||
|
||||
|
||||
def docker_available() -> bool:
|
||||
return builder.docker_available()
|
||||
|
||||
|
||||
async def _docker(*args: str, timeout: int = 30) -> tuple[int, str]:
|
||||
if not shutil.which("docker"):
|
||||
return 127, "docker not found"
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"docker",
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
try:
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
return 124, "docker timed out"
|
||||
return proc.returncode, out.decode(errors="replace")
|
||||
|
||||
|
||||
def _container(name: str) -> str:
|
||||
return f"{_PREFIX}{name}"
|
||||
|
||||
|
||||
async def info(db) -> dict:
|
||||
nodes = [
|
||||
d for d in await rd.list_all(db) if d.get("kind") == "native"
|
||||
]
|
||||
return {"docker": docker_available(), "image": IMAGE, "nodes": nodes}
|
||||
|
||||
|
||||
async def create(db, *, name: str, tcp_port: int) -> dict:
|
||||
code, out = await _docker(
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
_container(name),
|
||||
"-p",
|
||||
f"{tcp_port}:4403",
|
||||
IMAGE,
|
||||
)
|
||||
if code != 0:
|
||||
raise RuntimeError(f"docker run failed: {out.strip()[:200]}")
|
||||
return await rd.upsert_native(db, name=name, tcp_port=tcp_port, online=True)
|
||||
|
||||
|
||||
async def lifecycle(db, name: str, action: str) -> dict:
|
||||
verb = {"start": "start", "stop": "stop", "restart": "restart"}.get(action)
|
||||
if verb is None:
|
||||
raise ValueError(f"unknown action: {action}")
|
||||
code, out = await _docker(verb, _container(name))
|
||||
if code != 0:
|
||||
raise RuntimeError(f"docker {verb} failed: {out.strip()[:200]}")
|
||||
online = action != "stop"
|
||||
row = await rd.get(db, f"native:{name}")
|
||||
tcp_port = row.get("tcp_port") if row else 4403
|
||||
return await rd.upsert_native(db, name=name, tcp_port=tcp_port, online=online)
|
||||
|
||||
|
||||
async def remove(db, name: str) -> None:
|
||||
await _docker("rm", "-f", _container(name))
|
||||
await db.execute("DELETE FROM devices WHERE serial_number=?", (f"native:{name}",))
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Per-node USB power control via uhubctl.
|
||||
|
||||
Each bench node sits on a PPPS-capable (per-port-power-switching) hub. uhubctl
|
||||
can toggle VBUS on a given ``(hub_location, port)``, but its port listing only
|
||||
exposes VID:PID — not the USB serial — so two same-VID nRF52s can't be told
|
||||
apart from the hub side. We therefore track the mapping explicitly on the device
|
||||
row (``hub_location`` / ``hub_port``):
|
||||
|
||||
* ``locate`` auto-binds a device when exactly one PPPS port matches its VID;
|
||||
* an ambiguous match (two identical boards) is surfaced for the operator to
|
||||
pick the right slot, which is then pinned and survives replug/reboot.
|
||||
|
||||
Power actions resolve through that mapping (falling back to a unique VID match)
|
||||
and are gated by the run-safety lock like any other control action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from meshtastic_mcp import uhubctl
|
||||
|
||||
from ..db import repo_devices as rd
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.power")
|
||||
|
||||
_ACTIONS = {"on": uhubctl.power_on, "off": uhubctl.power_off, "cycle": uhubctl.cycle}
|
||||
|
||||
|
||||
class AmbiguousPort(RuntimeError):
|
||||
"""More than one PPPS port matches the device's VID — operator must pick."""
|
||||
|
||||
def __init__(self, candidates: list[dict]) -> None:
|
||||
super().__init__("ambiguous hub port — assign one manually")
|
||||
self.candidates = candidates
|
||||
|
||||
|
||||
class NoPort(RuntimeError):
|
||||
"""No controllable (PPPS) hub port hosts this device."""
|
||||
|
||||
|
||||
def available() -> bool:
|
||||
try:
|
||||
from meshtastic_mcp import config as mcfg
|
||||
|
||||
binary = mcfg.uhubctl_bin()
|
||||
if binary and Path(str(binary)).exists():
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return shutil.which("uhubctl") is not None
|
||||
|
||||
|
||||
def _vid_int(device: dict) -> int | None:
|
||||
vid = device.get("vid")
|
||||
if not vid:
|
||||
return None
|
||||
try:
|
||||
return int(str(vid), 16)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def list_hubs() -> list[dict]:
|
||||
"""Parsed uhubctl hubs (with PPPS flag + per-port attachments)."""
|
||||
return uhubctl.list_hubs()
|
||||
|
||||
|
||||
def candidates_for(device: dict) -> list[dict]:
|
||||
"""All PPPS ``(location, port)`` slots whose attached VID matches the
|
||||
device, as ``[{"location", "port"}]``."""
|
||||
vid = _vid_int(device)
|
||||
if vid is None:
|
||||
return []
|
||||
return [
|
||||
{"location": loc, "port": port}
|
||||
for loc, port in uhubctl.find_port_for_vid(vid)
|
||||
]
|
||||
|
||||
|
||||
async def locate(db, serial: str) -> dict:
|
||||
"""Try to auto-bind the device to its hub port. Returns the resolved
|
||||
mapping, or the candidate list when ambiguous so the UI can prompt."""
|
||||
device = await rd.get(db, serial)
|
||||
if device is None:
|
||||
raise LookupError(serial)
|
||||
cands = await asyncio.to_thread(candidates_for, device)
|
||||
if len(cands) == 1:
|
||||
dev = await rd.set_hub_port(
|
||||
db, serial, location=cands[0]["location"], port=cands[0]["port"]
|
||||
)
|
||||
return {"located": True, "device": dev, "candidates": cands}
|
||||
return {"located": False, "device": device, "candidates": cands}
|
||||
|
||||
|
||||
async def power_device(db, serial: str, action: str) -> dict:
|
||||
"""Run a power action against the device's hub port. Resolves the pinned
|
||||
mapping first, falling back to a unique VID match; raises ``AmbiguousPort``
|
||||
/ ``NoPort`` when the slot can't be determined."""
|
||||
if action not in _ACTIONS:
|
||||
raise ValueError(f"unknown power action: {action}")
|
||||
device = await rd.get(db, serial)
|
||||
if device is None:
|
||||
raise LookupError(serial)
|
||||
|
||||
loc = device.get("hub_location")
|
||||
port = device.get("hub_port")
|
||||
if loc is None or port is None:
|
||||
cands = await asyncio.to_thread(candidates_for, device)
|
||||
if not cands:
|
||||
raise NoPort("no PPPS hub port hosts this device — assign one manually")
|
||||
if len(cands) > 1:
|
||||
raise AmbiguousPort(cands)
|
||||
loc, port = cands[0]["location"], cands[0]["port"]
|
||||
|
||||
result = await asyncio.to_thread(_ACTIONS[action], loc, port)
|
||||
return {"location": loc, "port": port, **result}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Coordinate scrubber for outbound telemetry/logs.
|
||||
|
||||
Three modes:
|
||||
off — pass through unchanged
|
||||
coarse — round decimal lat/lon to 1 dp, truncate 1e-7 integer coords to the
|
||||
nearest million, drop NMEA sentence bodies
|
||||
redact — replace any coordinate value with ``<scrubbed>``
|
||||
|
||||
Only coordinate-named keys are touched, so a decimal like ``latency=12.5`` is
|
||||
left alone.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Decimal coordinate: lat/lon/latitude/longitude = <signed decimal>. Longest
|
||||
# names first so "latitude" isn't half-matched as "lat".
|
||||
_DEC = re.compile(r"\b(latitude|longitude|lat|lon)=(-?\d+\.\d+)")
|
||||
# Protobuf 1e-7 integer coordinate: same names with an _i suffix = <signed int>.
|
||||
_INT = re.compile(r"\b(latitude_i|longitude_i|lat_i|lon_i)=(-?\d+)")
|
||||
# NMEA sentence ("$GPGGA,...") — drop everything after the sentence type.
|
||||
_NMEA = re.compile(r"(\$G[A-Z]{4}),\S*")
|
||||
|
||||
|
||||
class Scrubber:
|
||||
def __init__(self, mode: str = "coarse") -> None:
|
||||
self.mode = (mode or "off").lower()
|
||||
|
||||
def scrub(self, text: str) -> str:
|
||||
if self.mode == "off" or not text:
|
||||
return text
|
||||
if self.mode == "redact":
|
||||
text = _DEC.sub(lambda m: f"{m.group(1)}=<scrubbed>", text)
|
||||
text = _INT.sub(lambda m: f"{m.group(1)}=<scrubbed>", text)
|
||||
text = _NMEA.sub(r"\1,<scrubbed>", text)
|
||||
return text
|
||||
# coarse
|
||||
text = _DEC.sub(lambda m: f"{m.group(1)}={round(float(m.group(2)), 1)}", text)
|
||||
text = _INT.sub(
|
||||
lambda m: f"{m.group(1)}={int(float(m.group(2)) / 1e6) * 1_000_000}", text
|
||||
)
|
||||
text = _NMEA.sub(r"\1,<scrubbed>", text)
|
||||
return text
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Live serial monitors, one per device, multiplexed onto the ``serial.<serial>``
|
||||
WebSocket topic.
|
||||
|
||||
A monitor is reference-counted by subscriber: the first client to open a
|
||||
device's Serial tab spawns a pyserial reader thread that republishes each line;
|
||||
the last to leave tears it down. Direct pyserial is used (not ``pio device
|
||||
monitor``, whose miniterm backend requires a controlling TTY and crashes when
|
||||
run headless under the server). Because the reader holds the USB port, any
|
||||
control action ``suspend``s it for the duration and ``resume``s after, so the
|
||||
port is never double-opened.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
|
||||
import serial as pyserial
|
||||
|
||||
from meshtastic_mcp import connection
|
||||
|
||||
from ..db import repo_devices as rd
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.serial_monitor")
|
||||
|
||||
BAUD = 115200
|
||||
|
||||
|
||||
class _Monitor:
|
||||
def __init__(self) -> None:
|
||||
self.refs = 0
|
||||
self.suspended = False
|
||||
self.stop: threading.Event | None = None
|
||||
self.thread: threading.Thread | None = None
|
||||
|
||||
|
||||
class SerialMonitor:
|
||||
def __init__(self, db, hub) -> None:
|
||||
self.db = db
|
||||
self.hub = hub
|
||||
self._mons: dict[str, _Monitor] = {}
|
||||
|
||||
def _topic(self, serial: str) -> str:
|
||||
return f"serial.{serial}"
|
||||
|
||||
async def acquire(self, serial: str) -> None:
|
||||
mon = self._mons.setdefault(serial, _Monitor())
|
||||
mon.refs += 1
|
||||
if mon.refs == 1 and not mon.suspended:
|
||||
await self._open(serial, mon)
|
||||
|
||||
async def release(self, serial: str) -> None:
|
||||
mon = self._mons.get(serial)
|
||||
if mon is None:
|
||||
return
|
||||
mon.refs = max(0, mon.refs - 1)
|
||||
if mon.refs == 0:
|
||||
await self._close(mon)
|
||||
self._mons.pop(serial, None)
|
||||
|
||||
async def suspend(self, serial: str) -> None:
|
||||
"""Free the port for a control action (no-op if not monitored)."""
|
||||
mon = self._mons.get(serial)
|
||||
if mon is None:
|
||||
return
|
||||
mon.suspended = True
|
||||
await self._close(mon)
|
||||
|
||||
async def resume(self, serial: str) -> None:
|
||||
mon = self._mons.get(serial)
|
||||
if mon is None:
|
||||
return
|
||||
mon.suspended = False
|
||||
if mon.refs > 0 and mon.thread is None:
|
||||
await self._open(serial, mon)
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
for mon in list(self._mons.values()):
|
||||
await self._close(mon)
|
||||
self._mons.clear()
|
||||
|
||||
async def _open(self, serial: str, mon: _Monitor) -> None:
|
||||
row = await rd.get(self.db, serial)
|
||||
if row is None or row.get("kind") == "native":
|
||||
return # native nodes are TCP — nothing to monitor on the USB bus
|
||||
port = row.get("current_port")
|
||||
if not port or connection.is_tcp_port(port):
|
||||
return
|
||||
mon.stop = threading.Event()
|
||||
mon.thread = threading.Thread(
|
||||
target=self._read_loop, args=(serial, port, mon.stop), daemon=True
|
||||
)
|
||||
mon.thread.start()
|
||||
|
||||
async def _close(self, mon: _Monitor) -> None:
|
||||
if mon.stop is not None:
|
||||
mon.stop.set()
|
||||
thread = mon.thread
|
||||
mon.thread = None
|
||||
mon.stop = None
|
||||
if thread is not None:
|
||||
await asyncio.to_thread(thread.join, 2.0)
|
||||
|
||||
def _read_loop(self, serial: str, port: str, stop: threading.Event) -> None:
|
||||
"""Runs in a worker thread; publishes lines via the hub's thread-safe
|
||||
path. Reads with a short timeout so ``stop`` is honoured promptly."""
|
||||
topic = self._topic(serial)
|
||||
try:
|
||||
ser = pyserial.Serial(port, BAUD, timeout=0.5)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.hub.publish_threadsafe(topic, {"line": f"— cannot open {port}: {exc} —"})
|
||||
return
|
||||
self.hub.publish_threadsafe(topic, {"line": f"— monitor opened on {port} —"})
|
||||
buf = b""
|
||||
try:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
data = ser.read(256)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.hub.publish_threadsafe(topic, {"line": f"— read error: {exc} —"})
|
||||
break
|
||||
if not data:
|
||||
continue
|
||||
buf += data
|
||||
while b"\n" in buf:
|
||||
raw, buf = buf.split(b"\n", 1)
|
||||
text = raw.decode("utf-8", "replace").rstrip("\r")
|
||||
if not text:
|
||||
continue
|
||||
# The meshtastic CDC carries protobuf API frames interleaved
|
||||
# with text debug logs. Drop lines that are mostly undecodable
|
||||
# bytes (a protobuf frame) — decoded text logs render with ANSI.
|
||||
bad = text.count("�")
|
||||
if bad and bad > len(text) * 0.2:
|
||||
continue
|
||||
self.hub.publish_threadsafe(topic, {"line": text})
|
||||
finally:
|
||||
try:
|
||||
ser.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
@@ -0,0 +1,267 @@
|
||||
"""The pytest runner.
|
||||
|
||||
``resolve_env_overrides`` and ``is_running`` are the pure pieces the safety
|
||||
gate and the run-launcher depend on. ``TestRunner`` drives an actual pytest
|
||||
subprocess: it bakes per-board env overrides, tails ``pytest-reportlog`` JSONL
|
||||
for live per-test progress, and streams stdout/stderr + firmware logs over the
|
||||
hub.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.test_runner")
|
||||
|
||||
# Tiers the UI knows about, in display order. A nodeid maps to a tier by its
|
||||
# path under tests/ (directory name, or "bake"/"unit" for top-level files).
|
||||
TIERS = (
|
||||
"bake",
|
||||
"unit",
|
||||
"mesh",
|
||||
"telemetry",
|
||||
"monitor",
|
||||
"fleet",
|
||||
"admin",
|
||||
"provisioning",
|
||||
)
|
||||
|
||||
# Module-level run state. A single harness runs one suite at a time.
|
||||
_state: dict = {"running": False, "run_id": None, "exit_code": None, "proc": None}
|
||||
|
||||
|
||||
def is_running() -> bool:
|
||||
return bool(_state.get("running"))
|
||||
|
||||
|
||||
def status() -> dict:
|
||||
return {
|
||||
"running": _state["running"],
|
||||
"run_id": _state["run_id"],
|
||||
"exit_code": _state["exit_code"],
|
||||
}
|
||||
|
||||
|
||||
def resolve_env_overrides(rows: list[dict]) -> dict[str, str]:
|
||||
"""From the online, env-resolved devices, bake one
|
||||
``MESHTASTIC_MCP_ENV_<ROLE>=<env>`` override per role. Rows without a role
|
||||
or env are skipped (so native/TCP nodes never become a flash target)."""
|
||||
overrides: dict[str, str] = {}
|
||||
for row in rows:
|
||||
role = row.get("role")
|
||||
env = row.get("env")
|
||||
if not role or not env:
|
||||
continue
|
||||
overrides[f"MESHTASTIC_MCP_ENV_{role.upper()}"] = env
|
||||
return overrides
|
||||
|
||||
|
||||
def tier_for(nodeid: str) -> str:
|
||||
"""Derive a tier from a pytest nodeid path."""
|
||||
path = nodeid.split("::", 1)[0]
|
||||
parts = path.split("/")
|
||||
if "tests" in parts:
|
||||
rest = parts[parts.index("tests") + 1 :]
|
||||
if rest:
|
||||
seg = rest[0]
|
||||
if seg.endswith(".py"):
|
||||
return "bake" if "bake" in seg else "unit"
|
||||
return seg
|
||||
return "unit"
|
||||
|
||||
|
||||
def _split_nodeid(nodeid: str) -> tuple[str, str]:
|
||||
path, _, name = nodeid.partition("::")
|
||||
return path, name or nodeid
|
||||
|
||||
|
||||
class TestRunner:
|
||||
"""Owns the live pytest subprocess + its reportlog tail. One per app."""
|
||||
|
||||
def __init__(self, db, hub) -> None:
|
||||
self.db = db
|
||||
self.hub = hub
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
async def start(self, args: list[str]) -> dict:
|
||||
from . import firmware # local import to avoid a cycle at module load
|
||||
|
||||
if is_running():
|
||||
raise RuntimeError("a test run is already in progress")
|
||||
|
||||
fw = firmware.firmware_ref()
|
||||
from ..db import repo_devices as rd
|
||||
|
||||
overrides = resolve_env_overrides(await rd.online_with_env(self.db))
|
||||
|
||||
from ..db import repo_runs as rr
|
||||
|
||||
run_id = await rr.create_run(
|
||||
self.db,
|
||||
args=args,
|
||||
seed=str(int(time.time())),
|
||||
fw_branch=fw.get("branch"),
|
||||
fw_sha=fw.get("sha"),
|
||||
fw_dirty=bool(fw.get("dirty")),
|
||||
)
|
||||
_state.update(running=True, run_id=run_id, exit_code=None)
|
||||
await self.hub.publish("test.progress", {"type": "run_started", "run_id": run_id})
|
||||
self._task = asyncio.create_task(self._drive(run_id, args, overrides))
|
||||
return status()
|
||||
|
||||
async def stop(self) -> None:
|
||||
proc = _state.get("proc")
|
||||
if proc and proc.returncode is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
|
||||
async def _drive(self, run_id: int, args: list[str], overrides: dict) -> None:
|
||||
from .. import config as _cfg # noqa: F401
|
||||
from ..db import repo_runs as rr
|
||||
from meshtastic_mcp import config as mcfg
|
||||
|
||||
exit_code = None
|
||||
report = Path(tempfile.gettempdir()) / f"fleetsuite-report-{run_id}.jsonl"
|
||||
report.unlink(missing_ok=True)
|
||||
try:
|
||||
root = mcfg.firmware_root() / "mcp-server"
|
||||
except Exception: # noqa: BLE001
|
||||
root = Path.cwd()
|
||||
|
||||
env = dict(os.environ)
|
||||
env.update(overrides)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
"-p",
|
||||
"no:cacheprovider",
|
||||
f"--report-log={report}",
|
||||
"-v",
|
||||
*args,
|
||||
]
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
cwd=str(root),
|
||||
env=env,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_state["proc"] = proc
|
||||
tail = asyncio.create_task(self._tail_report(run_id, report))
|
||||
await asyncio.gather(
|
||||
self._pump(proc.stdout, "stdout"),
|
||||
self._pump(proc.stderr, "stderr"),
|
||||
)
|
||||
exit_code = await proc.wait()
|
||||
await asyncio.sleep(0.2) # let the reportlog flush
|
||||
tail.cancel()
|
||||
except FileNotFoundError as exc:
|
||||
await self.hub.publish(
|
||||
"test.stdout", {"line": f"failed to launch pytest: {exc}", "source": "stderr"}
|
||||
)
|
||||
exit_code = 127
|
||||
finally:
|
||||
_state.update(running=False, exit_code=exit_code, proc=None)
|
||||
await rr.finish_run(self.db, run_id, exit_code=exit_code)
|
||||
await self.hub.publish(
|
||||
"test.progress", {"type": "run_finished", "exit_code": exit_code}
|
||||
)
|
||||
report.unlink(missing_ok=True)
|
||||
|
||||
async def _pump(self, stream, source: str) -> None:
|
||||
if stream is None:
|
||||
return
|
||||
while True:
|
||||
raw = await stream.readline()
|
||||
if not raw:
|
||||
break
|
||||
line = raw.decode(errors="replace").rstrip("\n")
|
||||
await self.hub.publish("test.stdout", {"line": line, "source": source})
|
||||
|
||||
async def _tail_report(self, run_id: int, report: Path) -> None:
|
||||
"""Follow the reportlog JSONL and translate entries into progress frames
|
||||
+ persisted results."""
|
||||
from ..db import repo_runs as rr
|
||||
|
||||
seen_register: set[str] = set()
|
||||
pos = 0
|
||||
while True:
|
||||
if report.exists():
|
||||
with open(report, "rb") as fh:
|
||||
fh.seek(pos)
|
||||
chunk = fh.read()
|
||||
pos = fh.tell()
|
||||
for raw in chunk.split(b"\n"):
|
||||
if not raw.strip():
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
await self._handle_entry(run_id, entry, seen_register, rr)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
async def _handle_entry(self, run_id, entry, seen_register, rr) -> None:
|
||||
rtype = entry.get("$report_type")
|
||||
if rtype == "CollectReport":
|
||||
for item in entry.get("result", []):
|
||||
nodeid = item.get("nodeid")
|
||||
if not nodeid or "::" not in nodeid or nodeid in seen_register:
|
||||
continue
|
||||
seen_register.add(nodeid)
|
||||
path, name = _split_nodeid(nodeid)
|
||||
await self.hub.publish(
|
||||
"test.progress",
|
||||
{
|
||||
"type": "register",
|
||||
"nodeid": nodeid,
|
||||
"tier": tier_for(nodeid),
|
||||
"file": path,
|
||||
"testname": name,
|
||||
},
|
||||
)
|
||||
elif rtype == "TestReport":
|
||||
nodeid = entry.get("nodeid")
|
||||
when = entry.get("when")
|
||||
outcome = entry.get("outcome")
|
||||
if when == "setup":
|
||||
await self.hub.publish(
|
||||
"test.progress", {"type": "running", "nodeid": nodeid}
|
||||
)
|
||||
# Final outcome: the call phase normally, or a non-passed setup
|
||||
# (skip/error) that short-circuits the test.
|
||||
final = when == "call" or (when == "setup" and outcome != "passed")
|
||||
if final:
|
||||
duration = entry.get("duration")
|
||||
await self.hub.publish(
|
||||
"test.progress",
|
||||
{
|
||||
"type": "outcome",
|
||||
"nodeid": nodeid,
|
||||
"outcome": outcome,
|
||||
"duration": duration,
|
||||
},
|
||||
)
|
||||
longrepr = entry.get("longrepr")
|
||||
await rr.add_result(
|
||||
self.db,
|
||||
run_id,
|
||||
nodeid=nodeid,
|
||||
tier=tier_for(nodeid or ""),
|
||||
outcome=outcome,
|
||||
duration_s=duration,
|
||||
device_serial=None,
|
||||
longrepr=str(longrepr) if longrepr else None,
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""The single ``/ws`` broadcast hub. Stores fan topic-tagged frames out to it
|
||||
and the SPA's Pinia stores subscribe by topic."""
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Topic-based broadcast hub backing the single ``/ws`` socket.
|
||||
|
||||
Each connection subscribes to a set of topics; ``publish(topic, data)`` fans the
|
||||
frame ``{"topic": ..., "data": ...}`` to every subscriber. Producers running off
|
||||
the event loop (build threads, the pytest reader) use ``publish_threadsafe`` so
|
||||
they don't touch asyncio primitives from the wrong thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger("meshtastic_mcp.web.ws")
|
||||
|
||||
|
||||
class Connection:
|
||||
"""One websocket peer and the topics it cares about. ``send`` is injected by
|
||||
the ``/ws`` handler so the hub stays transport-agnostic (and trivially
|
||||
unit-testable)."""
|
||||
|
||||
def __init__(self, send) -> None:
|
||||
self.send = send
|
||||
self.topics: set[str] = set()
|
||||
|
||||
|
||||
class Hub:
|
||||
def __init__(self) -> None:
|
||||
self._conns: set[Connection] = set()
|
||||
self._loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
def bind_loop(self, loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""Remember the event loop so ``publish_threadsafe`` can hop onto it."""
|
||||
self._loop = loop
|
||||
|
||||
# --- connection lifecycle ---------------------------------------------
|
||||
def add(self, conn: Connection) -> None:
|
||||
self._conns.add(conn)
|
||||
|
||||
def remove(self, conn: Connection) -> None:
|
||||
self._conns.discard(conn)
|
||||
|
||||
def subscribe(self, conn: Connection, topic: str) -> None:
|
||||
conn.topics.add(topic)
|
||||
|
||||
def unsubscribe(self, conn: Connection, topic: str) -> None:
|
||||
conn.topics.discard(topic)
|
||||
|
||||
# --- publishing --------------------------------------------------------
|
||||
async def publish(self, topic: str, data: Any) -> None:
|
||||
if not self._conns:
|
||||
return
|
||||
frame = {"topic": topic, "data": data}
|
||||
dead: list[Connection] = []
|
||||
for conn in list(self._conns):
|
||||
if topic not in conn.topics:
|
||||
continue
|
||||
try:
|
||||
await conn.send(frame)
|
||||
except Exception: # noqa: BLE001 - a dropped peer shouldn't kill a broadcast
|
||||
dead.append(conn)
|
||||
for conn in dead:
|
||||
self.remove(conn)
|
||||
|
||||
def publish_threadsafe(self, topic: str, data: Any) -> None:
|
||||
"""Schedule a publish from a non-event-loop thread."""
|
||||
if self._loop is None:
|
||||
return
|
||||
try:
|
||||
asyncio.run_coroutine_threadsafe(self.publish(topic, data), self._loop)
|
||||
except RuntimeError:
|
||||
log.debug("publish_threadsafe after loop close: %s", topic)
|
||||
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
|
||||
"esp32-c6",
|
||||
"esp32c6",
|
||||
}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840"}
|
||||
|
||||
|
||||
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Phase 2 build-orchestrator logic: queue → build → SHA-keyed cache hit.
|
||||
|
||||
Uses an injected fake build function that writes a dummy artifact, so the
|
||||
queue/cache/dedup logic is exercised without Docker or a real compile.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
from meshtastic_mcp.web.db import repo_builds as rb
|
||||
from meshtastic_mcp.web.db.database import Database
|
||||
from meshtastic_mcp.web.services import builder
|
||||
from meshtastic_mcp.web.ws.hub import Hub
|
||||
|
||||
|
||||
def test_build_then_cache_hit(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_ARTIFACT_DIR", str(tmp_path / "artifacts"))
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_build(env: str, out: Path, log_cb) -> bool:
|
||||
calls["n"] += 1
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
(out / f"firmware-{env}-1.0.factory.bin").write_bytes(b"\x00\x01")
|
||||
(out / f"firmware-{env}-1.0.mt.json").write_text("{}")
|
||||
log_cb("built")
|
||||
return True
|
||||
|
||||
async def go():
|
||||
db = Database(path=tmp_path / "registry.db")
|
||||
await db.connect()
|
||||
hub = Hub()
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
orch = builder.BuildOrchestrator(db, hub, build_fn=fake_build)
|
||||
|
||||
# First enqueue → builds.
|
||||
res = await orch.enqueue(["heltec-v3"], sha="abc123", branch="develop")
|
||||
assert res and res[0]["status"] == "queued"
|
||||
await asyncio.gather(*orch._inflight.values())
|
||||
|
||||
row = await rb.get(db, "heltec-v3", "abc123")
|
||||
assert row["status"] == "success", row
|
||||
assert builder.cached_artifact_dir("abc123", "heltec-v3") is not None
|
||||
assert calls["n"] == 1
|
||||
|
||||
# Second enqueue at the same SHA → cache hit, no rebuild.
|
||||
res2 = await orch.enqueue(["heltec-v3"], sha="abc123", branch="develop")
|
||||
assert res2[0].get("cached") is True
|
||||
assert calls["n"] == 1 # fake_build NOT called again
|
||||
|
||||
# A different SHA → builds again.
|
||||
res3 = await orch.enqueue(["heltec-v3"], sha="def456", branch="develop")
|
||||
await asyncio.gather(*orch._inflight.values())
|
||||
assert calls["n"] == 2
|
||||
assert (await rb.get(db, "heltec-v3", "def456"))["status"] == "success"
|
||||
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_build_failure_recorded(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MESHTASTIC_MCP_ARTIFACT_DIR", str(tmp_path / "artifacts"))
|
||||
|
||||
def failing_build(env: str, out: Path, log_cb) -> bool:
|
||||
log_cb("boom")
|
||||
return False
|
||||
|
||||
async def go():
|
||||
db = Database(path=tmp_path / "registry.db")
|
||||
await db.connect()
|
||||
hub = Hub()
|
||||
hub.bind_loop(asyncio.get_running_loop())
|
||||
orch = builder.BuildOrchestrator(db, hub, build_fn=failing_build)
|
||||
await orch.enqueue(["rak4631"], sha="aaa", branch="develop")
|
||||
await asyncio.gather(*orch._inflight.values())
|
||||
row = await rb.get(db, "rak4631", "aaa")
|
||||
assert row["status"] == "failed"
|
||||
assert builder.cached_artifact_dir("aaa", "rak4631") is None
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Datadog forwarder: scrub, payload mapping (dashboard-compatible), cursor
|
||||
reader, and config persistence. Pure/deterministic — no network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from meshtastic_mcp.web.db import repo_settings as rs
|
||||
from meshtastic_mcp.web.db.database import Database
|
||||
from meshtastic_mcp.web.services import datadog as dd
|
||||
from meshtastic_mcp.web.services.scrub import Scrubber
|
||||
|
||||
|
||||
def test_scrub_modes():
|
||||
assert Scrubber("off").scrub("lat=37.774929") == "lat=37.774929"
|
||||
assert (
|
||||
Scrubber("coarse").scrub("lat=37.774929 lon=-122.419416")
|
||||
== "lat=37.8 lon=-122.4"
|
||||
)
|
||||
assert Scrubber("redact").scrub("latitude=37.774929") == "latitude=<scrubbed>"
|
||||
# protobuf 1e-7 integer coords
|
||||
assert Scrubber("coarse").scrub("latitude_i=377749290") == "latitude_i=377000000"
|
||||
assert Scrubber("redact").scrub("lon_i=-1224194150") == "lon_i=<scrubbed>"
|
||||
# NMEA body dropped
|
||||
assert Scrubber("coarse").scrub("$GPGGA,123519,4807.038,N") == "$GPGGA,<scrubbed>"
|
||||
# non-coordinate decimals untouched (no false positive on "latency")
|
||||
assert Scrubber("coarse").scrub("latency=12.5ms") == "latency=12.5ms"
|
||||
|
||||
|
||||
def test_log_payload_is_dashboard_compatible():
|
||||
rec = {
|
||||
"ts": 1700000000.5,
|
||||
"port": "/dev/cu.x",
|
||||
"level": "ERROR",
|
||||
"tag": "Router",
|
||||
"line": "\x1b[31mradio fault\x1b[0m",
|
||||
"heap_free": 1234,
|
||||
}
|
||||
m = dd.log_to_dd(
|
||||
rec,
|
||||
host="bench1",
|
||||
base_tags=["collector:bench"],
|
||||
port_tags={"/dev/cu.x": ["role:esp32s3", "env:heltec-v4"]},
|
||||
scrubber=Scrubber("off"),
|
||||
ship_debug=False,
|
||||
)
|
||||
assert m["ddsource"] == "meshtastic-firmware"
|
||||
assert m["service"] == "meshtastic-firmware"
|
||||
assert m["hostname"] == "bench1"
|
||||
assert m["message"] == "radio fault" # ANSI stripped
|
||||
assert m["status"] == "error" and m["level"] == "ERROR"
|
||||
assert m["timestamp"] == 1700000000500 # ms
|
||||
assert m["heap_free"] == 1234
|
||||
tags = set(m["ddtags"].split(","))
|
||||
assert {
|
||||
"collector:bench",
|
||||
"port:/dev/cu.x",
|
||||
"role:esp32s3",
|
||||
"env:heltec-v4",
|
||||
"level:error",
|
||||
"thread:router",
|
||||
} <= tags
|
||||
|
||||
|
||||
def test_log_debug_skipped_but_panics_always_ship():
|
||||
base = dict(host="h", base_tags=[], port_tags={}, scrubber=Scrubber("off"))
|
||||
assert (
|
||||
dd.log_to_dd({"level": "DEBUG", "line": "x"}, ship_debug=False, **base) is None
|
||||
)
|
||||
assert (
|
||||
dd.log_to_dd({"level": "DEBUG", "line": "x"}, ship_debug=True, **base)
|
||||
is not None
|
||||
)
|
||||
# an un-leveled line (panic/backtrace) always ships
|
||||
assert (
|
||||
dd.log_to_dd(
|
||||
{"level": None, "line": "Guru Meditation"}, ship_debug=False, **base
|
||||
)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def test_metric_mapping():
|
||||
metrics = dd.telemetry_to_metrics(
|
||||
{
|
||||
"ts": 1700000000,
|
||||
"port": "/dev/cu.x",
|
||||
"variant": "device",
|
||||
"fields": {
|
||||
"battery_level": 101,
|
||||
"air_util_tx": 1.5,
|
||||
"ok": True,
|
||||
"name": "z",
|
||||
},
|
||||
},
|
||||
host="bench1",
|
||||
base_tags=["collector:bench"],
|
||||
port_tags={"/dev/cu.x": ["role:esp32s3"]},
|
||||
)
|
||||
names = {x["metric"] for x in metrics}
|
||||
assert names == {
|
||||
"mesh.device.battery_level",
|
||||
"mesh.device.air_util_tx",
|
||||
} # bool/str dropped
|
||||
one = metrics[0]
|
||||
assert one["type"] == 3 # GAUGE
|
||||
assert one["resources"] == [{"type": "host", "name": "bench1"}]
|
||||
assert "collector:bench" in one["tags"] and "variant:device" in one["tags"]
|
||||
assert "role:esp32s3" in one["tags"]
|
||||
|
||||
|
||||
def test_cursor_reader_partial_line_and_truncation(tmp_path):
|
||||
p = tmp_path / "logs.jsonl"
|
||||
p.write_text(
|
||||
json.dumps({"a": 1}) + "\n" + json.dumps({"a": 2}) + "\n" + '{"a": 3}'
|
||||
) # last: no newline
|
||||
rows, cand = dd._read_live(p, {}, 100)
|
||||
assert [r["a"] for r in rows] == [1, 2] # partial 3rd line left for next cycle
|
||||
# resume from candidate → no rows until the partial line completes
|
||||
rows2, cand2 = dd._read_live(p, cand, 100)
|
||||
assert rows2 == []
|
||||
p.write_text(
|
||||
json.dumps({"a": 1})
|
||||
+ "\n"
|
||||
+ json.dumps({"a": 2})
|
||||
+ "\n"
|
||||
+ json.dumps({"a": 3})
|
||||
+ "\n"
|
||||
)
|
||||
rows3, _ = dd._read_live(p, cand, 100)
|
||||
assert [r["a"] for r in rows3] == [3]
|
||||
# a cursor pos beyond EOF (truncation/rotation) resets to 0
|
||||
rows4, _ = dd._read_live(p, {"ino": cand["ino"], "pos": 10_000_000}, 100)
|
||||
assert [r["a"] for r in rows4] == [1, 2, 3]
|
||||
|
||||
|
||||
def test_browser_intake_origin():
|
||||
assert (
|
||||
dd._browser_intake_origin("us5.datadoghq.com")
|
||||
== "https://browser-intake-us5-datadoghq.com"
|
||||
)
|
||||
assert (
|
||||
dd._browser_intake_origin("datadoghq.eu")
|
||||
== "https://browser-intake-datadoghq.eu"
|
||||
)
|
||||
|
||||
|
||||
def test_config_masks_key_and_persists(tmp_path):
|
||||
async def go():
|
||||
db = Database(path=tmp_path / "r.db")
|
||||
await db.connect()
|
||||
try:
|
||||
cfg = dd.DDConfig(
|
||||
enabled=True, api_key="abcd1234efgh5678", site="us5.datadoghq.com"
|
||||
)
|
||||
masked = cfg.masked()
|
||||
assert "api_key" not in masked
|
||||
assert masked["has_key"] is True and masked["key_hint"] == "5678"
|
||||
assert masked["is_client_token"] is False
|
||||
# client token detection
|
||||
assert dd.DDConfig(api_key="pubXYZ").masked()["is_client_token"] is True
|
||||
# persistence round-trip via settings store
|
||||
from dataclasses import asdict
|
||||
|
||||
await rs.set_json(db, "datadog", asdict(cfg))
|
||||
back = dd.DDConfig.from_dict(await rs.get_json(db, "datadog"))
|
||||
assert back.enabled and back.api_key == "abcd1234efgh5678"
|
||||
assert back.site == "us5.datadoghq.com"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Unit tests for the web stack's SQLite registry + identity reconciliation.
|
||||
|
||||
No hardware, no event loop fixtures — each test drives the async helpers via
|
||||
``asyncio.run`` against a tmpdir-backed Database.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.web.db import repo_cameras as rc
|
||||
from meshtastic_mcp.web.db import repo_devices as rd
|
||||
from meshtastic_mcp.web.db import repo_flash as rf
|
||||
from meshtastic_mcp.web.db import repo_runs as rr
|
||||
from meshtastic_mcp.web.db.database import Database
|
||||
from meshtastic_mcp.web.services import control, identity
|
||||
|
||||
|
||||
def _fresh_db(tmp_path) -> Database:
|
||||
return Database(path=tmp_path / "registry.db")
|
||||
|
||||
|
||||
def test_port_follow_keeps_one_device_and_persists_friendly_name(tmp_path):
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
r1 = await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="SER1",
|
||||
current_port="/dev/tty.usbmodem1111",
|
||||
vid="0x239a",
|
||||
pid="0x0029",
|
||||
role="nrf52",
|
||||
)
|
||||
assert r1["_is_new"] and not r1["_port_changed"]
|
||||
await rd.set_friendly_name(db, "SER1", "rak-bench")
|
||||
|
||||
# Same device reappears on a new port.
|
||||
r2 = await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="SER1",
|
||||
current_port="/dev/tty.usbmodem2222",
|
||||
vid="0x239a",
|
||||
pid="0x0029",
|
||||
role="nrf52",
|
||||
)
|
||||
assert r2["_port_changed"] and not r2["_is_new"]
|
||||
assert r2["current_port"] == "/dev/tty.usbmodem2222"
|
||||
assert r2["friendly_name"] == "rak-bench" # survived the port change
|
||||
|
||||
devs = await rd.list_all(db)
|
||||
assert len(devs) == 1 # one row, not two
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_offline_marking_keeps_row(tmp_path):
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="SER1",
|
||||
current_port="/dev/x",
|
||||
vid="0x303a",
|
||||
pid="0x1",
|
||||
role="esp32s3",
|
||||
)
|
||||
newly_offline = await rd.mark_offline_except(db, set())
|
||||
assert newly_offline == ["SER1"]
|
||||
row = await rd.get(db, "SER1")
|
||||
assert row is not None and row["online"] == 0 # row stays, greyed
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_camera_assignment_survives_port_change(tmp_path):
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="SER1",
|
||||
current_port="/dev/a",
|
||||
vid="0x239a",
|
||||
pid="0x1",
|
||||
role="nrf52",
|
||||
)
|
||||
cid = await rc.add(db, name="cam", device_index="0")
|
||||
await rc.assign(db, cid, "SER1")
|
||||
# Device moves ports.
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="SER1",
|
||||
current_port="/dev/b",
|
||||
vid="0x239a",
|
||||
pid="0x1",
|
||||
role="nrf52",
|
||||
)
|
||||
cam = await rc.for_device(db, "SER1")
|
||||
assert cam is not None and cam["device_serial"] == "SER1"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_camera_rotation_persists_and_normalizes(tmp_path):
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
cid = await rc.add(db, name="cam", device_index="0")
|
||||
assert (await rc.get(db, cid))["rotation"] == 0 # default
|
||||
await rc.set_rotation(db, cid, 90)
|
||||
assert (await rc.get(db, cid))["rotation"] == 90
|
||||
await rc.set_rotation(db, cid, 360) # wraps to 0
|
||||
assert (await rc.get(db, cid))["rotation"] == 0
|
||||
await rc.set_rotation(db, cid, 450) # wraps to 90
|
||||
assert (await rc.get(db, cid))["rotation"] == 90
|
||||
await rc.set_rotation(db, cid, 100) # snaps to nearest quarter (90)
|
||||
assert (await rc.get(db, cid))["rotation"] == 90
|
||||
# rotation survives reassignment (it's a property of the camera)
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="X",
|
||||
current_port="/dev/a",
|
||||
vid="0x239a",
|
||||
pid="0x1",
|
||||
role="nrf52",
|
||||
)
|
||||
await rc.assign(db, cid, "X")
|
||||
assert (await rc.for_device(db, "X"))["rotation"] == 90
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_role_to_serial_mapping(tmp_path):
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="NRF",
|
||||
current_port="/dev/a",
|
||||
vid="0x239a",
|
||||
pid="0x1",
|
||||
role="nrf52",
|
||||
)
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="ESP",
|
||||
current_port="/dev/b",
|
||||
vid="0x303a",
|
||||
pid="0x1",
|
||||
role="esp32s3",
|
||||
)
|
||||
nrf = await rd.online_by_role(db, "nrf52")
|
||||
esp = await rd.online_by_role(db, "esp32s3")
|
||||
assert nrf["serial_number"] == "NRF"
|
||||
assert esp["serial_number"] == "ESP"
|
||||
# mesh-pair nodeid → both roles → both serials resolvable
|
||||
run_id = await rr.create_run(
|
||||
db, args=[], seed="s", fw_branch="develop", fw_sha="abc", fw_dirty=False
|
||||
)
|
||||
await rr.add_result(
|
||||
db,
|
||||
run_id,
|
||||
nodeid="tests/mesh/test_x.py::test_y[nrf52]",
|
||||
tier="mesh",
|
||||
outcome="passed",
|
||||
duration_s=1.0,
|
||||
device_serial="NRF",
|
||||
longrepr=None,
|
||||
)
|
||||
hist = await rr.results_for_device(db, "NRF")
|
||||
assert len(hist) == 1 and hist[0]["fw_sha"] == "abc"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_identity_helpers():
|
||||
assert identity.role_for_vid("0x239a") == "nrf52"
|
||||
assert identity.role_for_vid("0x303A") == "esp32s3" # case-insensitive
|
||||
assert identity.role_for_vid("0x10c4") == "esp32s3"
|
||||
assert identity.role_for_vid(None) is None
|
||||
assert identity.env_for_role("nrf52") == "rak4631"
|
||||
assert identity.env_for_role("esp32s3") == "heltec-v3"
|
||||
|
||||
# Device with a real serial → stable key; blank serial → surrogate.
|
||||
key, stable = identity.device_key(
|
||||
{"serial_number": "ABC", "vid": "0x239a", "pid": "1", "port": "/dev/x"}
|
||||
)
|
||||
assert key == "ABC" and stable is True
|
||||
key2, stable2 = identity.device_key(
|
||||
{"serial_number": None, "vid": "0x10c4", "pid": "0xea60", "port": "/dev/y"}
|
||||
)
|
||||
assert key2.startswith("noserial:") and stable2 is False
|
||||
assert identity.has_stable_id("ABC") and not identity.has_stable_id(key2)
|
||||
|
||||
|
||||
def test_flash_timing_comparison(tmp_path):
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="SER1",
|
||||
current_port="/dev/a",
|
||||
vid="0x239a",
|
||||
pid="0x1",
|
||||
role="nrf52",
|
||||
)
|
||||
# A slow host rebuild, then a fast direct-artifact flash.
|
||||
await rf.record(
|
||||
db,
|
||||
device_serial="SER1",
|
||||
env="rak4631",
|
||||
fw_sha="abc",
|
||||
from_artifact=False,
|
||||
duration_s=210.0,
|
||||
ok=True,
|
||||
)
|
||||
await rf.record(
|
||||
db,
|
||||
device_serial="SER1",
|
||||
env="rak4631",
|
||||
fw_sha="abc",
|
||||
from_artifact=True,
|
||||
duration_s=10.0,
|
||||
ok=True,
|
||||
)
|
||||
cmp = await rf.comparison(db, "SER1")
|
||||
assert cmp["artifact"]["duration_s"] == 10.0
|
||||
assert cmp["rebuild"]["duration_s"] == 210.0
|
||||
assert cmp["speedup"] == 21.0
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_env_resolves_from_hw_model_not_just_role(monkeypatch):
|
||||
"""A Heltec V4 (HELTEC_V4) must resolve to env heltec-v4, NOT the coarse
|
||||
esp32s3→heltec-v3 role default. Regression for the wrong-variant flash risk
|
||||
surfaced by real-hardware testing. Stubs the board catalog (no pio needed)."""
|
||||
import meshtastic_mcp.boards as boards_mod
|
||||
|
||||
fake_catalog = [
|
||||
{"env": "heltec-v3", "hw_model_slug": "HELTEC_V3"},
|
||||
{"env": "heltec-v4", "hw_model_slug": "HELTEC_V4"},
|
||||
{"env": "heltec-v4-tft", "hw_model_slug": "HELTEC_V4"}, # variant
|
||||
{"env": "heltec-wsl-v3", "hw_model_slug": "HELTEC_WSL_V3"},
|
||||
]
|
||||
monkeypatch.setattr(boards_mod, "list_boards", lambda *a, **k: fake_catalog)
|
||||
|
||||
assert identity.env_for_hw_model("HELTEC_V4") == "heltec-v4" # base, not -tft
|
||||
assert identity.env_for_hw_model("HELTEC_V3") == "heltec-v3"
|
||||
assert identity.env_for_hw_model("HELTEC_WSL_V3") == "heltec-wsl-v3"
|
||||
assert identity.env_for_hw_model("NOT_A_BOARD") is None
|
||||
assert identity.env_for_hw_model(None) is None
|
||||
|
||||
# control.env_for_device prefers the device's resolved env over the role default.
|
||||
assert (
|
||||
control.env_for_device({"role": "esp32s3", "env": "heltec-v4"}) == "heltec-v4"
|
||||
)
|
||||
# No resolved env → falls back to the role default.
|
||||
assert control.env_for_device({"role": "esp32s3", "env": None}) == "heltec-v3"
|
||||
|
||||
|
||||
def test_suite_env_overrides_from_connected_boards(tmp_path):
|
||||
"""The test runner bakes the variant resolved per connected board: an online
|
||||
Heltec V4 → MESHTASTIC_MCP_ENV_ESP32S3=heltec-v4 (not the heltec-v3 default).
|
||||
Native (TCP) nodes and un-enriched devices are excluded."""
|
||||
from meshtastic_mcp.web.services.test_runner import resolve_env_overrides
|
||||
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
# esp32s3 V4 (enriched), an nrf52 (rak4631), a stale esp32 (no env),
|
||||
# and a native node — only the first two should produce overrides.
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="V4",
|
||||
current_port="/dev/a",
|
||||
vid="0x303a",
|
||||
pid="0x1",
|
||||
role="esp32s3",
|
||||
)
|
||||
await rd.update_enrichment(db, "V4", node_num=1, env="heltec-v4")
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="NRF",
|
||||
current_port="/dev/b",
|
||||
vid="0x239a",
|
||||
pid="0x1",
|
||||
role="nrf52",
|
||||
)
|
||||
await rd.update_enrichment(db, "NRF", node_num=2, env="rak4631")
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="UNENRICHED",
|
||||
current_port="/dev/c",
|
||||
vid="0x303a",
|
||||
pid="0x1",
|
||||
role="esp32s3",
|
||||
) # no env yet → excluded
|
||||
await rd.upsert_native(db, name="sim", tcp_port=4403, online=True)
|
||||
|
||||
rows = await rd.online_with_env(db)
|
||||
overrides = resolve_env_overrides(rows)
|
||||
assert overrides["MESHTASTIC_MCP_ENV_ESP32S3"] == "heltec-v4"
|
||||
assert overrides["MESHTASTIC_MCP_ENV_NRF52"] == "rak4631"
|
||||
# native nodes never become a flash/bake target
|
||||
assert not any("native" in v.lower() for v in overrides.values())
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_manual_env_override_survives_enrichment(tmp_path):
|
||||
"""A user-pinned env must not be clobbered by auto-enrichment; releasing the
|
||||
pin lets hw_model resolution take over again."""
|
||||
|
||||
async def go():
|
||||
db = _fresh_db(tmp_path)
|
||||
await db.connect()
|
||||
try:
|
||||
await rd.upsert_from_discovery(
|
||||
db,
|
||||
serial_number="D",
|
||||
current_port="/dev/a",
|
||||
vid="0x303a",
|
||||
pid="0x1",
|
||||
role="esp32s3",
|
||||
)
|
||||
# Auto enrichment resolves (wrongly, say) to heltec-v3.
|
||||
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
|
||||
assert (await rd.get(db, "D"))["env"] == "heltec-v3"
|
||||
|
||||
# User pins the correct env.
|
||||
await rd.set_env(db, "D", "heltec-v4", locked=True)
|
||||
row = await rd.get(db, "D")
|
||||
assert row["env"] == "heltec-v4" and row["env_locked"] == 1
|
||||
|
||||
# A later auto-enrichment must NOT overwrite the pinned env.
|
||||
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
|
||||
assert (await rd.get(db, "D"))["env"] == "heltec-v4"
|
||||
|
||||
# Releasing the pin lets auto-detect win again.
|
||||
await rd.set_env(db, "D", None, locked=False)
|
||||
await rd.update_enrichment(db, "D", node_num=1, env="heltec-v3")
|
||||
assert (await rd.get(db, "D"))["env"] == "heltec-v3"
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
asyncio.run(go())
|
||||
|
||||
|
||||
def test_control_rejected_while_run_active(monkeypatch):
|
||||
# The central safety property: no connect()-based action while a run holds
|
||||
# the ports. Simulate an active run and assert every gate raises.
|
||||
monkeypatch.setattr(
|
||||
"meshtastic_mcp.web.services.test_runner.is_running", lambda: True
|
||||
)
|
||||
with pytest.raises(control.ControlBusy):
|
||||
control._ensure_idle()
|
||||
with pytest.raises(control.ControlBusy):
|
||||
control._ensure_port_free("/dev/whatever")
|
||||
@@ -0,0 +1,48 @@
|
||||
# FleetSuite — web UI
|
||||
|
||||
Vue 3 + Vite + Tailwind v4 + Pinia single-page app for the Meshtastic test
|
||||
harness. Replaces the old Textual TUI. It talks to the FastAPI backend in
|
||||
`../src/meshtastic_mcp/web` over REST + a single `/ws` WebSocket.
|
||||
|
||||
## Develop
|
||||
|
||||
```bash
|
||||
# from mcp-server/: runs the backend + this dev server together (HMR)
|
||||
./scripts/web-dev.sh
|
||||
```
|
||||
|
||||
or manually:
|
||||
|
||||
```bash
|
||||
# terminal 1 — backend
|
||||
cd mcp-server && .venv/bin/python -m uvicorn meshtastic_mcp.web.app:create_app \
|
||||
--factory --port 8765 --reload
|
||||
# terminal 2 — frontend (proxies /api + /ws → :8765)
|
||||
cd mcp-server/web-ui && npm install && npm run dev
|
||||
```
|
||||
|
||||
## Build (production)
|
||||
|
||||
```bash
|
||||
npm run build # emits into ../src/meshtastic_mcp/web/static (gitignored)
|
||||
```
|
||||
|
||||
Then `meshtastic-mcp-web` serves that build and the API on one port and opens a
|
||||
pywebview window (`--browser` to serve only).
|
||||
|
||||
## Layout
|
||||
|
||||
- `stores/` — Pinia stores. `ws.ts` owns the single WebSocket and dispatches
|
||||
topic-tagged frames; `devices` / `cameras` / `firmware` / `tests` hydrate via
|
||||
REST and apply live deltas.
|
||||
- `components/` — `DeviceCard` (keyed by serial → follows the device across
|
||||
ports), `CameraFeed` (MJPEG `<img>`), `TestDashboard` (counters/tree/logs),
|
||||
etc.
|
||||
- `api/client.ts` — thin REST wrappers (relative URLs; same build works behind
|
||||
pywebview and the dev proxy).
|
||||
|
||||
## macOS camera permission
|
||||
|
||||
`cv2.VideoCapture` inherits the launching process's TCC Camera grant. Launched
|
||||
from a terminal, the first stream prompts for permission. A packaged `.app`
|
||||
would need `NSCameraUsageDescription`.
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Meshtastic FleetSuite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2258
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "meshtastic-fleetsuite",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.2.0",
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import AppBar from "./components/AppBar.vue";
|
||||
import DeviceGrid from "./components/DeviceGrid.vue";
|
||||
import TestDashboard from "./components/TestDashboard.vue";
|
||||
import { useBuildsStore } from "./stores/builds";
|
||||
import { useCamerasStore } from "./stores/cameras";
|
||||
import { useDatadogStore } from "./stores/datadog";
|
||||
import { useDevicesStore } from "./stores/devices";
|
||||
import { useFirmwareStore } from "./stores/firmware";
|
||||
import { useTestsStore } from "./stores/tests";
|
||||
import { useWsStore } from "./stores/ws";
|
||||
|
||||
const tab = ref("fleet");
|
||||
|
||||
const ws = useWsStore();
|
||||
const devices = useDevicesStore();
|
||||
const cameras = useCamerasStore();
|
||||
const firmware = useFirmwareStore();
|
||||
const tests = useTestsStore();
|
||||
const builds = useBuildsStore();
|
||||
const datadog = useDatadogStore();
|
||||
|
||||
onMounted(() => {
|
||||
ws.connect();
|
||||
devices.init();
|
||||
cameras.init();
|
||||
firmware.init();
|
||||
tests.init();
|
||||
builds.init();
|
||||
datadog.init();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen">
|
||||
<AppBar :tab="tab" @update:tab="(v) => (tab = v)" />
|
||||
<main>
|
||||
<DeviceGrid v-show="tab === 'fleet'" />
|
||||
<TestDashboard v-if="tab === 'tests'" />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,76 @@
|
||||
// Minimal ANSI SGR → HTML converter for log panes. Firmware debug output and
|
||||
// pytest both emit color codes (e.g. \x1b[34m for DEBUG). We render the common
|
||||
// foreground colors + bold and DROP every other escape sequence. Text content
|
||||
// is HTML-escaped first, so the only markup that reaches the DOM is our own
|
||||
// <span> tags — safe to use with v-html.
|
||||
|
||||
const CSI = /\x1b\[([0-9;]*)([A-Za-z])/g;
|
||||
|
||||
// Tuned for a dark background (pure black/white pushed toward slate).
|
||||
const FG: Record<number, string> = {
|
||||
30: "#64748b",
|
||||
31: "#f87171",
|
||||
32: "#34d399",
|
||||
33: "#fbbf24",
|
||||
34: "#60a5fa",
|
||||
35: "#c084fc",
|
||||
36: "#22d3ee",
|
||||
37: "#e5e7eb",
|
||||
90: "#94a3b8",
|
||||
91: "#fca5a5",
|
||||
92: "#6ee7b7",
|
||||
93: "#fde68a",
|
||||
94: "#93c5fd",
|
||||
95: "#d8b4fe",
|
||||
96: "#67e8f9",
|
||||
97: "#f8fafc",
|
||||
};
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function span(text: string, color: string | null, bold: boolean): string {
|
||||
const styles: string[] = [];
|
||||
if (color) styles.push(`color:${color}`);
|
||||
if (bold) styles.push("font-weight:600");
|
||||
const attr = styles.length ? ` style="${styles.join(";")}"` : "";
|
||||
return `<span${attr}>${escapeHtml(text)}</span>`;
|
||||
}
|
||||
|
||||
const _cache = new Map<string, string>();
|
||||
|
||||
export function ansiToHtml(line: string): string {
|
||||
const hit = _cache.get(line);
|
||||
if (hit !== undefined) return hit;
|
||||
|
||||
let out = "";
|
||||
let idx = 0;
|
||||
let color: string | null = null;
|
||||
let bold = false;
|
||||
CSI.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = CSI.exec(line)) !== null) {
|
||||
if (m.index > idx) out += span(line.slice(idx, m.index), color, bold);
|
||||
idx = CSI.lastIndex;
|
||||
if (m[2] === "m") {
|
||||
const codes = m[1] === "" ? [0] : m[1].split(";").map((c) => Number(c));
|
||||
for (const c of codes) {
|
||||
if (c === 0) {
|
||||
color = null;
|
||||
bold = false;
|
||||
} else if (c === 1) bold = true;
|
||||
else if (c === 22) bold = false;
|
||||
else if (c === 39) color = null;
|
||||
else if (FG[c] !== undefined) color = FG[c];
|
||||
}
|
||||
}
|
||||
// Non-SGR sequences (cursor moves, clear-line, …) are dropped.
|
||||
}
|
||||
if (idx < line.length) out += span(line.slice(idx), color, bold);
|
||||
|
||||
// Bound the cache so a long-running session doesn't grow unbounded.
|
||||
if (_cache.size > 6000) _cache.clear();
|
||||
_cache.set(line, out);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Thin REST wrappers over the FastAPI backend. Relative URLs so the same build
|
||||
// works behind the pywebview window and the Vite dev proxy.
|
||||
|
||||
async function req<T>(method: string, url: string, body?: unknown): Promise<T> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body !== undefined ? { "content-type": "application/json" } : {},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
try {
|
||||
detail = (await res.json()).detail ?? detail;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new Error(`${res.status}: ${detail}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(url: string) => req<T>("GET", url),
|
||||
post: <T>(url: string, body?: unknown) => req<T>("POST", url, body ?? {}),
|
||||
put: <T>(url: string, body?: unknown) => req<T>("PUT", url, body ?? {}),
|
||||
patch: <T>(url: string, body?: unknown) => req<T>("PATCH", url, body ?? {}),
|
||||
del: <T>(url: string) => req<T>("DELETE", url),
|
||||
};
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { useWsStore } from "../stores/ws";
|
||||
import { useDevicesStore } from "../stores/devices";
|
||||
import FirmwareRef from "./FirmwareRef.vue";
|
||||
|
||||
defineProps<{ tab: string }>();
|
||||
const emit = defineEmits<{ (e: "update:tab", v: string): void }>();
|
||||
|
||||
const ws = useWsStore();
|
||||
const devices = useDevicesStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class="flex items-center gap-4 px-5 py-3 border-b border-slate-800 bg-slate-950/70 backdrop-blur sticky top-0 z-10"
|
||||
>
|
||||
<!-- Wordmark + LoRa-chirp signature (nods to the Meshtastic logo origin) -->
|
||||
<div class="flex items-center gap-2.5">
|
||||
<svg
|
||||
viewBox="0 0 40 24"
|
||||
class="w-9 h-5 shrink-0"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="chirp" x1="0" y1="0" x2="40" y2="0"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#9ba8e0" />
|
||||
<stop offset="1" stop-color="#67ea94" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<!-- a rising chirp: frequency increases left→right -->
|
||||
<path
|
||||
d="M1 12 C3 5, 5 5, 7 12 S11 19, 13 12 S16 6, 18 12 S20.5 17, 22.5 12 S24.5 8, 26 12 S27.5 15.5, 29 12 S30.5 9.5, 32 12 S33 14, 34 12 S35 11, 36 12 39 12 39 12"
|
||||
stroke="url(#chirp)"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span class="text-sm font-medium text-slate-400 tracking-tight"
|
||||
>Meshtastic</span
|
||||
>
|
||||
<span class="fs-display text-base font-bold text-indigo-300"
|
||||
>FleetSuite</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex gap-1 ml-3">
|
||||
<button
|
||||
v-for="t in ['fleet', 'tests']"
|
||||
:key="t"
|
||||
@click="emit('update:tab', t)"
|
||||
class="px-3 py-1.5 rounded-md text-xs fs-display transition"
|
||||
:class="
|
||||
tab === t
|
||||
? 'bg-indigo-600/20 text-indigo-200 ring-1 ring-indigo-600/60'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800'
|
||||
"
|
||||
>
|
||||
{{ t === "tests" ? "Test Suite" : "Fleet" }}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="flex-1" />
|
||||
|
||||
<!-- Instrument readout: online count, mint when any are live -->
|
||||
<span
|
||||
class="flex items-center gap-1.5 text-xs mono px-2 py-1 rounded-md bg-slate-900/70 border border-slate-800"
|
||||
>
|
||||
<span
|
||||
class="w-1.5 h-1.5 rounded-full"
|
||||
:class="
|
||||
devices.list.filter((d) => d.online).length
|
||||
? 'bg-emerald-400'
|
||||
: 'bg-slate-600'
|
||||
"
|
||||
/>
|
||||
<span class="text-slate-300 tabular-nums">{{
|
||||
devices.list.filter((d) => d.online).length
|
||||
}}</span>
|
||||
<span class="text-slate-500">online</span>
|
||||
</span>
|
||||
|
||||
<FirmwareRef />
|
||||
|
||||
<span
|
||||
class="flex items-center gap-1.5 text-xs fs-display"
|
||||
:class="ws.connected ? 'text-emerald-400' : 'text-rose-400'"
|
||||
>
|
||||
<span class="relative flex h-2 w-2">
|
||||
<span
|
||||
v-if="ws.connected"
|
||||
class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-60"
|
||||
/>
|
||||
<span
|
||||
class="relative inline-flex rounded-full h-2 w-2"
|
||||
:class="ws.connected ? 'bg-emerald-400' : 'bg-rose-400'"
|
||||
/>
|
||||
</span>
|
||||
{{ ws.connected ? "live" : "offline" }}
|
||||
</span>
|
||||
</header>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { useBuildsStore } from "../stores/builds";
|
||||
import { useFirmwareStore } from "../stores/firmware";
|
||||
|
||||
const builds = useBuildsStore();
|
||||
const fw = useFirmwareStore();
|
||||
|
||||
const STATUS: Record<string, { glyph: string; cls: string }> = {
|
||||
queued: { glyph: "…", cls: "text-slate-400" },
|
||||
building: { glyph: "⏳", cls: "text-amber-400 animate-pulse" },
|
||||
success: { glyph: "✓", cls: "text-emerald-400" },
|
||||
cached: { glyph: "✓", cls: "text-emerald-400/70" },
|
||||
failed: { glyph: "✗", cls: "text-rose-400" },
|
||||
cancelled: { glyph: "∅", cls: "text-slate-500" },
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
|
||||
<h3 class="section-label">Build Queue</h3>
|
||||
<span
|
||||
v-if="!builds.dockerAvailable"
|
||||
class="text-[11px] px-2 py-0.5 rounded bg-amber-950/40 text-amber-400"
|
||||
title="Docker not detected — builds fall back to host pio (not parallelized)"
|
||||
>Docker unavailable — host builds</span
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
@click="builds.prebuildTracked()"
|
||||
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50"
|
||||
:title="'prebuild connected device targets @ ' + (fw.ref.short_sha || '')"
|
||||
>
|
||||
prebuild current ref
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="b in builds.list"
|
||||
:key="b.id"
|
||||
class="flex items-center gap-2 text-xs rounded-lg border border-slate-800 px-2.5 py-1.5"
|
||||
:title="b.error || b.artifact_dir || ''"
|
||||
>
|
||||
<span :class="(STATUS[b.status] || STATUS.queued).cls">{{
|
||||
(STATUS[b.status] || STATUS.queued).glyph
|
||||
}}</span>
|
||||
<span class="text-slate-200">{{ b.env }}</span>
|
||||
<span class="mono text-emerald-300/60">{{ b.fw_sha?.slice(0, 7) }}</span>
|
||||
<span v-if="b.duration_s" class="text-slate-500"
|
||||
>{{ b.duration_s.toFixed(0) }}s</span
|
||||
>
|
||||
<span v-if="b.cached" class="text-slate-600">cached</span>
|
||||
</div>
|
||||
<div v-if="builds.list.length === 0" class="text-xs text-slate-600">
|
||||
no builds yet — "prebuild current ref" builds each connected target in
|
||||
parallel (Docker) in the background
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useCamerasStore } from "../stores/cameras";
|
||||
import type { Camera } from "../types";
|
||||
|
||||
const props = defineProps<{ camera?: Camera }>();
|
||||
const cameras = useCamerasStore();
|
||||
|
||||
const errored = ref(false);
|
||||
const statusMsg = ref<string | null>(null);
|
||||
// Cache-bust so reassign / remount restarts the stream.
|
||||
const nonce = ref(Date.now());
|
||||
|
||||
const src = computed(() =>
|
||||
props.camera
|
||||
? `/api/cameras/${props.camera.id}/stream.mjpg?t=${nonce.value}`
|
||||
: "",
|
||||
);
|
||||
|
||||
const rotation = computed(() => props.camera?.rotation ?? 0);
|
||||
const mirrored = computed(() => !!props.camera?.mirror);
|
||||
|
||||
// Rotation + mirror are pure CSS (the MJPEG stream isn't restarted). For 90/270
|
||||
// we scale a 16:9 feed (filling the 16:9 box) by 9/16 so it fits after the
|
||||
// quarter turn. Mirror is a horizontal flip applied before the rotation.
|
||||
const imgStyle = computed(() => {
|
||||
const r = rotation.value;
|
||||
const scale = r === 90 || r === 270 ? 0.5625 : 1;
|
||||
const flip = mirrored.value ? " scaleX(-1)" : "";
|
||||
return {
|
||||
transform: `rotate(${r}deg) scale(${scale})${flip}`,
|
||||
transition: "transform 0.2s ease",
|
||||
};
|
||||
});
|
||||
|
||||
// Only the camera id changing should restart the stream (not a rotation save).
|
||||
watch(
|
||||
() => props.camera?.id,
|
||||
() => {
|
||||
errored.value = false;
|
||||
statusMsg.value = null;
|
||||
nonce.value = Date.now();
|
||||
},
|
||||
);
|
||||
|
||||
async function onError() {
|
||||
errored.value = true;
|
||||
if (!props.camera) return;
|
||||
try {
|
||||
const s = await api.get<{ ok: boolean; error: string | null }>(
|
||||
`/api/cameras/${props.camera.id}/status`,
|
||||
);
|
||||
statusMsg.value = s.ok ? "stream interrupted" : s.error;
|
||||
} catch {
|
||||
statusMsg.value = "camera unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
function retry() {
|
||||
errored.value = false;
|
||||
statusMsg.value = null;
|
||||
nonce.value = Date.now();
|
||||
}
|
||||
|
||||
async function rotate() {
|
||||
if (!props.camera) return;
|
||||
try {
|
||||
await cameras.setRotation(props.camera.id, (rotation.value + 90) % 360);
|
||||
} catch {
|
||||
/* ignore — transient */
|
||||
}
|
||||
}
|
||||
|
||||
async function mirror() {
|
||||
if (!props.camera) return;
|
||||
try {
|
||||
await cameras.setMirror(props.camera.id, !mirrored.value);
|
||||
} catch {
|
||||
/* ignore — transient */
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative aspect-video w-full bg-black rounded-md overflow-hidden border border-slate-800"
|
||||
>
|
||||
<template v-if="camera && !errored">
|
||||
<img
|
||||
:src="src"
|
||||
:style="imgStyle"
|
||||
class="w-full h-full object-contain"
|
||||
@error="onError"
|
||||
alt="camera feed"
|
||||
/>
|
||||
<span
|
||||
class="absolute top-1 left-1 text-[10px] px-1.5 py-0.5 rounded bg-black/60 text-emerald-300"
|
||||
>● {{ camera.name }}</span
|
||||
>
|
||||
<div class="absolute top-1 right-1 flex gap-1">
|
||||
<button
|
||||
@click="mirror"
|
||||
class="p-1 rounded bg-black/60 transition"
|
||||
:class="mirrored ? 'text-emerald-300' : 'text-slate-300 hover:text-emerald-300'"
|
||||
:title="mirrored ? 'mirror: on (horizontal flip)' : 'mirror (horizontal flip)'"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 3v18" />
|
||||
<path d="M16 7l4 5-4 5" />
|
||||
<path d="M8 7l-4 5 4 5" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="rotate"
|
||||
class="p-1 rounded bg-black/60 text-slate-300 hover:text-emerald-300 transition"
|
||||
:title="`rotate (now ${rotation}°)`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-else-if="camera && errored"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center gap-2 text-center px-3"
|
||||
>
|
||||
<span class="text-rose-400 text-sm">⚠ no signal</span>
|
||||
<span class="text-xs text-slate-500">{{
|
||||
statusMsg || "camera produced no frames"
|
||||
}}</span>
|
||||
<button
|
||||
@click="retry"
|
||||
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
|
||||
>
|
||||
retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="absolute inset-0 flex items-center justify-center text-xs text-slate-600"
|
||||
>
|
||||
no camera assigned
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useCamerasStore } from "../stores/cameras";
|
||||
|
||||
interface Discovered {
|
||||
index: number;
|
||||
name: string;
|
||||
in_use: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
unavailable?: boolean;
|
||||
}
|
||||
|
||||
const cameras = useCamerasStore();
|
||||
const name = ref("");
|
||||
const index = ref("0");
|
||||
const adding = ref(false);
|
||||
const manual = ref(false);
|
||||
|
||||
const discovered = ref<Discovered[]>([]);
|
||||
const scanning = ref(false);
|
||||
const scanned = ref(false);
|
||||
const hasBackend = ref(true); // cv2 present → live preview possible
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true;
|
||||
try {
|
||||
const res = await api.get<{ cv2: boolean; cameras: Discovered[] }>(
|
||||
"/api/cameras/discover",
|
||||
);
|
||||
hasBackend.value = res.cv2;
|
||||
discovered.value = res.cameras;
|
||||
scanned.value = true;
|
||||
} catch {
|
||||
discovered.value = [];
|
||||
scanned.value = true;
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
}
|
||||
onMounted(scan);
|
||||
|
||||
async function quickAdd(cam: Discovered) {
|
||||
await cameras.add(cam.name, String(cam.index));
|
||||
await scan(); // refresh in_use flags
|
||||
}
|
||||
|
||||
async function add() {
|
||||
if (!name.value.trim()) return;
|
||||
adding.value = true;
|
||||
try {
|
||||
await cameras.add(name.value, index.value);
|
||||
name.value = "";
|
||||
index.value = "0";
|
||||
await scan();
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function res(c: Discovered): string {
|
||||
if (c.unavailable) return "can't open";
|
||||
if (c.width && c.height) return `${c.width}×${c.height}`;
|
||||
return hasBackend.value ? "" : "no preview backend";
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
|
||||
<h3 class="section-label">USB Cameras</h3>
|
||||
<div class="flex-1" />
|
||||
<button
|
||||
@click="scan"
|
||||
:disabled="scanning"
|
||||
class="text-xs px-2.5 py-1 rounded border border-indigo-700 text-indigo-300 hover:bg-indigo-600/20 disabled:opacity-40 fs-display"
|
||||
>
|
||||
{{ scanning ? "scanning…" : "⟳ scan" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- discovered cameras -->
|
||||
<ul v-if="discovered.length" class="space-y-1 mb-3">
|
||||
<li
|
||||
v-for="c in discovered"
|
||||
:key="c.index"
|
||||
class="flex items-center gap-2 text-xs px-2 py-1.5 rounded bg-slate-950/40 border border-slate-800"
|
||||
>
|
||||
<span class="w-1.5 h-1.5 rounded-full" :class="c.unavailable ? 'bg-rose-500' : 'bg-emerald-400'" />
|
||||
<span class="text-slate-200 truncate">{{ c.name }}</span>
|
||||
<span class="mono text-slate-500">idx {{ c.index }}</span>
|
||||
<span v-if="res(c)" class="mono text-slate-600">{{ res(c) }}</span>
|
||||
<span class="ml-auto">
|
||||
<span v-if="c.in_use" class="text-emerald-400/70">added ✓</span>
|
||||
<button
|
||||
v-else
|
||||
@click="quickAdd(c)"
|
||||
class="px-2 py-0.5 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50"
|
||||
>
|
||||
add
|
||||
</button>
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p
|
||||
v-else-if="scanned && !scanning"
|
||||
class="text-xs text-slate-600 mb-3"
|
||||
>
|
||||
no cameras detected — connect a USB capture device and scan again
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="scanned && !hasBackend"
|
||||
class="text-[11px] text-amber-400/80 mb-3"
|
||||
>
|
||||
live preview needs OpenCV — install the bench extra:
|
||||
<span class="mono">pip install -e '.[ui]'</span> (discovery still works without it)
|
||||
</p>
|
||||
|
||||
<!-- manual fallback -->
|
||||
<button
|
||||
@click="manual = !manual"
|
||||
class="text-[11px] text-slate-500 hover:text-slate-300"
|
||||
>
|
||||
{{ manual ? "▾" : "▸" }} add by index manually
|
||||
</button>
|
||||
<div v-if="manual" class="flex flex-wrap gap-2 mt-2">
|
||||
<input
|
||||
v-model="name"
|
||||
placeholder="name"
|
||||
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<input
|
||||
v-model="index"
|
||||
placeholder="device index (0)"
|
||||
class="text-xs w-32 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<button
|
||||
@click="add"
|
||||
:disabled="adding"
|
||||
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
|
||||
>
|
||||
add camera
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- registered cameras -->
|
||||
<ul v-if="cameras.list.length" class="space-y-1 mt-3 pt-3 border-t border-slate-800">
|
||||
<li
|
||||
v-for="c in cameras.list"
|
||||
:key="c.id"
|
||||
class="flex items-center gap-2 text-xs text-slate-400"
|
||||
>
|
||||
<span class="text-slate-200">{{ c.name }}</span>
|
||||
<span class="mono">idx {{ c.device_index }}</span>
|
||||
<span v-if="c.device_serial" class="text-emerald-400/70"
|
||||
>→ {{ c.device_serial }}</span
|
||||
>
|
||||
<span v-else class="text-slate-600">unassigned</span>
|
||||
<button
|
||||
@click="cameras.remove(c.id)"
|
||||
class="ml-auto text-rose-400/70 hover:text-rose-300"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from "vue";
|
||||
import { useDatadogStore } from "../stores/datadog";
|
||||
|
||||
const dd = useDatadogStore();
|
||||
|
||||
// Local editable draft, seeded from status and kept in sync when status loads.
|
||||
const draft = reactive({
|
||||
enabled: false,
|
||||
site: "us5.datadoghq.com",
|
||||
scrub: "coarse",
|
||||
collector: "bench",
|
||||
ship_debug: false,
|
||||
api_key: "", // write-only; blank = keep existing
|
||||
});
|
||||
|
||||
let seeded = false;
|
||||
watch(
|
||||
() => dd.status,
|
||||
(s) => {
|
||||
if (!s || seeded) return;
|
||||
draft.enabled = s.config.enabled;
|
||||
draft.site = s.config.site;
|
||||
draft.scrub = s.config.scrub;
|
||||
draft.collector = s.config.collector;
|
||||
draft.ship_debug = s.config.ship_debug;
|
||||
seeded = true;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const busy = ref(false);
|
||||
const msg = ref<string | null>(null);
|
||||
const ok = ref(true);
|
||||
|
||||
async function save() {
|
||||
busy.value = true;
|
||||
msg.value = "saving…";
|
||||
ok.value = true;
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
enabled: draft.enabled,
|
||||
site: draft.site,
|
||||
scrub: draft.scrub,
|
||||
collector: draft.collector,
|
||||
ship_debug: draft.ship_debug,
|
||||
};
|
||||
if (draft.api_key.trim()) payload.api_key = draft.api_key.trim();
|
||||
await dd.save(payload);
|
||||
draft.api_key = ""; // never keep the secret in the field
|
||||
msg.value = "saved";
|
||||
} catch (e: any) {
|
||||
msg.value = e.message;
|
||||
ok.value = false;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function test() {
|
||||
busy.value = true;
|
||||
msg.value = "sending test log…";
|
||||
ok.value = true;
|
||||
try {
|
||||
const r = await dd.test();
|
||||
ok.value = r.ok;
|
||||
msg.value = r.ok ? "test log accepted by Datadog ✓" : `test failed: ${r.error}`;
|
||||
} catch (e: any) {
|
||||
ok.value = false;
|
||||
msg.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
|
||||
<h3 class="section-label">Datadog logging</h3>
|
||||
<span
|
||||
v-if="dd.status"
|
||||
class="flex items-center gap-1.5 text-xs"
|
||||
:class="dd.status.stats.running ? 'text-emerald-400' : 'text-slate-500'"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="dd.status.stats.running ? 'bg-emerald-400' : 'bg-slate-600'"
|
||||
/>
|
||||
{{ dd.status.stats.running ? "shipping" : "idle" }}
|
||||
</span>
|
||||
<span v-if="dd.status" class="text-xs text-slate-500 tabular-nums">
|
||||
{{ dd.status.stats.sent_logs }} logs ·
|
||||
{{ dd.status.stats.sent_metrics }} metrics
|
||||
</span>
|
||||
<span
|
||||
v-if="dd.status?.stats.last_error"
|
||||
class="text-xs text-rose-400 truncate"
|
||||
:title="dd.status.stats.last_error"
|
||||
>⚠ {{ dd.status.stats.last_error }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<label class="flex items-center gap-2 col-span-2">
|
||||
<input type="checkbox" v-model="draft.enabled" class="accent-emerald-500" />
|
||||
<span class="text-slate-300">Ship recorder logs + telemetry to Datadog</span>
|
||||
</label>
|
||||
|
||||
<div class="col-span-2 flex gap-1.5">
|
||||
<input
|
||||
v-model="draft.api_key"
|
||||
type="password"
|
||||
:placeholder="
|
||||
dd.status?.config.has_key
|
||||
? `API key set (••••${dd.status.config.key_hint}) — leave blank to keep`
|
||||
: 'DD_API_KEY (or pub… client token)'
|
||||
"
|
||||
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="text-slate-500"
|
||||
>Site
|
||||
<input
|
||||
v-model="draft.site"
|
||||
class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/></label>
|
||||
<label class="text-slate-500"
|
||||
>GPS scrub
|
||||
<select
|
||||
v-model="draft.scrub"
|
||||
class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none"
|
||||
>
|
||||
<option value="off">off</option>
|
||||
<option value="coarse">coarse (~11 km)</option>
|
||||
<option value="redact">redact</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="text-slate-500"
|
||||
>Collector tag
|
||||
<input
|
||||
v-model="draft.collector"
|
||||
class="w-full mt-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/></label>
|
||||
<label class="flex items-end gap-2 pb-1">
|
||||
<input type="checkbox" v-model="draft.ship_debug" class="accent-emerald-500" />
|
||||
<span class="text-slate-400">ship DEBUG lines</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 mt-3">
|
||||
<button
|
||||
@click="save"
|
||||
:disabled="busy"
|
||||
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
|
||||
>
|
||||
save
|
||||
</button>
|
||||
<button
|
||||
@click="test"
|
||||
:disabled="busy"
|
||||
class="text-xs px-3 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
>
|
||||
test connection
|
||||
</button>
|
||||
<span
|
||||
v-if="msg"
|
||||
class="text-xs mono truncate"
|
||||
:class="ok ? 'text-slate-500' : 'text-rose-400'"
|
||||
:title="msg"
|
||||
>{{ msg }}</span
|
||||
>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-600 mt-2">
|
||||
Streams <code>.mtlog/logs.jsonl</code> → Datadog Logs and
|
||||
<code>telemetry.jsonl</code> → Metrics, tagged <code>collector:{{ draft.collector }}</code
|
||||
>. Same schema as the bench/fleet forwarders, so the existing dashboard works.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,266 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useCamerasStore } from "../stores/cameras";
|
||||
import { useDevicesStore } from "../stores/devices";
|
||||
import { useFirmwareStore } from "../stores/firmware";
|
||||
import type { Device } from "../types";
|
||||
import CameraFeed from "./CameraFeed.vue";
|
||||
import DeviceControls from "./DeviceControls.vue";
|
||||
import DeviceSettings from "./DeviceSettings.vue";
|
||||
import PacketPane from "./PacketPane.vue";
|
||||
import SerialLogPane from "./SerialLogPane.vue";
|
||||
import TestResultsPane from "./TestResultsPane.vue";
|
||||
|
||||
const props = defineProps<{ device: Device }>();
|
||||
const devices = useDevicesStore();
|
||||
const cameras = useCamerasStore();
|
||||
const fw = useFirmwareStore();
|
||||
|
||||
const tab = ref<"serial" | "packets" | "results">("serial");
|
||||
const editing = ref(false);
|
||||
const nameDraft = ref("");
|
||||
const showSettings = ref(false);
|
||||
|
||||
const assignedCamera = computed(() =>
|
||||
cameras.forDevice(props.device.serial_number),
|
||||
);
|
||||
|
||||
// Circular node identifier (Meshtastic design standard): a deterministic color
|
||||
// + short token per node. Color is used on the ring/text only — never as a row
|
||||
// background wash.
|
||||
function colorFor(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return `hsl(${h % 360} 60% 68%)`;
|
||||
}
|
||||
const nodeColor = computed(() =>
|
||||
props.device.online ? colorFor(props.device.serial_number) : "#5c5e78",
|
||||
);
|
||||
const nodeToken = computed(() => {
|
||||
const d = props.device;
|
||||
if (d.role === "native") return "DK";
|
||||
if (d.node_num) return d.node_num.toString(16).slice(-2).toUpperCase();
|
||||
return (d.serial_number || "??")
|
||||
.replace(/[^a-zA-Z0-9]/g, "")
|
||||
.slice(-2)
|
||||
.toUpperCase();
|
||||
});
|
||||
|
||||
const flashedDrift = computed(
|
||||
() =>
|
||||
props.device.flashed_fw_sha &&
|
||||
fw.ref.sha &&
|
||||
props.device.flashed_fw_sha !== fw.ref.sha,
|
||||
);
|
||||
|
||||
function startEdit() {
|
||||
nameDraft.value = props.device.friendly_name || "";
|
||||
editing.value = true;
|
||||
}
|
||||
async function saveName() {
|
||||
editing.value = false;
|
||||
await devices.setFriendlyName(props.device.serial_number, nameDraft.value);
|
||||
}
|
||||
|
||||
async function onAssign(e: Event) {
|
||||
const val = (e.target as HTMLSelectElement).value;
|
||||
// find camera currently assigned and reassign; here we assign the picked
|
||||
// camera id to this device (or unassign all when "")
|
||||
const id = val ? Number(val) : null;
|
||||
// Unassign whatever is currently on this device first if changing.
|
||||
const current = assignedCamera.value;
|
||||
if (current && current.id !== id) await cameras.assign(current.id, null);
|
||||
if (id != null) await cameras.assign(id, props.device.serial_number);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="card-rail rounded-xl border bg-slate-900/60 p-4 flex flex-col gap-3"
|
||||
:class="
|
||||
device.online
|
||||
? 'border-slate-700/80'
|
||||
: 'border-slate-800 opacity-60 is-offline'
|
||||
"
|
||||
>
|
||||
<!-- header -->
|
||||
<div class="flex items-start gap-2.5">
|
||||
<div
|
||||
class="node-id mt-0.5"
|
||||
:style="{ color: nodeColor }"
|
||||
:title="
|
||||
(device.online ? 'online' : 'offline') + ' · ' + device.serial_number
|
||||
"
|
||||
>
|
||||
<span class="text-slate-100">{{ nodeToken }}</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="editing">
|
||||
<input
|
||||
v-model="nameDraft"
|
||||
@keyup.enter="saveName"
|
||||
@blur="saveName"
|
||||
autofocus
|
||||
class="text-sm bg-slate-800 border border-slate-600 rounded px-1.5 py-0.5 outline-none"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span
|
||||
class="font-semibold text-slate-100 truncate cursor-pointer hover:text-emerald-300"
|
||||
@click="startEdit"
|
||||
:title="'click to rename · ' + device.serial_number"
|
||||
>
|
||||
{{ device.friendly_name || device.serial_number }}
|
||||
</span>
|
||||
</template>
|
||||
<span
|
||||
v-if="device.role"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-slate-800 text-slate-400 uppercase"
|
||||
>{{ device.role }}</span
|
||||
>
|
||||
<span
|
||||
v-if="!device.has_stable_id"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-amber-950/50 text-amber-400"
|
||||
title="no stable USB serial — won't follow across port changes"
|
||||
>no-serial</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-xs text-slate-500 mono truncate">
|
||||
{{ device.current_port || "—" }}
|
||||
<span v-if="device.node_num" class="text-slate-600"
|
||||
>· !{{ device.node_num.toString(16) }}</span
|
||||
>
|
||||
<span v-if="device.stale" class="text-amber-500/70">· stale</span>
|
||||
</div>
|
||||
<!-- auto-sniffed specs: running firmware, hw model, region, exact env -->
|
||||
<div
|
||||
v-if="device.firmware_version || device.hw_model"
|
||||
class="text-[11px] mono truncate mt-0.5 flex flex-wrap items-center gap-x-1.5"
|
||||
>
|
||||
<span v-if="device.firmware_version" class="text-emerald-300/90"
|
||||
>v{{ device.firmware_version }}</span
|
||||
>
|
||||
<span v-if="device.hw_model" class="text-slate-400"
|
||||
>· {{ device.hw_model }}</span
|
||||
>
|
||||
<span
|
||||
v-if="device.region && device.region !== 'UNSET'"
|
||||
class="text-indigo-300/90"
|
||||
>· {{ device.region }}</span
|
||||
>
|
||||
<span
|
||||
v-else-if="device.region === 'UNSET'"
|
||||
class="text-amber-400/90"
|
||||
title="region unset — node will not transmit"
|
||||
>· region unset</span
|
||||
>
|
||||
<span v-if="device.env" class="text-slate-500"
|
||||
>· env <span class="text-slate-300">{{ device.env }}</span></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
@click="showSettings = !showSettings"
|
||||
class="p-1 rounded transition hover:bg-slate-800"
|
||||
:class="
|
||||
showSettings ? 'text-emerald-300' : 'text-slate-500 hover:text-slate-300'
|
||||
"
|
||||
title="device settings (env override, rename, node config)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="devices.refresh(device.serial_number)"
|
||||
class="p-1 rounded text-slate-500 hover:text-slate-300 transition hover:bg-slate-800"
|
||||
title="refresh device info"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path
|
||||
d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- settings panel (cog) -->
|
||||
<DeviceSettings v-if="showSettings" :device="device" />
|
||||
|
||||
<!-- flashed firmware ref -->
|
||||
<div class="text-[11px] flex items-center gap-1.5">
|
||||
<span class="text-slate-500">firmware:</span>
|
||||
<template v-if="device.flashed_fw_sha">
|
||||
<span class="mono text-emerald-300/80">{{
|
||||
device.flashed_fw_sha.slice(0, 7)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="flashedDrift"
|
||||
class="text-amber-400"
|
||||
title="device firmware differs from the current checkout"
|
||||
>⚠ behind current ref</span
|
||||
>
|
||||
</template>
|
||||
<span v-else class="text-slate-600">unknown (not flashed via FleetSuite)</span>
|
||||
</div>
|
||||
|
||||
<!-- camera -->
|
||||
<CameraFeed :camera="assignedCamera" />
|
||||
<select
|
||||
:value="assignedCamera?.id ?? ''"
|
||||
@change="onAssign"
|
||||
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none"
|
||||
>
|
||||
<option value="">— no camera —</option>
|
||||
<option v-for="c in cameras.list" :key="c.id" :value="c.id">
|
||||
{{ c.name }} (idx {{ c.device_index }})
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="flex gap-1 text-xs border-b border-slate-800">
|
||||
<button
|
||||
v-for="t in ['serial', 'packets', 'results']"
|
||||
:key="t"
|
||||
@click="tab = t as any"
|
||||
class="px-2 py-1 capitalize -mb-px border-b-2 transition"
|
||||
:class="
|
||||
tab === t
|
||||
? 'border-emerald-500 text-emerald-300'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-300'
|
||||
"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
<SerialLogPane v-if="tab === 'serial'" :serial="device.serial_number" />
|
||||
<PacketPane v-else-if="tab === 'packets'" :serial="device.serial_number" />
|
||||
<TestResultsPane v-else :serial="device.serial_number" />
|
||||
|
||||
<DeviceControls :device="device" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useBuildsStore } from "../stores/builds";
|
||||
import { useDevicesStore } from "../stores/devices";
|
||||
import { useFirmwareStore } from "../stores/firmware";
|
||||
import { useTestsStore } from "../stores/tests";
|
||||
import type { Device } from "../types";
|
||||
|
||||
const props = defineProps<{ device: Device }>();
|
||||
const tests = useTestsStore();
|
||||
const builds = useBuildsStore();
|
||||
const devices = useDevicesStore();
|
||||
const fw = useFirmwareStore();
|
||||
|
||||
const nodedbSize = ref(500);
|
||||
const flashStats = ref<any>(null);
|
||||
|
||||
const isNative = computed(() => props.device.role === "native");
|
||||
const nativeName = computed(() => props.device.serial_number.split(":")[1] ?? "");
|
||||
|
||||
async function loadFlashStats() {
|
||||
if (isNative.value) return; // native nodes aren't flashed
|
||||
try {
|
||||
flashStats.value = await api.get(
|
||||
`/api/devices/${props.device.serial_number}/flash-stats`,
|
||||
);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
onMounted(loadFlashStats);
|
||||
|
||||
// role → default pio env (mirrors the backend identity map).
|
||||
const ROLE_ENV: Record<string, string> = {
|
||||
nrf52: "rak4631",
|
||||
esp32s3: "heltec-v3",
|
||||
};
|
||||
|
||||
// Is there a prebuilt artifact for this device's target at the current ref?
|
||||
// Prefer the env resolved from hw_model; fall back to the coarse role default.
|
||||
const flashReady = computed(() => {
|
||||
const env =
|
||||
props.device.env ||
|
||||
(props.device.role ? ROLE_ENV[props.device.role] : undefined);
|
||||
if (!env) return false;
|
||||
const b = builds.statusFor(env, fw.ref.sha);
|
||||
return b?.status === "success" || b?.status === "cached";
|
||||
});
|
||||
|
||||
const busy = ref(false);
|
||||
const msg = ref<string | null>(null);
|
||||
const ok = ref(true);
|
||||
const sendText = ref("");
|
||||
|
||||
async function run(label: string, fn: () => Promise<any>) {
|
||||
busy.value = true;
|
||||
msg.value = `${label}…`;
|
||||
ok.value = true;
|
||||
try {
|
||||
await fn();
|
||||
msg.value = `${label} ✓`;
|
||||
ok.value = true;
|
||||
} catch (e: any) {
|
||||
msg.value = `${label}: ${e.message}`;
|
||||
ok.value = false;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const base = () => `/api/devices/${props.device.serial_number}`;
|
||||
const flash = () =>
|
||||
run("flash", () => api.post(`${base()}/flash`, {})).then(loadFlashStats);
|
||||
const injectNodeDb = () =>
|
||||
run(`inject ${nodedbSize.value}-node db`, () =>
|
||||
api.post(`${base()}/inject-nodedb`, { size: nodedbSize.value }),
|
||||
);
|
||||
const reboot = () => run("reboot", () => api.post(`${base()}/reboot`, {}));
|
||||
const factory = () => {
|
||||
if (confirm(`Factory-reset ${props.device.friendly_name || props.device.serial_number}?`))
|
||||
run("factory-reset", () => api.post(`${base()}/factory-reset`, {}));
|
||||
};
|
||||
const getConfig = () =>
|
||||
run("get-config", async () => {
|
||||
const c = await api.get(`${base()}/config?section=lora`);
|
||||
msg.value = JSON.stringify(c).slice(0, 120);
|
||||
});
|
||||
|
||||
// Native (Docker meshtasticd) container lifecycle.
|
||||
const nativeBase = () => `/api/native/${nativeName.value}`;
|
||||
const startNode = () => run("start", () => api.post(`${nativeBase()}/start`));
|
||||
const stopNode = () => run("stop", () => api.post(`${nativeBase()}/stop`));
|
||||
const restartNode = () => run("restart", () => api.post(`${nativeBase()}/restart`));
|
||||
|
||||
const actions = computed(() =>
|
||||
isNative.value
|
||||
? [
|
||||
{ label: "Start", fn: startNode },
|
||||
{ label: "Stop", fn: stopNode },
|
||||
{ label: "Restart", fn: restartNode },
|
||||
{ label: "Config", fn: getConfig },
|
||||
]
|
||||
: [
|
||||
{ label: "Flash", fn: flash },
|
||||
{ label: "Reboot", fn: reboot },
|
||||
{ label: "Config", fn: getConfig },
|
||||
{ label: "Factory Reset", fn: factory, danger: true },
|
||||
],
|
||||
);
|
||||
|
||||
const doSend = () => {
|
||||
if (!sendText.value.trim()) return;
|
||||
const text = sendText.value;
|
||||
run("send-text", () => api.post(`${base()}/send-text`, { text })).then(
|
||||
() => (sendText.value = ""),
|
||||
);
|
||||
};
|
||||
|
||||
// USB power control (uhubctl). The node's hub port is tracked on the device;
|
||||
// if it's unmapped the backend auto-binds a unique VID match, or returns 409
|
||||
// with candidates to pick in device settings.
|
||||
const serial = computed(() => props.device.serial_number);
|
||||
const hubLabel = computed(() =>
|
||||
props.device.hub_location != null
|
||||
? `hub ${props.device.hub_location}:${props.device.hub_port}`
|
||||
: "port unmapped — auto/assign in ⚙",
|
||||
);
|
||||
const powerCycle = () =>
|
||||
run("power cycle", () => devices.power(serial.value, "cycle"));
|
||||
const powerOff = () => {
|
||||
if (confirm(`Cut USB power to ${props.device.friendly_name || serial.value}?`))
|
||||
run("power off", () => devices.power(serial.value, "off"));
|
||||
};
|
||||
const powerOn = () => run("power on", () => devices.power(serial.value, "on"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-if="tests.running"
|
||||
class="text-[11px] text-amber-400/80 bg-amber-950/30 rounded px-2 py-1"
|
||||
>
|
||||
device control disabled — test run in progress
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<button
|
||||
v-for="b in actions"
|
||||
:key="b.label"
|
||||
:disabled="busy || tests.running"
|
||||
@click="b.fn"
|
||||
class="text-xs px-2 py-1 rounded border transition disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
:title="
|
||||
b.label === 'Flash' && flashReady
|
||||
? 'prebuilt artifact ready for ' + (fw.ref.short_sha || 'current ref')
|
||||
: ''
|
||||
"
|
||||
:class="[
|
||||
b.danger
|
||||
? 'border-rose-800 text-rose-300 hover:bg-rose-950/40'
|
||||
: 'border-slate-700 text-slate-300 hover:bg-slate-800',
|
||||
b.label === 'Flash' && flashReady ? 'border-emerald-700 text-emerald-300' : '',
|
||||
]"
|
||||
>
|
||||
{{ b.label === "Flash" && flashReady ? "Flash ✓" : b.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex gap-1.5">
|
||||
<input
|
||||
v-model="sendText"
|
||||
@keyup.enter="doSend"
|
||||
:disabled="tests.running"
|
||||
placeholder="send text…"
|
||||
class="flex-1 text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700 disabled:opacity-40"
|
||||
/>
|
||||
<button
|
||||
@click="doSend"
|
||||
:disabled="busy || tests.running"
|
||||
class="text-xs px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
>
|
||||
send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- inject fake NodeDB -->
|
||||
<div class="flex gap-1.5 items-center">
|
||||
<span class="text-[11px] text-slate-500">inject NodeDB</span>
|
||||
<select
|
||||
v-model.number="nodedbSize"
|
||||
:disabled="tests.running"
|
||||
class="text-xs bg-slate-900 border border-slate-700 rounded px-1.5 py-1 outline-none disabled:opacity-40"
|
||||
>
|
||||
<option :value="250">250</option>
|
||||
<option :value="500">500</option>
|
||||
<option :value="1000">1000</option>
|
||||
<option :value="2000">2000</option>
|
||||
</select>
|
||||
<span class="text-[11px] text-slate-600">nodes</span>
|
||||
<button
|
||||
@click="injectNodeDb"
|
||||
:disabled="busy || tests.running"
|
||||
class="text-xs px-2 py-1 rounded border border-sky-800 text-sky-300 hover:bg-sky-950/40 disabled:opacity-40"
|
||||
title="XModem-push a fresh fake NodeDB fixture, then reboot"
|
||||
>
|
||||
inject + reboot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- USB power (uhubctl) -->
|
||||
<div v-if="!isNative" class="flex gap-1.5 items-center flex-wrap">
|
||||
<span class="text-[11px] text-slate-500">power</span>
|
||||
<button
|
||||
@click="powerCycle"
|
||||
:disabled="busy || tests.running"
|
||||
class="text-xs px-2 py-1 rounded border border-amber-800 text-amber-300 hover:bg-amber-950/40 disabled:opacity-40"
|
||||
title="USB power-cycle this port (uhubctl off → on)"
|
||||
>
|
||||
⟳ cycle
|
||||
</button>
|
||||
<button
|
||||
@click="powerOff"
|
||||
:disabled="busy || tests.running"
|
||||
class="text-xs px-2 py-1 rounded border border-rose-800 text-rose-300 hover:bg-rose-950/40 disabled:opacity-40"
|
||||
title="Cut USB power to this port"
|
||||
>
|
||||
⏻ off
|
||||
</button>
|
||||
<button
|
||||
@click="powerOn"
|
||||
:disabled="busy || tests.running"
|
||||
class="text-xs px-2 py-1 rounded border border-emerald-800 text-emerald-300 hover:bg-emerald-950/40 disabled:opacity-40"
|
||||
title="Restore USB power to this port"
|
||||
>
|
||||
⏼ on
|
||||
</button>
|
||||
<span class="text-[11px] text-slate-600 mono">{{ hubLabel }}</span>
|
||||
</div>
|
||||
|
||||
<!-- flash timing: direct artifact vs host rebuild -->
|
||||
<div
|
||||
v-if="flashStats && (flashStats.artifact || flashStats.rebuild)"
|
||||
class="text-[11px] text-slate-500"
|
||||
>
|
||||
flash:
|
||||
<span v-if="flashStats.artifact" class="text-emerald-400"
|
||||
>{{ flashStats.artifact.duration_s }}s artifact</span
|
||||
>
|
||||
<span v-if="flashStats.artifact && flashStats.rebuild"> vs </span>
|
||||
<span v-if="flashStats.rebuild" class="text-slate-400"
|
||||
>{{ flashStats.rebuild.duration_s }}s rebuild</span
|
||||
>
|
||||
<span v-if="flashStats.speedup" class="text-emerald-300">
|
||||
— {{ flashStats.speedup }}× faster</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="msg"
|
||||
class="text-[11px] mono truncate"
|
||||
:class="ok ? 'text-slate-500' : 'text-rose-400'"
|
||||
:title="msg"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useDevicesStore } from "../stores/devices";
|
||||
import CameraManager from "./CameraManager.vue";
|
||||
import DeviceCard from "./DeviceCard.vue";
|
||||
import NativeManager from "./NativeManager.vue";
|
||||
|
||||
const devices = useDevicesStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5 space-y-5">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<CameraManager />
|
||||
<NativeManager />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="devices.list.length === 0"
|
||||
class="rounded-xl border border-dashed border-slate-700 p-10 text-center text-slate-500"
|
||||
>
|
||||
No devices detected. Plug in a Meshtastic board over USB — the card will
|
||||
appear automatically and follow it across ports.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-4"
|
||||
style="grid-template-columns: repeat(auto-fill, minmax(360px, 1fr))"
|
||||
>
|
||||
<DeviceCard
|
||||
v-for="d in devices.list"
|
||||
:key="d.serial_number"
|
||||
:device="d"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,306 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useDevicesStore } from "../stores/devices";
|
||||
import type { Device } from "../types";
|
||||
|
||||
const props = defineProps<{ device: Device }>();
|
||||
const devices = useDevicesStore();
|
||||
|
||||
const name = ref(props.device.friendly_name || "");
|
||||
const envInput = ref(props.device.env || "");
|
||||
const envList = ref<string[]>([]);
|
||||
const cfgPath = ref("");
|
||||
const cfgValue = ref("");
|
||||
const busy = ref(false);
|
||||
const msg = ref<string | null>(null);
|
||||
const ok = ref(true);
|
||||
|
||||
const isNative = computed(() => props.device.role === "native");
|
||||
const serial = computed(() => props.device.serial_number);
|
||||
|
||||
// --- uhubctl hub-port assignment ---
|
||||
type Slot = { location: string; port: number; label: string; value: string };
|
||||
const hubAvailable = ref(true);
|
||||
const slots = ref<Slot[]>([]);
|
||||
const selectedSlot = ref("");
|
||||
const currentSlot = computed(() =>
|
||||
props.device.hub_location != null
|
||||
? `${props.device.hub_location}:${props.device.hub_port}`
|
||||
: "",
|
||||
);
|
||||
|
||||
async function loadHubs() {
|
||||
if (isNative.value) return;
|
||||
try {
|
||||
const res = await api.get<{ available: boolean; hubs: any[] }>("/api/hubs");
|
||||
hubAvailable.value = res.available;
|
||||
slots.value = (res.hubs || [])
|
||||
.filter((h) => h.ppps)
|
||||
.flatMap((h) =>
|
||||
h.ports.map((p: any) => {
|
||||
const tail = p.device_desc
|
||||
? ` — ${p.device_desc}`
|
||||
: p.device_vid
|
||||
? ` — ${p.device_vid.toString(16)}:${(p.device_pid ?? 0).toString(16)}`
|
||||
: " (empty)";
|
||||
return {
|
||||
location: h.location,
|
||||
port: p.port,
|
||||
value: `${h.location}:${p.port}`,
|
||||
label: `${h.location}:${p.port}${tail}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
selectedSlot.value = currentSlot.value;
|
||||
} catch {
|
||||
/* hubs unavailable — section degrades to read-only */
|
||||
}
|
||||
}
|
||||
|
||||
const assignSlot = () => {
|
||||
if (!selectedSlot.value) return;
|
||||
const slot = slots.value.find((s) => s.value === selectedSlot.value);
|
||||
if (!slot) return;
|
||||
act("assign hub port", () =>
|
||||
devices.setHubPort(serial.value, slot.location, slot.port),
|
||||
);
|
||||
};
|
||||
|
||||
const clearSlot = () =>
|
||||
act("clear hub port", () => devices.setHubPort(serial.value, null, null));
|
||||
|
||||
const autoLocate = () =>
|
||||
act("auto-locate", async () => {
|
||||
const res = await devices.locate(serial.value);
|
||||
if (!res.located) {
|
||||
const c = res.candidates || [];
|
||||
throw new Error(
|
||||
c.length
|
||||
? `ambiguous — pick a port: ${c.map((x) => `${x.location}:${x.port}`).join(", ")}`
|
||||
: "no PPPS hub port matched this device's VID",
|
||||
);
|
||||
}
|
||||
selectedSlot.value = currentSlot.value;
|
||||
});
|
||||
|
||||
async function loadEnvs() {
|
||||
try {
|
||||
// Suggest envs, narrowed to this board's architecture when we can infer it.
|
||||
const arch =
|
||||
props.device.role === "nrf52"
|
||||
? "nrf52840"
|
||||
: props.device.role === "esp32s3"
|
||||
? "esp32-s3"
|
||||
: undefined;
|
||||
const q = arch ? `?architecture=${arch}` : "";
|
||||
const boards = await api.get<any[]>(`/api/boards${q}`);
|
||||
envList.value = boards.map((b) => b.env).filter(Boolean).sort();
|
||||
} catch {
|
||||
/* leave datalist empty — free-form input still works */
|
||||
}
|
||||
}
|
||||
onMounted(() => {
|
||||
loadEnvs();
|
||||
loadHubs();
|
||||
});
|
||||
|
||||
async function act(label: string, fn: () => Promise<any>) {
|
||||
busy.value = true;
|
||||
msg.value = `${label}…`;
|
||||
ok.value = true;
|
||||
try {
|
||||
await fn();
|
||||
msg.value = `${label} ✓`;
|
||||
} catch (e: any) {
|
||||
msg.value = `${label}: ${e.message}`;
|
||||
ok.value = false;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const saveName = () =>
|
||||
act("rename", () => devices.setFriendlyName(props.device.serial_number, name.value));
|
||||
|
||||
const pinEnv = () =>
|
||||
envInput.value.trim() &&
|
||||
act("pin env", () => devices.setEnv(props.device.serial_number, envInput.value.trim()));
|
||||
|
||||
const autoEnv = () =>
|
||||
act("auto-detect env", async () => {
|
||||
await devices.setEnv(props.device.serial_number, null);
|
||||
envInput.value = props.device.env || "";
|
||||
});
|
||||
|
||||
const setConfig = () => {
|
||||
if (!cfgPath.value.trim()) return;
|
||||
// Coerce "30"→30, "true"→true; leave bare strings (e.g. "US") as-is.
|
||||
let value: any = cfgValue.value;
|
||||
try {
|
||||
value = JSON.parse(cfgValue.value);
|
||||
} catch {
|
||||
/* keep string */
|
||||
}
|
||||
act("set config", () =>
|
||||
api.put(`/api/devices/${props.device.serial_number}/config`, {
|
||||
path: cfgPath.value.trim(),
|
||||
value,
|
||||
}),
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-slate-700 bg-slate-950/50 p-3 space-y-3 text-xs">
|
||||
<!-- friendly name -->
|
||||
<div>
|
||||
<label class="text-slate-500">Friendly name</label>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<input
|
||||
v-model="name"
|
||||
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<button
|
||||
@click="saveName"
|
||||
:disabled="busy"
|
||||
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
>
|
||||
save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- pio env override -->
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-slate-500">PlatformIO env (flash/build target)</label>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="
|
||||
device.env_locked
|
||||
? 'bg-amber-950/50 text-amber-400'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
"
|
||||
>{{ device.env_locked ? "manual" : "auto" }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<input
|
||||
v-model="envInput"
|
||||
list="envlist"
|
||||
placeholder="e.g. heltec-v4"
|
||||
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<datalist id="envlist">
|
||||
<option v-for="e in envList" :key="e" :value="e" />
|
||||
</datalist>
|
||||
<button
|
||||
@click="pinEnv"
|
||||
:disabled="busy"
|
||||
class="px-2 py-1 rounded border border-emerald-700 text-emerald-300 hover:bg-emerald-700/40 disabled:opacity-40"
|
||||
>
|
||||
pin
|
||||
</button>
|
||||
<button
|
||||
@click="autoEnv"
|
||||
:disabled="busy"
|
||||
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
title="release the override and re-resolve env from hw_model"
|
||||
>
|
||||
auto
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-600 mt-1">
|
||||
current: <span class="mono text-emerald-300/70">{{ device.env || "—" }}</span>
|
||||
<span v-if="device.hw_model"> · hw {{ device.hw_model }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- generic node config set -->
|
||||
<div>
|
||||
<label class="text-slate-500">Set node config (advanced)</label>
|
||||
<div class="flex gap-1.5 mt-1">
|
||||
<input
|
||||
v-model="cfgPath"
|
||||
placeholder="path e.g. lora.region"
|
||||
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<input
|
||||
v-model="cfgValue"
|
||||
placeholder="value e.g. US"
|
||||
class="w-28 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<button
|
||||
@click="setConfig"
|
||||
:disabled="busy"
|
||||
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
>
|
||||
set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- uhubctl power port -->
|
||||
<div v-if="!isNative">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-slate-500">USB power port (uhubctl)</label>
|
||||
<span
|
||||
class="text-[10px] px-1.5 py-0.5 rounded"
|
||||
:class="
|
||||
device.hub_location != null
|
||||
? 'bg-emerald-950/50 text-emerald-400'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
"
|
||||
>{{ device.hub_location != null ? currentSlot : "unmapped" }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="!hubAvailable" class="text-[10px] text-amber-400/80 mt-1">
|
||||
uhubctl not available on this host
|
||||
</div>
|
||||
<div v-else class="flex gap-1.5 mt-1">
|
||||
<select
|
||||
v-model="selectedSlot"
|
||||
class="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
>
|
||||
<option value="">— select hub:port —</option>
|
||||
<option v-for="s in slots" :key="s.value" :value="s.value">
|
||||
{{ s.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
@click="assignSlot"
|
||||
:disabled="busy || !selectedSlot"
|
||||
class="px-2 py-1 rounded border border-emerald-700 text-emerald-300 hover:bg-emerald-700/40 disabled:opacity-40"
|
||||
>
|
||||
assign
|
||||
</button>
|
||||
<button
|
||||
@click="autoLocate"
|
||||
:disabled="busy"
|
||||
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
title="auto-bind if exactly one PPPS port matches this device's VID"
|
||||
>
|
||||
auto
|
||||
</button>
|
||||
<button
|
||||
v-if="device.hub_location != null"
|
||||
@click="clearSlot"
|
||||
:disabled="busy"
|
||||
class="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-40"
|
||||
>
|
||||
clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="msg"
|
||||
class="mono truncate"
|
||||
:class="ok ? 'text-slate-500' : 'text-rose-400'"
|
||||
:title="msg"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { useFirmwareStore } from "../stores/firmware";
|
||||
const fw = useFirmwareStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-2 px-3 py-1.5 rounded-md bg-slate-800/70 border border-slate-700 text-sm"
|
||||
:title="fw.ref.subject || ''"
|
||||
>
|
||||
<svg
|
||||
class="w-4 h-4 text-emerald-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle cx="6" cy="6" r="2.5" />
|
||||
<circle cx="6" cy="18" r="2.5" />
|
||||
<circle cx="18" cy="9" r="2.5" />
|
||||
<path d="M6 8.5v7M8.4 7.2A6 6 0 0 1 15.5 9" />
|
||||
</svg>
|
||||
<template v-if="fw.ref.available">
|
||||
<span class="font-semibold text-slate-100">{{
|
||||
fw.ref.branch || "(detached)"
|
||||
}}</span>
|
||||
<span class="mono text-emerald-300">{{ fw.ref.short_sha }}</span>
|
||||
<span
|
||||
v-if="fw.ref.dirty"
|
||||
class="text-amber-400 text-xs"
|
||||
title="working tree has uncommitted changes"
|
||||
>● dirty</span
|
||||
>
|
||||
</template>
|
||||
<span v-else class="text-slate-500">no git ref</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { ansiToHtml } from "../ansi";
|
||||
|
||||
const props = defineProps<{ lines: string[]; placeholder?: string }>();
|
||||
|
||||
const box = ref<HTMLElement | null>(null);
|
||||
const pinned = ref(true);
|
||||
|
||||
function onScroll() {
|
||||
const el = box.value;
|
||||
if (!el) return;
|
||||
pinned.value = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.lines.length,
|
||||
async () => {
|
||||
if (!pinned.value) return;
|
||||
await nextTick();
|
||||
const el = box.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="box"
|
||||
@scroll="onScroll"
|
||||
class="mono text-xs leading-relaxed overflow-auto h-full bg-black/40 rounded-md p-2 whitespace-pre-wrap break-all"
|
||||
>
|
||||
<span v-if="lines.length === 0" class="text-slate-600">{{
|
||||
placeholder || "(no output yet)"
|
||||
}}</span>
|
||||
<div v-for="(l, i) in lines" :key="i" v-html="ansiToHtml(l)" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
|
||||
interface NativeInfo {
|
||||
docker: boolean;
|
||||
image: string;
|
||||
nodes: any[];
|
||||
}
|
||||
|
||||
const info = ref<NativeInfo>({ docker: true, image: "", nodes: [] });
|
||||
const name = ref("");
|
||||
const port = ref(4403);
|
||||
const busy = ref(false);
|
||||
const err = ref<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
info.value = await api.get<NativeInfo>("/api/native");
|
||||
}
|
||||
onMounted(load);
|
||||
|
||||
async function act(fn: () => Promise<any>) {
|
||||
busy.value = true;
|
||||
err.value = null;
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
err.value = e.message;
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const add = () =>
|
||||
name.value.trim() &&
|
||||
act(async () => {
|
||||
await api.post("/api/native", { name: name.value.trim(), tcp_port: port.value });
|
||||
name.value = "";
|
||||
port.value = port.value + 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card-rail rounded-xl border border-slate-700/80 bg-slate-900/60 p-4">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<span class="w-1 h-3.5 rounded-full bg-indigo-500/80" />
|
||||
<h3 class="section-label">Native Nodes (Docker)</h3>
|
||||
<span
|
||||
v-if="!info.docker"
|
||||
class="text-[11px] px-2 py-0.5 rounded bg-amber-950/40 text-amber-400"
|
||||
>Docker unavailable</span
|
||||
>
|
||||
<span class="text-[11px] text-slate-600 mono">{{ info.image }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
<input
|
||||
v-model="name"
|
||||
placeholder="node name"
|
||||
class="text-xs bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
/>
|
||||
<input
|
||||
v-model.number="port"
|
||||
type="number"
|
||||
class="text-xs w-24 bg-slate-900 border border-slate-700 rounded px-2 py-1 outline-none focus:border-emerald-700"
|
||||
title="host TCP port → container 4403"
|
||||
/>
|
||||
<button
|
||||
@click="add"
|
||||
:disabled="busy || !info.docker"
|
||||
class="text-xs px-3 py-1 rounded bg-emerald-700/30 border border-emerald-700 text-emerald-300 hover:bg-emerald-700/50 disabled:opacity-40"
|
||||
>
|
||||
run native node
|
||||
</button>
|
||||
<span v-if="err" class="text-xs text-rose-400 self-center">{{ err }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-1">
|
||||
<li
|
||||
v-for="n in info.nodes"
|
||||
:key="n.serial_number"
|
||||
class="flex items-center gap-2 text-xs text-slate-400"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="n.online ? 'bg-emerald-400' : 'bg-slate-600'"
|
||||
/>
|
||||
<span class="text-slate-200">{{ n.friendly_name }}</span>
|
||||
<span class="mono">{{ n.current_port }}</span>
|
||||
<button
|
||||
v-if="!n.online"
|
||||
@click="act(() => api.post(`/api/native/${n.friendly_name}/start`))"
|
||||
class="ml-auto text-emerald-400/80 hover:text-emerald-300"
|
||||
>
|
||||
start
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="act(() => api.post(`/api/native/${n.friendly_name}/stop`))"
|
||||
class="ml-auto text-amber-400/80 hover:text-amber-300"
|
||||
>
|
||||
stop
|
||||
</button>
|
||||
<button
|
||||
@click="act(() => api.post(`/api/native/${n.friendly_name}/restart`))"
|
||||
class="text-sky-400/80 hover:text-sky-300"
|
||||
>
|
||||
restart
|
||||
</button>
|
||||
<button
|
||||
@click="act(() => api.del(`/api/native/${n.friendly_name}`))"
|
||||
class="text-rose-400/70 hover:text-rose-300"
|
||||
>
|
||||
remove
|
||||
</button>
|
||||
</li>
|
||||
<li v-if="info.nodes.length === 0" class="text-xs text-slate-600">
|
||||
no native nodes — run meshtasticd in Docker and manage it as a TCP device
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const props = defineProps<{ serial: string }>();
|
||||
const packets = ref<any[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await api.get<{ packets: any[] }>(
|
||||
`/api/devices/${props.serial}/packets?start=-30m&max=100`,
|
||||
);
|
||||
packets.value = res.packets.slice().reverse();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
|
||||
function ts(t: number) {
|
||||
return new Date(t * 1000).toLocaleTimeString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-48 overflow-auto bg-black/40 rounded-md p-2">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-xs text-slate-500">last 30 min · packet API</span>
|
||||
<button
|
||||
@click="load"
|
||||
class="text-xs px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
|
||||
>
|
||||
{{ loading ? "…" : "refresh" }}
|
||||
</button>
|
||||
</div>
|
||||
<table class="w-full text-xs mono">
|
||||
<thead class="text-slate-500">
|
||||
<tr class="text-left">
|
||||
<th class="font-normal">time</th>
|
||||
<th class="font-normal">portnum</th>
|
||||
<th class="font-normal">from→to</th>
|
||||
<th class="font-normal">snr</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(p, i) in packets" :key="i" class="border-t border-slate-800/60">
|
||||
<td class="text-slate-400">{{ ts(p.ts) }}</td>
|
||||
<td class="text-emerald-300">{{ p.portnum }}</td>
|
||||
<td class="text-slate-300">{{ p.from_node }}→{{ p.to_node }}</td>
|
||||
<td class="text-slate-400">{{ p.rx_snr ?? "" }}</td>
|
||||
</tr>
|
||||
<tr v-if="packets.length === 0">
|
||||
<td colspan="4" class="text-slate-600 py-2">no packets recorded</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { useTestsStore } from "../stores/tests";
|
||||
|
||||
const tests = useTestsStore();
|
||||
const args = ref("");
|
||||
const busy = ref(false);
|
||||
|
||||
async function start() {
|
||||
busy.value = true;
|
||||
try {
|
||||
const parsed = args.value.trim() ? args.value.trim().split(/\s+/) : [];
|
||||
await tests.start(parsed);
|
||||
} catch (e: any) {
|
||||
alert(e.message);
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
busy.value = true;
|
||||
try {
|
||||
await tests.stop();
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="!tests.running"
|
||||
@click="start"
|
||||
:disabled="busy"
|
||||
class="px-4 py-1.5 rounded-md bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
▶ Run suite
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="stop"
|
||||
:disabled="busy"
|
||||
class="px-4 py-1.5 rounded-md bg-rose-600 hover:bg-rose-500 text-white text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
■ Stop
|
||||
</button>
|
||||
|
||||
<input
|
||||
v-model="args"
|
||||
:disabled="tests.running"
|
||||
placeholder="pytest args (optional, e.g. tests/mesh)"
|
||||
class="flex-1 text-sm bg-slate-900 border border-slate-700 rounded px-3 py-1.5 outline-none focus:border-emerald-700 disabled:opacity-40"
|
||||
/>
|
||||
|
||||
<div class="text-sm flex items-center gap-3">
|
||||
<span v-if="tests.running" class="text-amber-400 flex items-center gap-1">
|
||||
<span class="animate-spin">⟳</span> running
|
||||
</span>
|
||||
<span
|
||||
v-else-if="tests.exitCode !== null"
|
||||
:class="tests.exitCode === 0 ? 'text-emerald-400' : 'text-rose-400'"
|
||||
>
|
||||
exit {{ tests.exitCode }}
|
||||
</span>
|
||||
<span class="tabular-nums text-xs text-slate-400">
|
||||
<span class="text-emerald-400">{{ tests.totals.passed }}</span> ·
|
||||
<span class="text-rose-400">{{ tests.totals.failed }}</span> ·
|
||||
<span class="text-slate-500">{{ tests.totals.skipped }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, reactive } from "vue";
|
||||
import { useWsStore } from "../stores/ws";
|
||||
import LogPane from "./LogPane.vue";
|
||||
|
||||
const props = defineProps<{ serial: string }>();
|
||||
const ws = useWsStore();
|
||||
const lines = reactive<string[]>([]);
|
||||
const topic = `serial.${props.serial}`;
|
||||
|
||||
function onLine(d: any) {
|
||||
lines.push(d.line);
|
||||
if (lines.length > 2000) lines.splice(0, lines.length - 2000);
|
||||
}
|
||||
|
||||
onMounted(() => ws.subscribe(topic, onLine));
|
||||
onUnmounted(() => ws.unsubscribe(topic, onLine));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-48">
|
||||
<LogPane :lines="lines" placeholder="opening serial monitor…" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useTestsStore } from "../stores/tests";
|
||||
import BuildQueue from "./BuildQueue.vue";
|
||||
import DatadogPanel from "./DatadogPanel.vue";
|
||||
import LogPane from "./LogPane.vue";
|
||||
import RunControls from "./RunControls.vue";
|
||||
import TestTree from "./TestTree.vue";
|
||||
import TierCounters from "./TierCounters.vue";
|
||||
|
||||
const tests = useTestsStore();
|
||||
const logTab = ref<"pytest" | "flash" | "firmware">("pytest");
|
||||
|
||||
const activeLines = computed(() => {
|
||||
if (logTab.value === "flash") return tests.flash;
|
||||
if (logTab.value === "firmware") return tests.fwlog;
|
||||
return tests.stdout;
|
||||
});
|
||||
|
||||
function fmtTime(t: number) {
|
||||
return new Date(t * 1000).toLocaleString();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5 flex flex-col gap-4 h-[calc(100vh-57px)]">
|
||||
<RunControls />
|
||||
<BuildQueue />
|
||||
<DatadogPanel />
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 flex-1 min-h-0">
|
||||
<!-- left: counters + tree -->
|
||||
<div class="flex flex-col gap-3 min-h-0">
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-4">
|
||||
<TierCounters />
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-slate-700 bg-slate-900/60 p-3 flex-1 min-h-0"
|
||||
>
|
||||
<TestTree />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- right: log panes -->
|
||||
<div class="flex flex-col min-h-0">
|
||||
<div class="flex gap-1 text-xs mb-2">
|
||||
<button
|
||||
v-for="t in ['pytest', 'flash', 'firmware']"
|
||||
:key="t"
|
||||
@click="logTab = t as any"
|
||||
class="px-3 py-1 rounded-md capitalize transition"
|
||||
:class="
|
||||
logTab === t
|
||||
? 'bg-slate-700 text-slate-100'
|
||||
: 'bg-slate-900 text-slate-500 hover:text-slate-300'
|
||||
"
|
||||
>
|
||||
{{ t }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 min-h-0">
|
||||
<LogPane :lines="activeLines" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- run history -->
|
||||
<div class="rounded-xl border border-slate-700 bg-slate-900/60 p-3">
|
||||
<div class="text-xs text-slate-500 mb-2">recent runs</div>
|
||||
<div class="flex gap-3 overflow-x-auto text-xs">
|
||||
<div
|
||||
v-for="r in tests.runs"
|
||||
:key="r.id"
|
||||
class="shrink-0 rounded-lg border border-slate-800 px-3 py-1.5"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-emerald-400">{{ r.passed }}</span>
|
||||
<span class="text-rose-400">{{ r.failed }}</span>
|
||||
<span class="text-slate-500">{{ r.skipped }}</span>
|
||||
<span class="mono text-emerald-300/60">{{
|
||||
r.fw_sha?.slice(0, 7)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-slate-600">{{ fmtTime(r.started_at) }}</div>
|
||||
</div>
|
||||
<div v-if="tests.runs.length === 0" class="text-slate-600">
|
||||
no runs recorded yet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
|
||||
const props = defineProps<{ serial: string }>();
|
||||
const results = ref<any[]>([]);
|
||||
|
||||
const OUTCOME_CLASS: Record<string, string> = {
|
||||
passed: "text-emerald-400",
|
||||
failed: "text-rose-400",
|
||||
skipped: "text-slate-500",
|
||||
};
|
||||
|
||||
async function load() {
|
||||
results.value = await api.get<any[]>(
|
||||
`/api/devices/${props.serial}/test-results?limit=100`,
|
||||
);
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-48 overflow-auto bg-black/40 rounded-md p-2">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="text-xs text-slate-500">test history</span>
|
||||
<button
|
||||
@click="load"
|
||||
class="text-xs px-2 py-0.5 rounded bg-slate-800 hover:bg-slate-700 text-slate-300"
|
||||
>
|
||||
refresh
|
||||
</button>
|
||||
</div>
|
||||
<table class="w-full text-xs">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(r, i) in results"
|
||||
:key="i"
|
||||
class="border-t border-slate-800/60"
|
||||
>
|
||||
<td :class="OUTCOME_CLASS[r.outcome] || 'text-slate-400'" class="w-4">
|
||||
{{
|
||||
r.outcome === "passed" ? "✓" : r.outcome === "failed" ? "✗" : "⊘"
|
||||
}}
|
||||
</td>
|
||||
<td class="mono text-slate-300 truncate max-w-[14rem]" :title="r.nodeid">
|
||||
{{ r.nodeid.split("::").pop() }}
|
||||
</td>
|
||||
<td class="mono text-emerald-300/70 text-right">{{ r.fw_sha?.slice(0, 7) }}</td>
|
||||
</tr>
|
||||
<tr v-if="results.length === 0">
|
||||
<td colspan="3" class="text-slate-600 py-2">no runs recorded yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,65 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useTestsStore } from "../stores/tests";
|
||||
import type { TestLeaf } from "../types";
|
||||
|
||||
const tests = useTestsStore();
|
||||
|
||||
const GLYPH: Record<string, string> = {
|
||||
passed: "✓",
|
||||
failed: "✗",
|
||||
skipped: "⊘",
|
||||
running: "⟳",
|
||||
pending: "·",
|
||||
};
|
||||
const CLS: Record<string, string> = {
|
||||
passed: "text-emerald-400",
|
||||
failed: "text-rose-400",
|
||||
skipped: "text-slate-500",
|
||||
running: "text-amber-400 animate-pulse",
|
||||
pending: "text-slate-600",
|
||||
};
|
||||
|
||||
// Group leaves: tier → file → [leaves]
|
||||
const grouped = computed(() => {
|
||||
const out: Record<string, Record<string, TestLeaf[]>> = {};
|
||||
for (const leaf of Object.values(tests.leaves)) {
|
||||
(out[leaf.tier] ??= {});
|
||||
(out[leaf.tier][leaf.file] ??= []).push(leaf);
|
||||
}
|
||||
return out;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-auto text-xs mono h-full">
|
||||
<template v-for="tier in tests.tierOrder" :key="tier">
|
||||
<div v-if="grouped[tier]" class="mb-2">
|
||||
<div class="text-slate-300 capitalize font-semibold">{{ tier }}</div>
|
||||
<div v-for="(leaves, file) in grouped[tier]" :key="file" class="ml-2">
|
||||
<div class="text-slate-500">{{ file }}</div>
|
||||
<div
|
||||
v-for="leaf in leaves"
|
||||
:key="leaf.nodeid"
|
||||
class="ml-3 flex items-center gap-1.5"
|
||||
:class="leaf.nodeid === tests.runningNodeId ? 'bg-amber-950/30 rounded' : ''"
|
||||
>
|
||||
<span :class="CLS[leaf.outcome]">{{ GLYPH[leaf.outcome] }}</span>
|
||||
<span class="text-slate-400 truncate">{{ leaf.testname }}</span>
|
||||
<span
|
||||
v-if="leaf.duration"
|
||||
class="text-slate-600 ml-auto pl-2"
|
||||
>{{ leaf.duration.toFixed(1) }}s</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-if="Object.keys(tests.leaves).length === 0"
|
||||
class="text-slate-600 p-2"
|
||||
>
|
||||
no tests collected yet — start a run
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,70 @@
|
||||
<script setup lang="ts">
|
||||
import { useTestsStore } from "../stores/tests";
|
||||
const tests = useTestsStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
v-for="t in tests.tierOrder"
|
||||
:key="t"
|
||||
class="flex items-center gap-2 text-xs transition-opacity"
|
||||
:class="tests.tiers[t].total ? '' : 'opacity-45'"
|
||||
>
|
||||
<span class="w-24 text-slate-400 capitalize">{{ t }}</span>
|
||||
<div class="flex-1 h-2.5 rounded-full bg-slate-800 overflow-hidden flex">
|
||||
<div
|
||||
class="bg-emerald-500"
|
||||
:style="{
|
||||
width:
|
||||
tests.tiers[t].total
|
||||
? (tests.tiers[t].passed / tests.tiers[t].total) * 100 + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="bg-rose-500"
|
||||
:style="{
|
||||
width:
|
||||
tests.tiers[t].total
|
||||
? (tests.tiers[t].failed / tests.tiers[t].total) * 100 + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="bg-slate-600"
|
||||
:style="{
|
||||
width:
|
||||
tests.tiers[t].total
|
||||
? (tests.tiers[t].skipped / tests.tiers[t].total) * 100 + '%'
|
||||
: '0%',
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span class="inline-flex items-center gap-1 tabular-nums shrink-0">
|
||||
<span
|
||||
class="w-6 text-right"
|
||||
:class="tests.tiers[t].passed ? 'text-emerald-400' : 'text-slate-600'"
|
||||
>{{ tests.tiers[t].passed }}</span
|
||||
>
|
||||
<span class="text-slate-700">/</span>
|
||||
<span
|
||||
class="w-6 text-right"
|
||||
:class="tests.tiers[t].failed ? 'text-rose-400' : 'text-slate-600'"
|
||||
>{{ tests.tiers[t].failed }}</span
|
||||
>
|
||||
<span class="text-slate-700">/</span>
|
||||
<span
|
||||
class="w-6 text-right"
|
||||
:class="tests.tiers[t].skipped ? 'text-slate-300' : 'text-slate-600'"
|
||||
>{{ tests.tiers[t].skipped }}</span
|
||||
>
|
||||
<span class="w-7 text-right">
|
||||
<span v-if="tests.tiers[t].running" class="text-amber-400"
|
||||
>⟳{{ tests.tiers[t].running }}</span
|
||||
>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import "./style.css";
|
||||
|
||||
createApp(App).use(createPinia()).mount("#app");
|
||||
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useWsStore } from "./ws";
|
||||
|
||||
export interface Build {
|
||||
id: number;
|
||||
env: string;
|
||||
fw_sha: string;
|
||||
fw_branch: string | null;
|
||||
status: string; // queued | building | success | failed | cached | cancelled
|
||||
duration_s: number | null;
|
||||
artifact_dir: string | null;
|
||||
error: string | null;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
export const useBuildsStore = defineStore("builds", () => {
|
||||
const byId = reactive<Record<number, Build>>({});
|
||||
const dockerAvailable = ref(true);
|
||||
|
||||
const list = computed(() => Object.values(byId).sort((a, b) => b.id - a.id));
|
||||
|
||||
async function load() {
|
||||
const res = await api.get<{ docker: boolean; builds: Build[] }>(
|
||||
"/api/builds",
|
||||
);
|
||||
dockerAvailable.value = res.docker;
|
||||
for (const b of res.builds) byId[b.id] = b;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const ws = useWsStore();
|
||||
ws.subscribe("build.update", (b: Build) => {
|
||||
if (b && b.id != null) byId[b.id] = b;
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
// Latest build row for an (env, sha) — drives the device flash button state.
|
||||
function statusFor(
|
||||
env: string,
|
||||
sha: string | null | undefined,
|
||||
): Build | undefined {
|
||||
if (!sha) return undefined;
|
||||
return Object.values(byId)
|
||||
.filter((b) => b.env === env && b.fw_sha === sha)
|
||||
.sort((a, b) => b.id - a.id)[0];
|
||||
}
|
||||
|
||||
async function prebuildTracked() {
|
||||
await api.post("/api/builds", {});
|
||||
}
|
||||
|
||||
async function enqueue(envs: string[], force = false) {
|
||||
await api.post("/api/builds", { envs, force });
|
||||
}
|
||||
|
||||
return {
|
||||
byId,
|
||||
list,
|
||||
dockerAvailable,
|
||||
load,
|
||||
init,
|
||||
statusFor,
|
||||
prebuildTracked,
|
||||
enqueue,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, reactive } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import type { Camera } from "../types";
|
||||
import { useWsStore } from "./ws";
|
||||
|
||||
export const useCamerasStore = defineStore("cameras", () => {
|
||||
const byId = reactive<Record<number, Camera>>({});
|
||||
|
||||
const list = computed(() => Object.values(byId).sort((a, b) => a.id - b.id));
|
||||
|
||||
function forDevice(serial: string): Camera | undefined {
|
||||
return Object.values(byId).find((c) => c.device_serial === serial);
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const cams = await api.get<Camera[]>("/api/cameras");
|
||||
for (const c of cams) byId[c.id] = c;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const ws = useWsStore();
|
||||
ws.subscribe("camera.update", (c: Camera) => {
|
||||
if (c.deleted) delete byId[c.id];
|
||||
else byId[c.id] = c;
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
async function add(name: string, device_index: string) {
|
||||
const cam = await api.post<Camera>("/api/cameras", { name, device_index });
|
||||
byId[cam.id] = cam;
|
||||
}
|
||||
|
||||
async function remove(id: number) {
|
||||
await api.del(`/api/cameras/${id}`);
|
||||
delete byId[id];
|
||||
}
|
||||
|
||||
async function assign(id: number, device_serial: string | null) {
|
||||
const cam = await api.post<Camera>(`/api/cameras/${id}/assign`, {
|
||||
device_serial,
|
||||
});
|
||||
byId[id] = cam;
|
||||
}
|
||||
|
||||
async function setRotation(id: number, rotation: number) {
|
||||
const cam = await api.post<Camera>(`/api/cameras/${id}/rotation`, {
|
||||
rotation,
|
||||
});
|
||||
byId[id] = cam;
|
||||
}
|
||||
|
||||
async function setMirror(id: number, mirror: boolean) {
|
||||
const cam = await api.post<Camera>(`/api/cameras/${id}/mirror`, { mirror });
|
||||
byId[id] = cam;
|
||||
}
|
||||
|
||||
return {
|
||||
byId,
|
||||
list,
|
||||
forDevice,
|
||||
load,
|
||||
init,
|
||||
add,
|
||||
remove,
|
||||
assign,
|
||||
setRotation,
|
||||
setMirror,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import { useWsStore } from "./ws";
|
||||
|
||||
export interface DDConfig {
|
||||
enabled: boolean;
|
||||
site: string;
|
||||
scrub: string;
|
||||
collector: string;
|
||||
host: string;
|
||||
ship_debug: boolean;
|
||||
has_key: boolean;
|
||||
key_hint: string;
|
||||
is_client_token: boolean;
|
||||
}
|
||||
|
||||
export interface DDStats {
|
||||
running: boolean;
|
||||
sent_logs: number;
|
||||
sent_metrics: number;
|
||||
cycles: number;
|
||||
last_error: string | null;
|
||||
last_cycle_ts: number | null;
|
||||
}
|
||||
|
||||
export interface DDStatus {
|
||||
config: DDConfig;
|
||||
stats: DDStats;
|
||||
}
|
||||
|
||||
export const useDatadogStore = defineStore("datadog", () => {
|
||||
const status = ref<DDStatus | null>(null);
|
||||
|
||||
async function load() {
|
||||
status.value = await api.get<DDStatus>("/api/datadog");
|
||||
}
|
||||
|
||||
function init() {
|
||||
const ws = useWsStore();
|
||||
ws.subscribe("datadog.update", (s: DDStatus) => {
|
||||
status.value = s;
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
// Send only the fields that changed. Omit api_key to keep the existing one.
|
||||
async function save(updates: Record<string, unknown>) {
|
||||
status.value = await api.put<DDStatus>("/api/datadog", updates);
|
||||
}
|
||||
|
||||
async function test(): Promise<{ ok: boolean; error: string | null }> {
|
||||
return api.post("/api/datadog/test");
|
||||
}
|
||||
|
||||
return { status, load, init, save, test };
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, reactive } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import type { Device } from "../types";
|
||||
import { useWsStore } from "./ws";
|
||||
|
||||
export const useDevicesStore = defineStore("devices", () => {
|
||||
// Keyed by serial_number — a device.update with a new current_port is a field
|
||||
// update on the same entry, so the card "follows" the device across ports.
|
||||
const bySerial = reactive<Record<string, Device>>({});
|
||||
|
||||
const list = computed(() =>
|
||||
Object.values(bySerial).sort((a, b) => {
|
||||
if (a.online !== b.online) return b.online - a.online;
|
||||
return (a.friendly_name || a.serial_number).localeCompare(
|
||||
b.friendly_name || b.serial_number,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
async function load() {
|
||||
const devices = await api.get<Device[]>("/api/devices");
|
||||
for (const d of devices) bySerial[d.serial_number] = d;
|
||||
}
|
||||
|
||||
function init() {
|
||||
const ws = useWsStore();
|
||||
ws.subscribe("device.update", (d: any) => {
|
||||
if (d && d.deleted) delete bySerial[d.serial_number];
|
||||
else if (d) bySerial[d.serial_number] = d;
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
async function setFriendlyName(serial: string, name: string) {
|
||||
const updated = await api.patch<Device>(`/api/devices/${serial}`, {
|
||||
friendly_name: name,
|
||||
});
|
||||
bySerial[serial] = updated;
|
||||
}
|
||||
|
||||
async function refresh(serial: string) {
|
||||
const res = await api.post<{ device: Device }>(
|
||||
`/api/devices/${serial}/refresh`,
|
||||
);
|
||||
bySerial[serial] = res.device;
|
||||
}
|
||||
|
||||
// Pin a pio env (manual override) or release to auto-detect (env=null).
|
||||
async function setEnv(serial: string, env: string | null) {
|
||||
const updated = await api.put<Device>(`/api/devices/${serial}/env`, {
|
||||
env,
|
||||
});
|
||||
bySerial[serial] = updated;
|
||||
}
|
||||
|
||||
// Pin (or clear, with location=null) which uhubctl hub port the node sits on.
|
||||
async function setHubPort(
|
||||
serial: string,
|
||||
location: string | null,
|
||||
port: number | null,
|
||||
) {
|
||||
const updated = await api.put<Device>(`/api/devices/${serial}/hub-port`, {
|
||||
location,
|
||||
port,
|
||||
});
|
||||
bySerial[serial] = updated;
|
||||
}
|
||||
|
||||
// Auto-bind the node to its hub port (unique VID match) or get candidates.
|
||||
async function locate(serial: string) {
|
||||
const res = await api.post<{
|
||||
located: boolean;
|
||||
device: Device;
|
||||
candidates: { location: string; port: number }[];
|
||||
}>(`/api/devices/${serial}/locate`);
|
||||
if (res.located && res.device) bySerial[serial] = res.device;
|
||||
return res;
|
||||
}
|
||||
|
||||
// Cut/restore/cycle USB power to the node via its tracked hub port.
|
||||
async function power(serial: string, action: "on" | "off" | "cycle") {
|
||||
return api.post(`/api/devices/${serial}/power/${action}`);
|
||||
}
|
||||
|
||||
return {
|
||||
bySerial,
|
||||
list,
|
||||
load,
|
||||
init,
|
||||
setFriendlyName,
|
||||
refresh,
|
||||
setEnv,
|
||||
setHubPort,
|
||||
locate,
|
||||
power,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { api } from "../api/client";
|
||||
import type { FirmwareRef } from "../types";
|
||||
import { useWsStore } from "./ws";
|
||||
|
||||
export const useFirmwareStore = defineStore("firmware", () => {
|
||||
const ref_ = ref<FirmwareRef>({ available: false });
|
||||
|
||||
async function load() {
|
||||
ref_.value = await api.get<FirmwareRef>("/api/firmware");
|
||||
}
|
||||
|
||||
function init() {
|
||||
const ws = useWsStore();
|
||||
ws.subscribe("firmware.update", (r: FirmwareRef) => {
|
||||
ref_.value = r;
|
||||
});
|
||||
load();
|
||||
}
|
||||
|
||||
return { ref: ref_, load, init };
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user