Skip to content

feat: zone-derived scan lists and scan carrier channel on export #164

Description

@pskillen

Problem

DM32 and many commercial DMR radios use per-channel scan lists (Scan.csv + channel Scan List FK) rather than “zone = scan” (OpenGD77) or a single global list (CHIRP). Operators routinely:

  1. Mark which channels in a zone should be scanned vs zone-only (operational grouping).
  2. Add a scan carrier dummy channel (e.g. GB7GL Scan) as the first zone member — a throwaway simplex slot the radio sits on while scanning; real traffic is heard on scanned channels.
  3. Configure scan so TX goes to the last activated (heard) channel, not the carrier.

Today we defer all scan semantics (#125 — manual ScanList entity). Operators build this layout by hand in CPS. We want zone-driven scan automation at export that derives scan lists from zone membership without requiring operators to maintain a parallel scan-list CRUD surface first.

This is complementary to, not a replacement for, #125 — see comment on #125 for the split.

Intended outcome

Export-time (and optional internal model) scan policy derived from zones, format-aware at the adapter boundary.

1. Per zone membership: include in scan list

Each zone↔channel membership carries an opt-in flag (vendor-neutral), e.g. includeInScanList: boolean (default true when unset — preserve today’s behaviour).

Export target How the flag is honoured
OpenGD77 Zone is the scan list — flag ignored; zone member order = scan order
DM32 Builds / updates Scan.csv member list from flagged members in that zone’s scan group
CHIRP Single zone / single scan list — flagged members contribute to the one scan sequence

Member filter (all targets that honour scan lists):

  • Respect Channel.scanSkip — channels with scanSkip: true are excluded from generated scan lists.
  • Do not map OpenGD77 Zone Skip (opengd77Extras['Zone Skip']) — that vendor field stays in opengd77Extras only; scan inclusion is modelled via includeInScanList + scanSkip, not wire stash.

2. Per zone: generate scan carrier channel

Zone-level boolean, e.g. generateScanCarrier: boolean (default false).

When enabled on export to targets with separate scan lists (primarily DM32):

  1. Emit a scan list named after the zone (or deterministic derivative — finalise collision rules).
  2. Emit a carrier channel (dummy RF — default 145.500 MHz simplex analogue unless operator overrides in zone settings; used only for scanning, not real traffic).
  3. Wire carrier channel Scan List → emitted scan list.
  4. Prepend carrier as first zone member in exported zone row (export-time injection — not necessarily stored in memberChannelIds until export resolves).
  5. Scan list members = zone channels with includeInScanList and not scanSkip, in zone member order (excluding carrier from scanned members; carrier holds the scan-list FK only).

OpenGD77: carrier generation not applicable (no separate scan list / carrier pattern). Flag ignored or hidden in CRUD when export target is zone-as-scan.

CHIRP: evaluate whether carrier pattern applies — likely N/A; document in tier-3 reference.

3. Scan TX mode: last activated channel

For generated scan lists (DM32 Scan.csv), set:

  • scanTxMode = last-activated-channel (TX on last heard channel during scan, not on the carrier).

Other scan-list wire fields (hang time, priority channels, digital scan mode, …) use documented defaults at export unless zone-level overrides are added in a follow-up.

Internal model (proposed)

interface ZoneMemberEntry {
  channelId: string;
  /** When false, channel stays in zone but is omitted from derived scan lists. Default true. */
  includeInScanList?: boolean;
}

interface Zone {
  id: string;
  name: string;
  /** Migrate from string[] or parallel metadata map — design in PR. */
  members: ZoneMemberEntry[];
  /** Export-time: emit scan carrier + derived scan list (DM32-style targets). */
  generateScanCarrier?: boolean;
  /** Optional override for carrier RF; export default 145.500 MHz simplex. */
  scanCarrierFrequencyHz?: number | null;
  meta?: EntityMeta;
}

Migration: existing memberChannelIdsmembers with includeInScanList default true.

Vendor boundary: caps (DM32 ≤16 scan members) apply at export with warnings — not in CRUD validation.

Relationship to #125 (manual ScanList)

This issue (zone-derived automation) #125 (manual ScanList entity)
Operator workflow Configure zones + membership flags; export synthesises scan lists CRUD scan lists independently; assign channel.scanListId
Persistence Zone/membership flags; carrier/list may be export-only ephemera initially First-class ScanList[] in codeplug
Coexistence Not mutually exclusive — manual lists from #125 can coexist; export merge rules TBD (manual wins? union? warn on conflict?)

Ship order flexible: this ticket can land with export-only synthesis before #125 model exists (write Scan.csv without persisting ScanList), then integrate when #125 lands.

Affected

  • src/models/codeplug.ts — zone membership shape + generateScanCarrier; schema migration
  • src/lib/export/dm32/Scan.csv synthesis + channel Scan List column + carrier channel row
  • src/lib/export/opengd77/ — confirm zone-as-scan unchanged; membership flag ignored
  • src/lib/export/chirp/ — single-list member selection from flags
  • src/lib/channelExpansion/ — zone member expansion (multi-mode / multi-TG) must fan out scan membership consistently
  • CRUD — zone edit: per-member “Include in scan list”; zone “Generate scan carrier on export”
  • docs/reference/multi-talkgroup-expansion.md or new docs/reference/scan-policy.md (tier-2 domain doc)
  • docs/reference/dm32/scan-lists.md — generated list + carrier semantics
  • Tests — DM32 export fixture with carrier + derived scan list; OpenGD77 regression

Related

Open design questions

  1. Persist carrier channel in internal model vs export-only synthetic row (re-import recognition).
  2. Merge with manual ScanList when feat: ScanList entity + DM32 Scan.csv import/export #125 lands — precedence rules.
  3. Carrier naming{zoneName} Scan vs {callsign} Scan template.
  4. Zone with generateScanCarrier but zero scan-eligible members — warn and skip?
  5. 16-member DM32 cap — truncate with warning vs split scan lists (out of scope for v1?).

Out of scope

  • Full manual scan-list CRUD (#125)
  • Priority channel 1/2, hang time, and other Scan.csv columns beyond TX mode default (follow-up unless trivial)
  • OpenGD77 Zone Skip wire column modelling
  • Scratch channel per RGL (#163)

Manual verify

  1. Zone with mixed includeInScanList flags → DM32 Scan.csv contains only opted-in, non-scanSkip members.
  2. generateScanCarrier on → carrier channel first in zone export; carrier references scan list; scanned members exclude carrier.
  3. Generated scan list scanTxMode = last activated channel on DM32 wire.
  4. OpenGD77 export unchanged — zone order only; no carrier; membership flag ignored.
  5. Channel with scanSkip: true never appears in derived scan list even if includeInScanList true.
  6. opengd77Extras['Zone Skip'] not read for scan inclusion decisions.
  7. 16 scan members on DM32 → export warning + truncation at boundary.

Workflow note

Branch from origin/main, atomic conventional commits (model → export synthesis + tests → CRUD → docs). PR linking Closes #. Coordinate with #125 when both are in flight — avoid conflicting ScanList shapes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestquality-of-lifefeature to improve the QOL of the person making a codeplugradio-format-supportsupport for 3rd party radio formats - import/export, etc

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions