diff --git a/frontend/src/cps-anytone.test.ts b/frontend/src/cps-anytone.test.ts index 8fdf305..f1f46e3 100644 --- a/frontend/src/cps-anytone.test.ts +++ b/frontend/src/cps-anytone.test.ts @@ -14,6 +14,11 @@ const makeRepeater = (overrides: Partial = {}): 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(/^".*"$/); + } }); }); diff --git a/frontend/src/cps-anytone.ts b/frontend/src/cps-anytone.ts index a839b08..f3689d9 100644 --- a/frontend/src/cps-anytone.ts +++ b/frontend/src/cps-anytone.ts @@ -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");