Fix AnyTone Channel export

This commit is contained in:
Marcus Kida 2026-02-20 14:38:16 +01:00
parent e53b008679
commit fade468ee0
2 changed files with 97 additions and 38 deletions

View file

@ -14,6 +14,11 @@ const makeRepeater = (overrides: Partial<Repeater> = {}): Repeater => ({
...overrides,
});
/** Parse a quoted CSV row into unquoted values. */
function parseCsvRow(row: string): string[] {
return row.split(",").map(v => v.replace(/^"|"$/g, ""));
}
describe("generateAnytoneContactsCsv", () => {
const tgs: CpsTalkgroup[] = [
{ id: 262, name: "TG262 Deutschlan", slot: "1" },
@ -71,34 +76,77 @@ describe("generateAnytoneChannelsCsv", () => {
expect(lines).toHaveLength(3); // header + 2 channels
});
it("has 55 columns per row (matching header)", () => {
it("has 56 columns per row (matching header)", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n");
const headerCols = lines[0].split(",").length;
expect(headerCols).toBe(55);
expect(headerCols).toBe(56);
for (let i = 1; i < lines.length; i++) {
expect(lines[i].split(",")).toHaveLength(headerCols);
}
});
it("includes correct frequencies", () => {
it("all values are double-quoted", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
expect(csv).toContain("439.500000");
expect(csv).toContain("431.900000");
const lines = csv.split("\r\n");
for (let i = 0; i < lines.length; i++) {
const fields = lines[i].split(",");
for (const field of fields) {
expect(field).toMatch(/^".*"$/);
}
}
});
it("includes correct frequencies with 5 decimal places", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
expect(csv).toContain("439.50000");
expect(csv).toContain("431.90000");
});
it("includes color code and timeslot", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n");
const cols = lines[1].split(",");
// Color code at index 20, slot at index 21
const cols = parseCsvRow(lines[1]);
// RX Color Code at index 20, Slot at index 21
expect(cols[20]).toBe("1");
expect(cols[21]).toBe("1");
});
it("has empty Radio ID field", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
expect(cols[12]).toBe("");
});
it("has Through Mode set to On", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
expect(cols[36]).toBe("On");
});
it("has TxCC as last column set to 1", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n");
const cols = parseCsvRow(lines[1]);
expect(cols[55]).toBe("1");
});
});
describe("ANYTONE_CH_HEADER", () => {
it("has 55 columns", () => {
expect(ANYTONE_CH_HEADER.split(",")).toHaveLength(55);
it("has 56 columns", () => {
expect(ANYTONE_CH_HEADER.split(",")).toHaveLength(56);
});
it("uses RX Color Code column name", () => {
expect(ANYTONE_CH_HEADER).toContain('"RX Color Code"');
});
it("has all values double-quoted", () => {
const fields = ANYTONE_CH_HEADER.split(",");
for (const field of fields) {
expect(field).toMatch(/^".*"$/);
}
});
});

View file

@ -1,18 +1,25 @@
import type { Repeater, CpsTalkgroup } from "./types";
import { formatFreq } from "./format";
export const ANYTONE_CH_HEADER = "No.,Channel Name,Receive Frequency,Transmit Frequency," +
"Channel Type,Transmit Power,Band Width,CTCSS/DCS Decode,CTCSS/DCS Encode," +
"Contact,Contact Call Type,Contact TG/DMR ID,Radio ID," +
"Busy Lock/TX Permit,Squelch Mode,Optional Signal,DTMF ID,2Tone ID,5Tone ID,PTT ID," +
"Color Code,Slot,Scan List,Receive Group List,PTT Prohibit,Reverse," +
"Simplex TDMA,Slot Suit,AES Digital Encryption,Digital Encryption," +
"Call Confirmation,Talk Around(Simplex),Work Alone,Custom CTCSS," +
"2TONE Decode,Ranging,Through Mode,APRS RX,Analog APRS PTT Mode," +
"Digital APRS PTT Mode,APRS Report Type,Digital APRS Report Channel," +
"Correct Frequency[Hz],SMS Confirmation,Exclude channel from roaming," +
"DMR MODE,DataACK Disable,R5toneBot,R5ToneEot,Auto Scan," +
"Ana APRS TX Path,APRS TX Tone,APRS Signal Path,Digi APRS TX CH,Alert Tone";
function q(v: string | number): string {
return '"' + v + '"';
}
export const ANYTONE_CH_HEADER = [
"No.", "Channel Name", "Receive Frequency", "Transmit Frequency",
"Channel Type", "Transmit Power", "Band Width", "CTCSS/DCS Decode",
"CTCSS/DCS Encode", "Contact", "Contact Call Type", "Contact TG/DMR ID",
"Radio ID", "Busy Lock/TX Permit", "Squelch Mode", "Optional Signal",
"DTMF ID", "2Tone ID", "5Tone ID", "PTT ID", "RX Color Code", "Slot",
"Scan List", "Receive Group List", "PTT Prohibit", "Reverse",
"Simplex TDMA", "Slot Suit", "AES Digital Encryption", "Digital Encryption",
"Call Confirmation", "Talk Around(Simplex)", "Work Alone", "Custom CTCSS",
"2TONE Decode", "Ranging", "Through Mode", "APRS RX",
"Analog APRS PTT Mode", "Digital APRS PTT Mode", "APRS Report Type",
"Digital APRS Report Channel", "Correct Frequency[Hz]", "SMS Confirmation",
"Exclude channel from roaming", "DMR MODE", "DataACK Disable",
"R5toneBot", "R5ToneEot", "Auto Scan", "Ana Aprs Mute",
"Send Talker Alias", "AnaAprsTxPath", "ARC4", "ex_emg_kind", "TxCC",
].map(q).join(",");
/** Generate an Anytone CPS talkgroups CSV. */
export function generateAnytoneContactsCsv(talkgroups: CpsTalkgroup[]): string {
@ -24,32 +31,36 @@ export function generateAnytoneContactsCsv(talkgroups: CpsTalkgroup[]): string {
return rows.join("\r\n");
}
/** Format frequency to 5 decimal places for Anytone CPS. */
function formatFreq5(mhz: number | string): string {
return parseFloat(String(mhz)).toFixed(5);
}
/** Generate an Anytone CPS channels CSV. */
export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string {
const rows = [ANYTONE_CH_HEADER];
let num = 0;
repeaters.forEach(function (r) {
const rxFreq = formatFreq(r.freq_tx);
const txFreq = formatFreq(r.freq_rx);
const rxFreq = formatFreq5(r.freq_tx);
const txFreq = formatFreq5(r.freq_rx);
const cc = r.color_code || 1;
talkgroups.forEach(function (tg) {
num++;
const chName = (r.callsign + " " + tg.name).substring(0, 16);
const contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16);
rows.push(
num + "," + chName + "," + rxFreq + "," + txFreq + "," +
"D-Digital,High,12.5K,Off,Off," +
contactName + ",Group Call," + tg.id + ",1," +
"Same Color Code,Carrier,Off,1,1,1,Off," +
cc + "," + tg.slot + ",None,None,Off,Off," +
"Off,Off,Normal Encryption,Off," +
"Off,Off,Off,251.1," +
"0,Off,Off,Off,Off," +
"Off,Off,1," +
"0,Off,0," +
"0,0,0,0,Off," +
"Off,Off,Off,1,Off"
);
rows.push([
num, chName, rxFreq, txFreq,
"D-Digital", "High", "12.5K", "Off", "Off",
contactName, "Group Call", tg.id, "",
"Same Color Code", "Carrier", "Off", 1, 1, 1, "Off",
cc, tg.slot, "None", "None", "Off", "Off",
"Off", "Off", "Normal Encryption", "Off",
"Off", "Off", "Off", "251.1",
0, "Off", "On", "Off", "Off",
"Off", "Off", 1, 0, "Off",
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 1,
].map(q).join(","));
});
});
return rows.join("\r\n");