feat(automations): geofence map editor, multi-source/unified-channel send, and builder/test-panel UX (#3653)#3721
Conversation
Define the trigger.geofence region visually instead of with bare lat/lon/radius number inputs. The builder now renders a circle/polygon toggle plus the shared GeofenceMapEditor (the same Leaflet editor the Meshtastic geofence-alert UI uses), and stores the drawn GeofenceShape as the trigger's `shape` param. Backend geofence evaluation becomes shape-aware: geo.ts gains pointInPolygon / pointInShape / geofenceCenter / normalizeGeofenceParams, the last of which falls back to legacy flat lat/lon/radiusKm so existing saved automations keep working. The engine and simulator now test inside/outside against the resolved shape and report distance to the shape's reference point (circle center / polygon centroid). Also fix unreadable automation-page headers: .ae-tab.is-active and .ae-drawer h3 used the black --ctp-accent-text on dark backgrounds — switched to the blue accent / theme foreground respectively. Tests: polygon point-in-shape + legacy back-compat in geo.test.ts, a polygon enter/exit engine test (existing flat-circle tests retained for back-compat), and a compile/decompile round-trip of nested circle & polygon shapes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…e matching (#3653) Two Automation Engine improvements: 1. Substitutions reference is now a docked, non-modal slide-in panel instead of a modal pop-over. Dropped the backdrop and click-away-close so it stays open beside the builder while you keep editing; added a slide-in animation (respecting prefers-reduced-motion). 2. trigger.message can now match by channel NAME, not just slot index. The same channel often sits in a different slot on different sources, so an index filter isn't portable. messageMatchesFilter gains a pre-resolved channelName arg; the engine resolves the message's per-source slot->name once (via databaseService.channels.getChannelById, exposed through a new optional NodeDataProvider.getChannelName) and only when a loaded automation actually filters by name (hot path stays DB-free otherwise). Catalog gains an "On channel (name)" field; the Test panel gains a "Channel name" input and the simulator honors it. Tests: channel-name match/no-match/unresolved in triggerContext.test.ts and an engine-level per-source slot->name resolution test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…ode, no-op action (#3653) Fixes a real "System Start automation never posts" trap and adds the building blocks for unconditional / multi-conditional workflows: - action.sendMessage gains a "Send via source" selector (new sourceselect field kind). Source-less triggers (System events, Schedule) carry sourceId=null, so without an explicit source the send had no radio and failed at mgr(null). The executor already honored a sourceId param (targetSource); it just wasn't exposed in the builder. - New condition.always — an explicit "Always (no filtering)" pass-through, so a rule can run unconditionally without reaching for source/field filters that can't match a source-less event. - New FINALLY mode ALWAYS (alongside ANY/ALL/NONE): the flow.collapse runs its actions regardless of which rules matched. - New action.nothing ("Do nothing") no-op, so a rule can contribute only its IF result to a FINALLY ANY/ALL/NONE step without doing anything itself (multi-conditional composition). Tests cover the no-op action, the ALWAYS collapse mode, condition.always, the source-routed send, and ALWAYS compile/decompile round-trip. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…3653) Replace the Send-a-message action's single source picker + channel-number field with two multi-selects: - "Send via sources": all enabled, sendable sources. MQTT sources are receive-only and excluded. MeshCore is now wired as a send target (companion sendMessage) alongside Meshtastic (sendTextMessage). - "On channels": channels unified across sources by name + key. The raw PSK is never sent to the browser — channels are identified by a one-way key fingerprint (sha256), so two sources only share a channel when both the name AND the key match. Send semantics are a source × channel matrix: for each selected source, the message goes out on each selected channel that source actually carries, resolved to that source's local slot index (the same channel can sit in a different slot per source). A channel absent on a source is skipped; if a selection matches nothing, the run fails with a clear error. New GET /api/automations/channels returns the unified list (name + fp + encryption + per-source slots, no psk). NodeDataProvider gains getChannels; the executor resolves selections via it and falls back to the legacy single sourceId/channel params so existing automations keep working. Tests: fingerprint/unify grouping (and psk non-exposure), executor matrix, absent-channel skip, no-match error, and legacy back-compat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…MC/MT badges (#3653) Address feedback on the Send-a-message channel picker: - Exclude disabled slots (Meshtastic role 0) — the empty "Channel 3..7" placeholders no longer appear. - Keep MeshCore and Meshtastic channels SEPARATE: unify by protocol + name (case-insensitive) instead of by key fingerprint. A MeshCore "gauntlet" and a Meshtastic "gauntlet" are distinct channels (different key derivation) and now show as two entries, each combining across the radios of its own protocol. - Badge sources and channels with MC / MT so the protocol is obvious. The executor now resolves a selected channel to each source's local slot by name AND protocol (via a new getSourceProtocol on the data provider), so a Meshtastic channel is never sent to a MeshCore source even if the names match. Selection shape is {name, protocol}; the unified-channels endpoint returns protocol + role and no raw PSK. Tests updated: protocol-separation, disabled-slot exclusion, psk non-exposure, and the executor's protocol-scoped matrix send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
#3653) The Test panel rendered the system-trigger "Event" as a free-text input, so typing anything other than the exact enum (e.g. "System start" instead of "bootup") made the dry-run report matched=false / "won't run". Replaced it with a select of the valid system events (System start / source online / source offline / upgrade available), defaulting to bootup, mirroring the builder's trigger.system field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…dry-run (#3653) Follow-up review of triggers/conditions for the same class of bug as the system-event free-text field: - Telemetry "Metric" in the Test panel is now a dropdown (was free text), matching the trigger.telemetry options — a typo'd metric no longer makes the dry-run silently not match. - Added a "From source" selector to the Test panel. condition.sourceFilter evaluated against the synthetic event's sourceId, which always defaulted to 'default' — so any source-scoped workflow tested as "won't run". The selector lets you pick the simulated source. - trigger.schedule now dry-runs as matched (cron tick assumed) instead of falling through to the default "not matched" branch. NOTE: live cron firing for schedule triggers is still not wired in the engine — this only makes the Test panel able to exercise the downstream conditions/actions. Tests cover the schedule dry-run and source-filter pass/fail by event source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…/time (#3653) `{{ trigger.timestamp }}` and `{{ NOW }}` carry epoch milliseconds, which showed up in sent messages as an unreadable number (e.g. "1782331391753"). Interpolation now formats any `NOW` / `*.timestamp` numeric token as a local `YYYY-MM-DD HH:mm:ss` string. Second-based epoch fields like `rxTime` are left untouched. Substitutions help updated to reflect the formatting. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
…ken entry) (#3653) Records two deferred items to pick up after this PR merges: - Fully implement the Schedule trigger (cron firing is not wired yet). - Highlight {{ trigger.* }}/{{ var.* }} tokens in text fields and flag unrecognized tokens as errors to catch typos. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Claude finished @Yeraze's task —— View job PR ReviewThis is a well-structured PR with clear separation of concerns. Here's a comprehensive review: Security✅ PSK never exposed to the browser —
const rawUrls = typeof p.urls === 'string'
? await interpolateAsync(p.urls, ctx, { varsOnly: true })
: '';The ✅ Prototype pollution guarded in ✅ ReDoS protected — both Potential Bugs🐛 private geofenceState = new Map<string, boolean>();One entry per 🐛 Geofence state not reset on this.geofenceState.clear(); // add after: this.index = index;🐛 if (destination != null || channelSel.length === 0) {
results.push(await deps.sendMessage({ sourceId: sid, text, channel: fallbackChannel, destination, replyId }));When 🐛 Empty channel name display in the builder ( {' '}{c.name || '(Primary)'}This correctly shows 🐛 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ...`;
Code Quality✅ Clean architecture — ✅ Back-compat —
<input type="radio" name="ae-geofence-shape" ...If multiple
return path === 'NOW' || /(^|\.)timestamp$/i.test(path);Any path ending in Performance✅ Lazy channel-name DB lookup (
const proto = (await ctx.data.getSourceProtocol?.(sid)) ?? null;
const srcChannels = (await ctx.data.getChannels?.(sid)) ?? [];These are called inside the Test Coverage✅ Strong new test coverage:
Minor / Nits
SummaryThis is a high-quality, well-tested PR with thoughtful security design (PSK non-exposure, ReDoS protection, prototype pollution guard). The main actionable items before merge:
The |
…dule note (#3653) From the Claude Code Review on #3721: - Narrow the timestamp auto-format heuristic to exactly {{ NOW }} and {{ trigger.timestamp }} so a user {{ var.*timestamp }} (unknown units, e.g. seconds) is never mis-rendered as a far-future date. + test. - GeofenceFieldInput: derive the radio-group name from useId() so multiple editors on a page can't interfere. - catalog.ts: comment that trigger.schedule is a stub not fired live yet. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5
|
Claude finished @Yeraze's task —— View job PR Review
This is a large, well-structured PR adding real capability to the Automation Engine. The code quality is consistently high and the PR description is honest about what's stubbed vs. shipped. Here's my detailed review: Security✅ PSK never sent to the browser — ✅ URL injection protected ( ✅ ReDoS protected — ✅ Prototype pollution guarded (
Bugs🐛 private geofenceState = new Map<string, boolean>();One entry per 🐛 🐛 Multi-source DM sends to every selected source ( if (destination != null || channelSel.length === 0) {
results.push(await deps.sendMessage({ sourceId: sid, text, channel: fallbackChannel, destination, replyId }));When a user selects 3 sources AND a DM destination, the DM is sent on all 3 sources. The UI doesn't warn about this — a user selecting "all my radios" for broadcast channel sends may inadvertently 3x a DM. 🐛 {' '}{c.name || '(Primary)'}MeshCore channels with empty names are shown as 🐛 return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
🐛 Code Quality✅ Clean architecture — ✅ Back-compat — ✅
function isMsTimestampPath(path: string): boolean {
return path === 'NOW' || path === 'trigger.timestamp';
}The current implementation is correctly narrowed to only
✅ Performance
const proto = (await ctx.data.getSourceProtocol?.(sid)) ?? null;
const srcChannels = (await ctx.data.getChannels?.(sid)) ?? [];These two DB calls happen inside the ✅ Lazy channel-name DB lookup ( Test Coverage✅ Strong new coverage:
Minor / Nits
SummaryShip-blocking: None, given the PR explicitly calls out Recommended before merge:
Fine as follow-ups (per PR description):
The test suite is comprehensive, the security design is sound, and the feature set is well-scoped. |
|
Thanks for the review. Triage of the flagged items (addressed in Fixed
Not an issue
Pre-existing (Phase 1a
Judgment calls, intentionally left as-is:
|
…t fields (#3653) (#3727) * feat(automations): highlight {{ }} tokens + flag typos in builder text fields (#3653) Second of the two post-#3721 follow-ups. Token-bearing text/textarea fields (catalog `tokens: true`: message text/DM-to, notify title/body, condition values, setVar value) now use TokenTextField — a highlight backdrop renders `{{ trigger.* }}` / `{{ var.* }}` tokens blue when recognized and red+wavy when not, with unrecognized tokens listed inline below the field to catch typos like `{{ trigger.lastestVersion }}` or a `var.` name with no matching variable. - tokenHints.ts (pure, tested): validTokenSet(triggerType, vars) from the exported TRIGGER_TOKENS + UNIVERSAL_TOKENS + known variables; tokenize() and unknownTokens(). - Non-blocking hint (not a hard save gate) to avoid false positives on tokens the static registry might not enumerate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 * refactor(automations): replace literal zero-width space with escape (#3653) Per PR review nit — the backdrop's trailing zero-width space was a literal invisible char in source (a readability/foot-gun, like a stray NUL). Use an explicit escape + comment instead. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VBLhGGNh35oMwTL53va1Y5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Automation Engine (#3653) usability + capability work, built and tested iteratively against the dev container.
Triggers & conditions
trigger.geofencenow defines its region on a Leaflet map (circle center+radius or polygon), reusing the sharedGeofenceMapEditor. Backend evaluation is shape-aware (pointInShape/ polygon ray-cast) with back-compat for legacy flatlat/lon/radiusKm.trigger.message— match by channel name (portable across sources where the channel sits in a different slot), not just slot index.condition.always— an explicit "Always (no filtering)" pass-through.ALWAYSmode — run the combine actions unconditionally (alongside ANY/ALL/NONE).action.nothing— a no-op so a rule can contribute only its IF result to a FINALLY step.Send-a-message: multi-source + unified channels
GET /api/automations/channelsreturns the unified list (no PSK). Back-compat with the legacy singlesourceId/channel.Builder / Test-panel UX
.ae-tab.is-active,.ae-drawer h3).← Dashboardbutton now respects the runtimeBASE_URL.condition.sourceFiltercan be exercised;trigger.schedulenow dry-runs as matched.{{ timestamp }}/{{ NOW }}render as a localYYYY-MM-DD HH:mm:ssinstead of raw epoch ms.Follow-ups (documented in
AUTOMATION_ENGINE_PLAN.md§11, for after merge)trigger.scheduleis a catalog stub; no live cron firing is wired yet (only the Test-panel dry-run).{{ }}text entry — highlight trigger/var tokens distinctly and flag unrecognized tokens as errors to catch typos.Testing
Full Vitest suite green (7491 passed, 0 failures) incl. new tests for geofence shapes, channel unify/protocol separation/PSK non-exposure, executor matrix + back-compat, condition.always, ALWAYS collapse, action.nothing, schedule dry-run, source-filter, and timestamp formatting.
tsc+ production build clean.🤖 Generated with Claude Code