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, ...overrides,
}); });
/** Parse a quoted CSV row into unquoted values. */
function parseCsvRow(row: string): string[] {
return row.split(",").map(v => v.replace(/^"|"$/g, ""));
}
describe("generateAnytoneContactsCsv", () => { describe("generateAnytoneContactsCsv", () => {
const tgs: CpsTalkgroup[] = [ const tgs: CpsTalkgroup[] = [
{ id: 262, name: "TG262 Deutschlan", slot: "1" }, { id: 262, name: "TG262 Deutschlan", slot: "1" },
@ -71,34 +76,77 @@ describe("generateAnytoneChannelsCsv", () => {
expect(lines).toHaveLength(3); // header + 2 channels 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 csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n"); const lines = csv.split("\r\n");
const headerCols = lines[0].split(",").length; const headerCols = lines[0].split(",").length;
expect(headerCols).toBe(55); expect(headerCols).toBe(56);
for (let i = 1; i < lines.length; i++) { for (let i = 1; i < lines.length; i++) {
expect(lines[i].split(",")).toHaveLength(headerCols); expect(lines[i].split(",")).toHaveLength(headerCols);
} }
}); });
it("includes correct frequencies", () => { it("all values are double-quoted", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs); const csv = generateAnytoneChannelsCsv(repeaters, tgs);
expect(csv).toContain("439.500000"); const lines = csv.split("\r\n");
expect(csv).toContain("431.900000"); 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", () => { it("includes color code and timeslot", () => {
const csv = generateAnytoneChannelsCsv(repeaters, tgs); const csv = generateAnytoneChannelsCsv(repeaters, tgs);
const lines = csv.split("\r\n"); const lines = csv.split("\r\n");
const cols = lines[1].split(","); const cols = parseCsvRow(lines[1]);
// Color code at index 20, slot at index 21 // RX Color Code at index 20, Slot at index 21
expect(cols[20]).toBe("1"); expect(cols[20]).toBe("1");
expect(cols[21]).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", () => { describe("ANYTONE_CH_HEADER", () => {
it("has 55 columns", () => { it("has 56 columns", () => {
expect(ANYTONE_CH_HEADER.split(",")).toHaveLength(55); 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 type { Repeater, CpsTalkgroup } from "./types";
import { formatFreq } from "./format";
export const ANYTONE_CH_HEADER = "No.,Channel Name,Receive Frequency,Transmit Frequency," + function q(v: string | number): string {
"Channel Type,Transmit Power,Band Width,CTCSS/DCS Decode,CTCSS/DCS Encode," + return '"' + v + '"';
"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," + export const ANYTONE_CH_HEADER = [
"Simplex TDMA,Slot Suit,AES Digital Encryption,Digital Encryption," + "No.", "Channel Name", "Receive Frequency", "Transmit Frequency",
"Call Confirmation,Talk Around(Simplex),Work Alone,Custom CTCSS," + "Channel Type", "Transmit Power", "Band Width", "CTCSS/DCS Decode",
"2TONE Decode,Ranging,Through Mode,APRS RX,Analog APRS PTT Mode," + "CTCSS/DCS Encode", "Contact", "Contact Call Type", "Contact TG/DMR ID",
"Digital APRS PTT Mode,APRS Report Type,Digital APRS Report Channel," + "Radio ID", "Busy Lock/TX Permit", "Squelch Mode", "Optional Signal",
"Correct Frequency[Hz],SMS Confirmation,Exclude channel from roaming," + "DTMF ID", "2Tone ID", "5Tone ID", "PTT ID", "RX Color Code", "Slot",
"DMR MODE,DataACK Disable,R5toneBot,R5ToneEot,Auto Scan," + "Scan List", "Receive Group List", "PTT Prohibit", "Reverse",
"Ana APRS TX Path,APRS TX Tone,APRS Signal Path,Digi APRS TX CH,Alert Tone"; "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. */ /** Generate an Anytone CPS talkgroups CSV. */
export function generateAnytoneContactsCsv(talkgroups: CpsTalkgroup[]): string { export function generateAnytoneContactsCsv(talkgroups: CpsTalkgroup[]): string {
@ -24,32 +31,36 @@ export function generateAnytoneContactsCsv(talkgroups: CpsTalkgroup[]): string {
return rows.join("\r\n"); 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. */ /** Generate an Anytone CPS channels CSV. */
export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string { export function generateAnytoneChannelsCsv(repeaters: Repeater[], talkgroups: CpsTalkgroup[]): string {
const rows = [ANYTONE_CH_HEADER]; const rows = [ANYTONE_CH_HEADER];
let num = 0; let num = 0;
repeaters.forEach(function (r) { repeaters.forEach(function (r) {
const rxFreq = formatFreq(r.freq_tx); const rxFreq = formatFreq5(r.freq_tx);
const txFreq = formatFreq(r.freq_rx); const txFreq = formatFreq5(r.freq_rx);
const cc = r.color_code || 1; const cc = r.color_code || 1;
talkgroups.forEach(function (tg) { talkgroups.forEach(function (tg) {
num++; num++;
const chName = (r.callsign + " " + tg.name).substring(0, 16); const chName = (r.callsign + " " + tg.name).substring(0, 16);
const contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16); const contactName = (tg.name || "TG" + tg.id).trim().substring(0, 16);
rows.push( rows.push([
num + "," + chName + "," + rxFreq + "," + txFreq + "," + num, chName, rxFreq, txFreq,
"D-Digital,High,12.5K,Off,Off," + "D-Digital", "High", "12.5K", "Off", "Off",
contactName + ",Group Call," + tg.id + ",1," + contactName, "Group Call", tg.id, "",
"Same Color Code,Carrier,Off,1,1,1,Off," + "Same Color Code", "Carrier", "Off", 1, 1, 1, "Off",
cc + "," + tg.slot + ",None,None,Off,Off," + cc, tg.slot, "None", "None", "Off", "Off",
"Off,Off,Normal Encryption,Off," + "Off", "Off", "Normal Encryption", "Off",
"Off,Off,Off,251.1," + "Off", "Off", "Off", "251.1",
"0,Off,Off,Off,Off," + 0, "Off", "On", "Off", "Off",
"Off,Off,1," + "Off", "Off", 1, 0, "Off",
"0,Off,0," + 0, 0, 0, 0, 0, 0,
"0,0,0,0,Off," + 0, 0, 0, 0, 0, 1,
"Off,Off,Off,1,Off" ].map(q).join(","));
);
}); });
}); });
return rows.join("\r\n"); return rows.join("\r\n");