[
  {
    "id": "aprs_tab",
    "type": "tab",
    "label": "LoRa-APRS",
    "disabled": false,
    "info": "Receives iGate syslog over UDP, parses it, writes to InfluxDB."
  },
  {
    "id": "udp_in",
    "type": "udp in",
    "z": "aprs_tab",
    "name": "iGate syslog UDP 1234",
    "iface": "",
    "port": "1234",
    "ipv": "udp4",
    "multicast": "false",
    "group": "",
    "datatype": "utf8",
    "x": 160,
    "y": 140,
    "wires": [
      [
        "parser"
      ]
    ]
  },
  {
    "id": "parser",
    "type": "function",
    "z": "aprs_tab",
    "name": "SYSLOG PARSER",
    "func": "// ============================================================================\n//  LoRa-APRS syslog parser  \u2014  Node-RED \"function\" node\n// ----------------------------------------------------------------------------\n//  IN  : msg.payload = ONE raw RFC5424 syslog line from your iGate\n//        (delivered by the \"udp in\" node listening on your chosen port)\n//  OUT : msg.payload = InfluxDB line protocol (one row per packet)\n//        msg.headers = the InfluxDB auth + content-type for the write\n//        -> wire this to an \"http request\" node that POSTs to /api/v2/write\n//\n//  Lines whose syslog HOSTNAME is not a valid CALLSIGN-SSID are dropped\n//  (return null) so junk / spoofed traffic never reaches the database.\n//\n//  >>> EDIT THE THREE CONFIG VALUES BELOW <<<\n// ============================================================================\n\nconst INFLUX_TOKEN = \"YOUR_INFLUX_TOKEN\";   // InfluxDB API token (read/write on the bucket)\nconst ORG          = \"aprs\";                // your InfluxDB organisation name\nconst BUCKET       = \"N0CALL-10\";           // your gateway callsign = your bucket name\n\n// --- the raw syslog line -----------------------------------------------------\nconst msgRaw = msg.payload.toString();\n\nlet data = { callsign:\"unknown\", category:\"SYSTEM\", rssi:0, snr:0, freq_offset:0,\n             battery:0, altitude:0, lat:0.0, lon:0.0, message:\"\", raw:msgRaw };\n\n// --- 1. category + HEARD-station callsign (from the line prefix) -------------\n//     RX = heard over RF, TX = transmitted by us, TCPIP = came via the internet.\nif (msgRaw.includes(\",TCPIP,\")) {\n    data.category = \"TCPIP\";\n    const m = msgRaw.match(/([A-Z0-9]+-[0-9]{1,2})>/); if (m) data.callsign = m[1];\n} else if (msgRaw.includes(\" - RX / \")) {\n    data.category = \"RX\";\n    const m = msgRaw.match(/RX \\/ (?:GPS \\/ )?([A-Z0-9]+-[0-9]{1,2})/); if (m) data.callsign = m[1];\n} else if (msgRaw.includes(\" - TX / \")) {\n    data.category = \"TX\";\n    const m = msgRaw.match(/TX \\/ ([A-Z0-9]+-[0-9]{1,2})/); if (m) data.callsign = m[1];\n}\n\n// --- 2. telemetry parsed from the suffix patterns ---------------------------\nlet t;\nif (t = msgRaw.match(/\\/ (-?\\d+)dBm/))      data.rssi        = parseFloat(t[1]); // signal strength\nif (t = msgRaw.match(/\\/ (-?\\d+\\.\\d+)dB/))  data.snr         = parseFloat(t[1]); // signal-to-noise\nif (t = msgRaw.match(/\\/ (-?\\d+)Hz/))       data.freq_offset = parseFloat(t[1]); // frequency error\nif (t = msgRaw.match(/Batt=(\\d+\\.\\d+)V/))   data.battery     = parseFloat(t[1]); // battery (heard stns)\nif (t = msgRaw.match(/\\/A=(\\d+)/))          data.altitude    = parseInt(t[1]);   // altitude\n\n// --- 3. coordinates \u2014 ANCHORED to the decoded \" LATN / LONE \" structure -----\n//     The \" / \" separator is the key: relayed/third-party positions lack it,\n//     so they are skipped and can never mis-pin your fixed gateway on the map.\nconst coord = msgRaw.match(/(\\d+\\.\\d+)([NS]) \\/ ([+-]?\\d+\\.\\d+)([EW])/);\nif (coord) {\n    let la = parseFloat(coord[1]); if (coord[2] === \"S\") la = -la;\n    let lo = parseFloat(coord[3]); if (coord[4] === \"W\") lo = -lo;\n    data.lat = la; data.lon = lo;\n}\n// legacy APRS DDMM.mm safety net: |lat| > 90 can't be degrees -> convert.\nfunction ddmm(v){ const s=v<0?-1:1, a=Math.abs(v), d=Math.floor(a/100); return s*(d+(a-d*100)/60); }\nif (Math.abs(data.lat) > 90) { data.lat = ddmm(data.lat); data.lon = ddmm(data.lon); }\n\n// --- 4. enrichment: tocall / path / digipeated / distance_km ----------------\nlet tocall=\"\", path=\"\", digipeated=0, distance_km=0;\nif (data.category === \"TX\") {\n    const fr = msgRaw.match(/TX \\/ [A-Z0-9-]+>([A-Z0-9-]+),([^:]*):/);\n    if (fr) { tocall = fr[1]; path = fr[2]; }\n} else if (data.category === \"RX\") {\n    const hdr = msgRaw.match(/RX \\/ (?:GPS \\/ )?[A-Z0-9-]+ \\/ (.*?) \\/ -?\\d+dBm/);\n    if (hdr) {\n        const to = hdr[1].match(/\\b(AP[A-Z0-9]+)\\b/); if (to) tocall = to[1];\n        path = hdr[1].replace(/^AP[A-Z0-9]+[,\\s]*/, \"\")\n                     .replace(/\\s*\\/\\s*/g, \" \").replace(/^-\\s*|\\s*-\\s*$/g, \"\").trim();\n    }\n    const dm = msgRaw.match(/[EW] \\/ (\\d+(?:\\.\\d+)?)km/); if (dm) distance_km = parseFloat(dm[1]);\n}\nif (path.includes(\"*\")) digipeated = 1;   // a \"*\" in the path = the packet was digipeated\n\n// --- 5. message content (free text after the last \"/\") ----------------------\nconst parts = msgRaw.split(\"/\"); data.message = parts[parts.length - 1].trim();\n\n// --- 6. GATEWAY = RFC5424 syslog HOSTNAME (3rd token) = YOUR iGate -----------\n//     This is the receiving station. We sanity-check its shape and drop junk.\nlet gateway = \"unknown\";\nconst gw = msgRaw.match(/^<\\d+>\\d+ - ([A-Z0-9]+-[0-9]{1,2}) /);\nif (gw) gateway = gw[1];\nif (!/^[A-Z0-9]{3,7}-\\d{1,2}$/.test(gateway)) return null;\n\n// --- 7. build InfluxDB line protocol ----------------------------------------\n//     ALL numerics are written WITHOUT an \"i\" suffix, i.e. as FLOATS. This is\n//     deliberate: the Grafana queries do float() maths, and InfluxDB refuses to\n//     mix int + float in the same field. Keep everything float and you never\n//     hit a \"field type conflict\".\nfunction et(s){ return String(s).replace(/[ ,=]/g, \"\\\\$&\"); }                        // escape tag value\nfunction ef(s){ return '\"' + String(s).replace(/\\\\/g,\"\\\\\\\\\").replace(/\"/g,'\\\\\"') + '\"'; } // escape string field\n\nconst tags = `callsign=${et(data.callsign)},category=${et(data.category)},gateway=${et(gateway)}`;\nconst fields = [\n    `rssi=${data.rssi}`, `snr=${data.snr}`, `freq_offset=${data.freq_offset}`,\n    `battery=${data.battery}`, `altitude=${data.altitude}`, `lat=${data.lat}`, `lon=${data.lon}`,\n    `digipeated=${digipeated}`, `distance_km=${distance_km}`,\n    `message=${ef(data.message)}`, `raw=${ef(data.raw)}`, `tocall=${ef(tocall)}`, `path=${ef(path)}`\n].join(\",\");\n\nmsg.payload = `aprs_syslog,${tags} ${fields} ${Date.now()}`;\nmsg.headers = {\n    \"Authorization\": \"Token \" + INFLUX_TOKEN,\n    \"Content-Type\": \"text/plain; charset=utf-8\"\n};\n// the http request node URL uses {{ORG}}/{{BUCKET}} via these (see the flow / guide):\nmsg.org = ORG;\nmsg.bucket = BUCKET;\nreturn msg;\n",
    "outputs": 1,
    "timeout": 0,
    "noerr": 0,
    "initialize": "",
    "finalize": "",
    "libs": [],
    "x": 410,
    "y": 140,
    "wires": [
      [
        "influx_write",
        "dbg"
      ]
    ]
  },
  {
    "id": "influx_write",
    "type": "http request",
    "z": "aprs_tab",
    "name": "InfluxDB write",
    "method": "POST",
    "ret": "txt",
    "paytoqs": "ignore",
    "url": "http://INFLUX_HOST:8086/api/v2/write?org={{org}}&bucket={{bucket}}&precision=ms",
    "tls": "",
    "persist": false,
    "proxy": "",
    "insecureHTTPParser": false,
    "authType": "",
    "senderr": false,
    "headers": [],
    "x": 660,
    "y": 120,
    "wires": [
      [
        "dbg_write"
      ]
    ]
  },
  {
    "id": "dbg",
    "type": "debug",
    "z": "aprs_tab",
    "name": "line protocol",
    "active": false,
    "tosidebar": true,
    "console": false,
    "tostatus": false,
    "complete": "payload",
    "targetType": "msg",
    "statusVal": "",
    "statusType": "auto",
    "x": 660,
    "y": 180,
    "wires": []
  },
  {
    "id": "dbg_write",
    "type": "debug",
    "z": "aprs_tab",
    "name": "write result",
    "active": true,
    "tosidebar": true,
    "console": false,
    "tostatus": false,
    "complete": "payload",
    "targetType": "msg",
    "statusVal": "",
    "statusType": "auto",
    "x": 890,
    "y": 120,
    "wires": []
  }
]