From a19f9b34691abe26b6bd1e03c70e569c137e5c8e Mon Sep 17 00:00:00 2001 From: romartak Date: Thu, 22 Oct 2026 03:01:59 +0000 Subject: [PATCH 1/4] Release 3.12.0: AIS ship map with its own NMEA decoder, AIS-catcher / rtl_ais support and demo ships --- CHANGELOG.md | 14 ++ README.es.md | 1 + README.md | 1 + ROADMAP.md | 2 +- VERSION | 2 +- docs/user-guide.md | 13 +- files/sdrkit_decoders.py | 343 +++++++++++++++++++++++++++++++++- files/sdrkit_server.py | 22 ++- files/web/analyzer.js | 1 + files/web/app.css | 1 + files/web/app.js | 4 +- files/web/decode.js | 126 ++++++++++++- files/web/help.js | 5 +- files/web/index.html | 31 +++ files/web/lang-en-analyzer.js | 1 + files/web/lang-en-app.js | 2 + files/web/lang-en-help.js | 3 + files/web/lang-en-html.js | 8 + files/web/lang-en-radio.js | 22 +++ files/web/lang-en-server.js | 4 + files/web/roadmap.js | 2 +- install.sh | 2 +- tests/test_ais.py | 89 +++++++++ tests/ui_smoke.mjs | 10 + 24 files changed, 686 insertions(+), 23 deletions(-) create mode 100644 tests/test_ais.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 838a9fc..c580fae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ Update an installed kit with `sudo sdrkit update`. ## [Unreleased] +## [3.12.0] - 2026-10-22 + +### Added +- **Ship map (AIS)** (Decode → **🚢 Ships**): ships appear on an offline radar-style map around your location, like the aircraft + radar — name, MMSI, type (cargo, tanker, passenger, fishing, sailing, tug…), size, speed, course, destination, distance and + bearing, with trails and colors by type. Ships, aids to navigation and base stations are decoded by SDR-Kit's own AIS + decoder (message types 1–5, 18, 19, 21, 24; checksums verified; multi-part messages joined) fed by **AIS-catcher** or + **rtl_ais** (`sudo apt install rtl-ais`; the installer adds it when the repository has it). Distress beacons + (AIS-SART, man overboard, EPIRB) are flagged red. New mode `ais` (the dongle works on 162 MHz while it is on), API + `GET/POST /api/ais`. Demo mode simulates eight ships and a distress beacon. Covered by `tests/test_ais.py` and the + browser smoke test. *Not verified with real hardware yet: the decoder is tested against real AIS sentences and the + demo, but the AIS-catcher / rtl_ais launch options have not been run on a Pi.* +- The decoder catalog now lists AIS as a panel decoder (before it only pointed at OpenWebRX+). + ## [3.11.0] - 2026-10-22 ### Added diff --git a/README.es.md b/README.es.md index 04358fa..32cf9ca 100644 --- a/README.es.md +++ b/README.es.md @@ -84,6 +84,7 @@ lugar entras con [Tailscale](https://tailscale.com). - **Buscapersonas** POCSAG / FLEX, **APRS** con posiciones, alertas **EAS** (multimon-ng) - **Sensores de 433/868/915 MHz**: estaciones meteorológicas, sensores de llantas, timbres… (rtl_433) - **Radar de aviones sin internet** en 1090 MHz (dump1090) +- **Mapa de barcos sin internet** (AIS, 162 MHz) con nombre, tipo, velocidad y balizas de socorro - Más de 40 modos en el receptor avanzado (OpenWebRX+): FT8, WSPR, SSTV, fax HF, ACARS… - Los canales de cada servicio listos: PMR446, FRS/GMRS, marina VHF, aviación, CB, radioaficionados… diff --git a/README.md b/README.md index eab7023..023162a 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ internet required. At home it joins your WiFi; in the field it creates its own h - **Pagers** POCSAG / FLEX, **APRS** with positions, **EAS** alerts (multimon-ng) - **433/868/915 MHz sensors**: weather stations, tyre sensors, doorbells… (rtl_433) - **Offline aircraft radar** on 1090 MHz (dump1090) +- **Offline ship map** (AIS, 162 MHz) with name, type, speed and distress beacons - 40+ more modes in the advanced receiver (OpenWebRX+): FT8, WSPR, SSTV, HF fax, ACARS… - Every service's channel plan built in: PMR446, FRS/GMRS, marine VHF, airband, CB, ham bands… diff --git a/ROADMAP.md b/ROADMAP.md index f4c0aa6..5958c61 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ Estado: ☐ pendiente · ◐ hecha en parte (ver nota) · ✅ terminada (fecha | 1 | ✅ 2026-10-18 · 3.7.0 | **Repetición instantánea** «⏪ ¿qué dijo?» | El radio guarda siempre los últimos 30 s en memoria y un botón los reproduce o los guarda como grabación. | | 2 | ✅ 2026-10-22 · 3.11.0 | **Panel del negocio** | Gráficos de actividad del radio por hora y por día, canal más usado, transmisión más larga; a partir del registro de actividad. | | 3 | ☐ | **Alertas de aviones** | Aviso (panel + ntfy) por códigos 7700/7600/7500, aviones militares o helicópteros cerca, con filtros configurables. | -| 4 | ☐ | **Mapa de barcos (AIS)** | Modo AIS con mapa sin internet como el radar de aviones (decodificador opcional, p. ej. AIS-catcher); barcos simulados en demo. | +| 4 | ✅ 2026-10-22 · 3.12.0 | **Mapa de barcos (AIS)** | Modo AIS con mapa sin internet como el radar de aviones (decodificador opcional, p. ej. AIS-catcher); barcos simulados en demo. | | 5 | ☐ | **Home Assistant (MQTT)** | Sensores de 433 MHz, aviones y actividad del radio publicados por MQTT con *discovery* automático; apagado por defecto. | | 6 | ☐ | **App instalable (PWA)** | El panel se instala en el celular, funciona sin conexión para lo estático y muestra avisos nativos cuando está abierto. | | 7 | ☐ | **Resumen diario por Telegram** | Bot opcional (token en `sdrkit.conf`) que manda cada día el resumen de actividad y, si se quiere, grabaciones marcadas. | diff --git a/VERSION b/VERSION index afad818..92536a9 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.11.0 +3.12.0 diff --git a/docs/user-guide.md b/docs/user-guide.md index 66ecd04..8f3c819 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -334,9 +334,20 @@ The **Received** list is searchable (text, RIC, callsign) and filterable by type Antenna: a vertical quarter-wave of **6.5–6.9 cm**, as high as possible. Typical range is 100–250 km with a clear view. See [Antennas](antennas.md). +### Ships (AIS 162 MHz) + +**Ship map · no internet**, with the same look as the aircraft radar. + +- **▶ Start map**; ships appear within 1–5 minutes (each one transmits every few seconds to a few minutes, depending on its speed). **Range**: 10, 25, 50, 100 or 200 km. +- The map is centred on your location, the same one you save under **Aircraft → Your location**. Ships are colored by type (green cargo, orange tanker, light blue passenger, violet fishing, yellow tug) and leave a trail. Squares are aids to navigation and base stations. +- The table shows name, MMSI, type, size, speed, course, destination (or navigation status) and distance. **Distress beacons are drawn in red**: AIS-SART, man-overboard and EPIRB (MMSI 970/972/974…) or navigation status 14. +- Needs a decoder: `sudo apt install rtl-ais` or [AIS-catcher](https://github.com/jvde-github/AIS-catcher). The installer adds them when your repository has them. SDR-Kit decodes the NMEA sentences itself. +- The dongle works on 162 MHz while the map is on, so the analyzer and the radio pause (as with the aircraft radar). AIS is a public, short-range signal: only ships that transmit appear, typically 20–60 km from the coast with a vertical antenna of about 46 cm. +- *Not verified with real hardware yet:* tested against real AIS sentences and in demo mode; the options passed to `AIS-catcher` / `rtl_ais` still need a check on a Pi. + ### All modes -**Everything this device can decode** lists every decoder with its installation status: those that run **in this panel** (multimon-ng modes, CTCSS/DCS/DTMF, digital voice, the digital-signal identifier, rtl_433, dump1090) and those available **in the advanced receiver** (OpenWebRX+): RDS, AIS, ACARS, VDL2, HFDL, UAT 978, radiosondes, LoRa/Meshtastic, FT8 family (with `INSTALL_WSJTX`), JS8, RTTY, PSK31, NAVTEX, SSTV, FAX, DRM, DAB+, HD Radio, TETRA, FreeDV, M17 and AMBE digital voice (with a licensed dongle). Tap an entry to jump to it. The same list is available over SSH with `sdrkit decoders`. +**Everything this device can decode** lists every decoder with its installation status: those that run **in this panel** (multimon-ng modes, CTCSS/DCS/DTMF, digital voice, the digital-signal identifier, rtl_433, dump1090, AIS) and those available **in the advanced receiver** (OpenWebRX+): RDS, ACARS, VDL2, HFDL, UAT 978, radiosondes, LoRa/Meshtastic, FT8 family (with `INSTALL_WSJTX`), JS8, RTTY, PSK31, NAVTEX, SSTV, FAX, DRM, DAB+, HD Radio, TETRA, FreeDV, M17 and AMBE digital voice (with a licensed dongle). Tap an entry to jump to it. The same list is available over SSH with `sdrkit decoders`. ## Recordings diff --git a/files/sdrkit_decoders.py b/files/sdrkit_decoders.py index 1843daf..c5405cd 100644 --- a/files/sdrkit_decoders.py +++ b/files/sdrkit_decoders.py @@ -3,6 +3,7 @@ SDR-Kit · Decodificadores - SensorsManager: rtl_433 (estaciones meteorológicas, sensores de llantas TPMS, timbres, controles…) - AdsbManager: dump1090-fa (aviones: posición, altura, velocidad) con radar sin internet + - AisManager: AIS-catcher / rtl_ais (barcos AIS) con decodificador NMEA propio y mapa sin internet - DataDecoder: multimon-ng sobre el audio del radio (POCSAG/FLEX, APRS, EAS, Morse, selcall…) - status(): qué decodificadores están instalados (propios y de OpenWebRX+) """ @@ -561,6 +562,346 @@ def _dist_bearing(lat1, lon1, lat2, lon2): return round(dist, 1), round((math.degrees(math.atan2(y, x)) + 360) % 360) +# ---------------------------------------------------------------------------- +# Barcos AIS (161.975 / 162.025 MHz): decodificador NMEA propio + AIS-catcher / rtl_ais +# ---------------------------------------------------------------------------- + +def nmea_checksum(body): + c = 0 + for ch in body: + c ^= ord(ch) + return "%02X" % c + + +def _ais_bits(payload, pad=0): + bits = [] + for ch in payload: + v = ord(ch) - 48 + if v > 40: + v -= 8 + if not 0 <= v < 64: + raise ValueError("carácter AIS inválido") + bits.extend((v >> i) & 1 for i in range(5, -1, -1)) + return bits[:len(bits) - pad] if pad else bits + + +def _u(bits, a, n): + v = 0 + for b in bits[a:a + n]: + v = (v << 1) | b + return v if len(bits) >= a + n else None + + +def _s(bits, a, n): + v = _u(bits, a, n) + if v is None: + return None + return v - (1 << n) if v & (1 << (n - 1)) else v + + +def _text(bits, a, n): + out = [] + for i in range(a, a + n, 6): + if i + 6 > len(bits): + break + v = _u(bits, i, 6) + out.append(chr(v + 64) if v < 32 else chr(v)) + return "".join(out).split("@")[0].strip() + + +def _lonlat(bits, lon_at, lon_n, lat_at, lat_n): + lon, lat = _s(bits, lon_at, lon_n), _s(bits, lat_at, lat_n) + if lon is None or lat is None or abs(lon) > 180 * 600000 or abs(lat) > 90 * 600000: + return None, None # 181° / 91° = «sin posición» + return round(lat / 600000.0, 5), round(lon / 600000.0, 5) + + +def ais_kind(code): + """Categoría para colorear el barco a partir del código «tipo de barco» de AIS.""" + c = int(code or 0) + if c == 30: + return "fishing" + if c in (31, 32, 52): + return "tug" + if c in (35, 55): + return "military" + if c == 36: + return "sailing" + if c == 37: + return "pleasure" + if 60 <= c <= 69: + return "passenger" + if 70 <= c <= 79: + return "cargo" + if 80 <= c <= 89: + return "tanker" + if c in (50, 51, 53, 54, 58) or 40 <= c <= 49: + return "service" + return "other" + + +class AisParser: + """Frases NMEA !AIVDM → barcos. Verifica el checksum y junta las frases de varios fragmentos.""" + + def __init__(self, keep=2000): + self.lock = threading.Lock() + self.ships = {} + self.frag = {} + self.keep = keep + self.count = 0 + + def feed(self, line, now=None): + """Procesa una línea; devuelve el MMSI actualizado o None si no era AIS válido.""" + line = line.strip() + if not (line.startswith("!AIVDM") or line.startswith("!AIVDO")): + return None + body, _, chk = line[1:].partition("*") + if chk[:2].upper() != nmea_checksum(body): + return None + f = body.split(",") + try: + total, num, seq, payload, pad = int(f[1]), int(f[2]), f[3], f[5], int(f[6] or 0) + except (IndexError, ValueError): + return None + if total > 1: + key = (seq, f[4]) + parts = self.frag.setdefault(key, {}) + parts[num] = (payload, pad) + if len(parts) < total: + if len(self.frag) > 20: + self.frag.pop(next(iter(self.frag))) + return None + del self.frag[key] + payload, pad = "".join(parts[i][0] for i in sorted(parts)), parts[max(parts)][1] + try: + bits = _ais_bits(payload, pad) + except ValueError: + return None + return self._message(bits, time.time() if now is None else now) + + def _message(self, b, now): + t, mmsi = _u(b, 0, 6), _u(b, 8, 30) + if t is None or not mmsi: + return None + upd = {} + if t in (1, 2, 3) and len(b) >= 137: + sog, cog, hd = _u(b, 50, 10), _u(b, 116, 12), _u(b, 128, 9) + lat, lon = _lonlat(b, 61, 28, 89, 27) + upd = {"cls": "A", "status": _u(b, 38, 4), "lat": lat, "lon": lon, + "sog": None if sog == 1023 else sog / 10.0, "cog": None if cog >= 3600 else cog / 10.0, + "heading": None if hd == 511 else hd} + elif t in (18, 19) and len(b) >= 133: + sog, cog, hd = _u(b, 46, 10), _u(b, 112, 12), _u(b, 124, 9) + lat, lon = _lonlat(b, 57, 28, 85, 27) + upd = {"cls": "B", "lat": lat, "lon": lon, "sog": None if sog == 1023 else sog / 10.0, + "cog": None if cog >= 3600 else cog / 10.0, "heading": None if hd == 511 else hd} + if t == 19 and len(b) >= 271: + upd.update(name=_text(b, 143, 120), shiptype=_u(b, 263, 8)) + elif t == 5 and len(b) >= 422: + upd = {"cls": "A", "imo": _u(b, 40, 30) or None, "callsign": _text(b, 70, 42), "name": _text(b, 112, 120), + "shiptype": _u(b, 232, 8), "length": (_u(b, 240, 9) or 0) + (_u(b, 249, 9) or 0) or None, + "beam": (_u(b, 258, 6) or 0) + (_u(b, 264, 6) or 0) or None, + "draught": (_u(b, 294, 8) or 0) / 10.0 or None, "dest": _text(b, 302, 120)} + elif t == 24 and len(b) >= 160: + if _u(b, 38, 2) == 0: + upd = {"cls": "B", "name": _text(b, 40, 120)} + elif len(b) >= 162: + upd = {"cls": "B", "shiptype": _u(b, 40, 8), "callsign": _text(b, 90, 42), + "length": (_u(b, 132, 9) or 0) + (_u(b, 141, 9) or 0) or None, + "beam": (_u(b, 150, 6) or 0) + (_u(b, 156, 6) or 0) or None} + elif t == 21 and len(b) >= 219: + lat, lon = _lonlat(b, 164, 28, 192, 27) + upd = {"cls": "AtoN", "name": _text(b, 43, 120), "lat": lat, "lon": lon, "shiptype": 0} + elif t == 4 and len(b) >= 134: + lat, lon = _lonlat(b, 79, 28, 107, 27) + upd = {"cls": "base", "lat": lat, "lon": lon} + else: + return None + with self.lock: + s = self.ships.get(mmsi) + if s is None: + if len(self.ships) >= self.keep: + self.ships.pop(min(self.ships, key=lambda k: self.ships[k]["last"])) + s = self.ships[mmsi] = {"mmsi": mmsi, "count": 0, "first": now} + for k, v in upd.items(): + if v is not None and v != "": + s[k] = v + if "lat" in upd and upd["lat"] is None: + s.pop("lat", None), s.pop("lon", None) + s.update(last=now, count=s["count"] + 1) + s["kind"] = ais_kind(s.get("shiptype")) + self.count += 1 + return mmsi + + def snapshot(self, max_age=1800.0, now=None): + now = time.time() if now is None else now + with self.lock: + out = [dict(s, seen=round(now - s["last"], 1)) for s in self.ships.values() if now - s["last"] <= max_age] + for k in [k for k, s in self.ships.items() if now - s["last"] > 6 * max_age]: + del self.ships[k] + out.sort(key=lambda s: (s.get("lat") is None, s["seen"])) + return out + + +def ais_emergency(s): + """Señal de socorro: estado «AIS-SART activo» o MMSI de baliza (970 SART, 972 hombre al agua, 974 EPIRB).""" + return s.get("status") == 14 or str(s.get("mmsi", "")).startswith(("970", "972", "974")) + + +# --- Codificador (lo usa el modo demo y las pruebas para generar frases reales, bit a bit) + +def _pack(fields): + bits = [] + for v, n in fields: + v = int(v) & ((1 << n) - 1) + bits.extend((v >> i) & 1 for i in range(n - 1, -1, -1)) + return bits + + +def _ptext(s, n): + s = (s.upper() + "@" * n)[:n] + return [(ord(c) - 64) if 64 <= ord(c) < 96 else ord(c) for c in s] + + +def _armor(bits): + pad = (-len(bits)) % 6 + bits = bits + [0] * pad + out = "" + for i in range(0, len(bits), 6): + v = int("".join(map(str, bits[i:i + 6])), 2) + out += chr(v + 48 if v < 40 else v + 56) + return out, pad + + +def _sentences(bits, channel="A", seq="1"): + payload, pad = _armor(bits) + chunks = [payload[i:i + 60] for i in range(0, len(payload), 60)] or [""] + out = [] + for i, c in enumerate(chunks, 1): + body = "AIVDM,%d,%d,%s,%s,%s,%d" % (len(chunks), i, seq if len(chunks) > 1 else "", channel, c, pad if i == len(chunks) else 0) + out.append("!%s*%s" % (body, nmea_checksum(body))) + return out + + +def ais_encode_position(mmsi, lat, lon, sog=0.0, cog=0.0, heading=511, status=0, msg=1): + f = [(msg, 6), (0, 2), (mmsi, 30), (status, 4), (128, 8), (round(sog * 10), 10), (0, 1), + (round(lon * 600000), 28), (round(lat * 600000), 27), (round(cog * 10), 12), (heading, 9), (60, 6), (0, 3), (0, 1), (0, 19)] + return _sentences(_pack(f)) + + +def ais_encode_class_b(mmsi, lat, lon, sog=0.0, cog=0.0, heading=511): + f = [(18, 6), (0, 2), (mmsi, 30), (0, 8), (round(sog * 10), 10), (0, 1), (round(lon * 600000), 28), (round(lat * 600000), 27), + (round(cog * 10), 12), (heading, 9), (60, 6), (0, 14)] + return _sentences(_pack(f)) + + +def ais_encode_static(mmsi, name, shiptype, callsign="", dest="", length=0, beam=0, draught=0.0, imo=0): + bow, port = int(length * 0.8), int(beam / 2) + f = [(5, 6), (0, 2), (mmsi, 30), (0, 2), (imo, 30)] + bits = _pack(f) + for s, n in ((callsign, 7), (name, 20)): + bits += _pack([(c, 6) for c in _ptext(s, n)]) + bits += _pack([(shiptype, 8), (bow, 9), (int(length) - bow, 9), (port, 6), (int(beam) - port, 6), (1, 4), (0, 20), (round(draught * 10), 8)]) + bits += _pack([(c, 6) for c in _ptext(dest, 20)]) + bits += _pack([(0, 1), (0, 1)]) + return _sentences(bits) + + +class AisManager(_ProcEngine): + name = "barcos" + + def __init__(self, conf, state, folder, demo=False, location=None): + self.parser = AisParser() + self.get_location = location or (lambda: None) + self.seen_today = {} + p = {"gain": "auto"} + p.update(state.get("ais") or {}) + self.params = p + self.tail = collections.deque(maxlen=6) + super().__init__(conf, state, folder, demo) + + def set_params(self, p): + g = p.get("gain", self.params.get("gain")) + self.params = {"gain": "auto" if str(g).lower() == "auto" else min(max(float(g), 0.0), 50.0)} + self.state.update(ais=self.params) + self.restart() + return self.params + + def snapshot(self): + ships = self.parser.snapshot() + loc = self.get_location() + if not loc: + pts = [(s["lat"], s["lon"]) for s in ships if s.get("lat") is not None] + if pts: + loc = {"lat": sum(p[0] for p in pts) / len(pts), "lon": sum(p[1] for p in pts) / len(pts), "source": "estimada"} + today = time.strftime("%Y-%m-%d") + for s in ships: + self.seen_today[s["mmsi"]] = today + s["emerg"] = ais_emergency(s) + if loc and s.get("lat") is not None: + s["dist_km"], s["bearing"] = _dist_bearing(loc["lat"], loc["lon"], s["lat"], s["lon"]) + return {"ships": ships, "messages": self.parser.count, "now": time.time(), "location": loc, "info": self.info, + "params": self.params, "today": len([1 for v in self.seen_today.values() if v == today])} + + def _run(self, ver): + exe = which("AIS-catcher", "ais-catcher", "rtl_ais") + if not exe: + raise RuntimeError("Falta un decodificador AIS (sudo apt install rtl-ais, o instala AIS-catcher).") + idx = str(int(float(self.conf.get("RTL_DEVICE_INDEX", 0) or 0))) + gain = self.params.get("gain") + if os.path.basename(exe).lower() == "rtl_ais": + cmd = [exe, "-n", "-d", idx] + (["-g", str(gain)] if gain not in (None, "auto") else []) + else: + cmd = [exe, "-d", idx, "-o", "1"] + (["-gr", "TUNER", str(gain), "RTLAGC", "off"] if gain not in (None, "auto") else []) + self.tail.clear() + # rtl_ais escribe las frases por stderr y AIS-catcher por stdout: se leen las dos juntas + self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace", bufsize=1) + for line in self.proc.stdout: + if not self._alive(ver): + break + if self.parser.feed(line) is None and line.strip(): + self.tail.append(line.strip()) + if self._alive(ver): + raise RuntimeError("El decodificador AIS se detuvo. " + (" ".join(list(self.tail)[-2:])[-300:] or "¿El dongle está conectado?")) + + def _demo_loop(self, ver): + loc = self.get_location() or {} + lat0, lon0 = loc.get("lat", 18.44), loc.get("lon", -69.90) + rng = np.random.default_rng(21) + kinds = [(70, "CARIBBEAN TRADER", 190, 28, "SANTO DOMINGO"), (80, "PETROMAR UNO", 228, 36, "HAINA"), + (60, "ISLA CATALINA", 120, 22, "LA ROMANA"), (30, "DON PEPE", 24, 7, ""), (36, "VIENTO SUR", 14, 4, "BOCA CHICA"), + (52, "REMOLCADOR 7", 32, 10, "HAINA"), (37, "SEA LION", 18, 5, ""), (70, "ATLANTIC STAR", 240, 32, "MIAMI")] + ships = [] + for i, (st, name, length, beam, dest) in enumerate(kinds): + ships.append({"mmsi": 366000000 + 1137 * (i + 1), "name": name, "st": st, "length": length, "beam": beam, "dest": dest, + "lat": lat0 - float(rng.uniform(0.02, 0.35)), "lon": lon0 + float(rng.uniform(-0.45, 0.45)), + "sog": 0.0 if st == 30 and i == 3 else float(rng.uniform(3, 17)), "cog": float(rng.uniform(0, 360)), + "cls": "B" if st in (30, 36, 37) else "A", "tick": 0}) + sos = {"mmsi": 970123456, "lat": lat0 - 0.18, "lon": lon0 + 0.1} + tick = 0 + while self._alive(ver): + for s in ships: + d = s["sog"] * 0.514 * 2.0 / 111000.0 + s["lat"] += d * math.cos(math.radians(s["cog"])) + s["lon"] += d * math.sin(math.radians(s["cog"])) / max(0.2, math.cos(math.radians(s["lat"]))) + s["cog"] = (s["cog"] + float(rng.normal(0, 1.5))) % 360 + lines = [] + if tick % 15 == 0: + lines += ais_encode_static(s["mmsi"], s["name"], s["st"], "HI%04d" % (s["mmsi"] % 10000), s["dest"], s["length"], s["beam"], 5.5) + if s["cls"] == "A": + lines += ais_encode_position(s["mmsi"], s["lat"], s["lon"], s["sog"], s["cog"], int(s["cog"]), 0 if s["sog"] > 0.5 else 5) + else: + lines += ais_encode_class_b(s["mmsi"], s["lat"], s["lon"], s["sog"], s["cog"], int(s["cog"])) + for ln in lines: + self.parser.feed(ln) + if tick == 6: # una baliza de socorro de ejemplo (AIS-SART) + for ln in ais_encode_position(sos["mmsi"], sos["lat"], sos["lon"], 0.0, 0.0, 511, 14): + self.parser.feed(ln) + tick += 1 + with self.cv: + self.cv.wait(2.0) + + # ---------------------------------------------------------------------------- # Qué está instalado # ---------------------------------------------------------------------------- @@ -578,6 +919,7 @@ def _dist_bearing(lat1, lon1, lat2, lon2): ("digid", "Identificador automático de señales digitales", "panel", [], "Radio: avisa qué tipo de señal es"), ("sensors", "Sensores ISM (rtl_433): clima, llantas TPMS, timbres…", "panel", ["rtl_433"], "Decodificar → Sensores"), ("adsb", "Aviones ADS-B (radar sin internet)", "panel", ["dump1090-fa|dump1090"], "Decodificar → Aviones"), + ("ais", "Barcos AIS (mapa sin internet)", "panel", ["AIS-catcher|ais-catcher|rtl_ais"], "Decodificar → Barcos"), ("dmr", "Voz digital DMR · D-Star · YSF · NXDN · P25", "owrx", ["codecserver"], "requiere códec AMBE (llave USB AMBE3000)"), ("m17", "Voz digital M17", "owrx", ["m17-demod"], "códec abierto"), ("freedv", "Voz digital FreeDV (HF)", "owrx", ["freedv_rx"], ""), @@ -586,7 +928,6 @@ def _dist_bearing(lat1, lon1, lat2, lon2): ("hdradio", "HD Radio (EE. UU.)", "owrx", ["nrsc5"], ""), ("drm", "DRM (onda corta digital)", "owrx", ["dream"], ""), ("rds", "RDS de emisoras FM", "owrx", ["redsea"], ""), - ("ais", "Barcos AIS", "owrx", [], "integrado en OpenWebRX+"), ("acars", "Aviación ACARS / VDL2 / HFDL", "owrx", ["acarsdec", "dumpvdl2", "dumphfdl"], ""), ("uat", "UAT 978 MHz (EE. UU.)", "owrx", ["dump978-fa"], ""), ("sonde", "Radiosondas meteorológicas", "owrx", ["rs41mod|dfm09mod|rs41ecc"], ""), diff --git a/files/sdrkit_server.py b/files/sdrkit_server.py index ba28ebe..497aff3 100755 --- a/files/sdrkit_server.py +++ b/files/sdrkit_server.py @@ -55,7 +55,7 @@ def _read_version(): return v except OSError: pass - return "3.11.0" + return "3.12.0" VERSION = _read_version() @@ -1546,12 +1546,13 @@ def update(self, **kw): class ModeManager: """Un solo dueño del dongle a la vez: analizador, radio (propio) o receptor OpenWebRX+.""" - def __init__(self, conf, sweep, receiver, state, hub, radio=None, sensors=None, adsb=None): + def __init__(self, conf, sweep, receiver, state, hub, radio=None, sensors=None, adsb=None, ais=None): self.conf = conf self.sweep = sweep self.radio = radio self.sensors = sensors self.adsb = adsb + self.ais = ais self.rx = receiver self.state = state self.hub = hub @@ -1563,7 +1564,7 @@ def __init__(self, conf, sweep, receiver, state, hub, radio=None, sensors=None, def boot(self): dm = str(self.conf.get("DEFAULT_MODE", "last")).lower() mode = self.state.get("mode", "sweep") if dm == "last" else dm - if mode not in ("sweep", "radio", "sensors", "adsb", "receiver", "idle"): + if mode not in ("sweep", "radio", "sensors", "adsb", "ais", "receiver", "idle"): mode = "sweep" self.switch(mode, background=True) @@ -1574,7 +1575,7 @@ def switch(self, mode, freq=None, mod=None, background=True): return self._switch(mode, freq, mod) def _switch(self, mode, freq, mod): - engines = {"sweep": self.sweep, "radio": self.radio, "sensors": self.sensors, "adsb": self.adsb} + engines = {"sweep": self.sweep, "radio": self.radio, "sensors": self.sensors, "adsb": self.adsb, "ais": self.ais} with self.lock: self.busy = True try: @@ -1900,8 +1901,9 @@ def __init__(self, conf, demo=False): self.radio = radio_mod.RadioManager(conf, self.state, open_dev, SdrError, STATE_DIR, demo) self.sensors = dec_mod.SensorsManager(conf, self.state, STATE_DIR, demo) self.adsb = dec_mod.AdsbManager(conf, self.state, STATE_DIR, demo) + self.ais = dec_mod.AisManager(conf, self.state, STATE_DIR, demo, lambda: self.adsb.location) self.modes = ModeManager(conf, self.sweep, self.receiver, self.state, self.hub, self.radio, - self.sensors, self.adsb) + self.sensors, self.adsb, self.ais) self.sys = SysInfo() self.wifi = WiFi(conf, demo) self.password = conf.get("ADMIN_PASSWORD", "") @@ -2072,6 +2074,7 @@ def status(self): "monitoring": self.radio.monitoring(), "params": self.radio.params}, "sensors": {"running": self.sensors.info.get("running"), "error": self.sensors.info.get("error")}, "adsb": {"running": self.adsb.info.get("running"), "error": self.adsb.info.get("error")}, + "ais": {"running": self.ais.info.get("running"), "error": self.ais.info.get("error")}, "monitor": self.sweep.monitor, "update": dict(self.upd, notify=self.update_notify()), } @@ -2350,6 +2353,8 @@ def do_GET(self): return self._send(200, app.sensors.snapshot()) if path == "/api/adsb": return self._send(200, app.adsb.snapshot()) + if path == "/api/ais": + return self._send(200, app.ais.snapshot()) if path == "/api/decoders": st = dec_mod.status() st["kinds"] = {k: v[0] for k, v in dec_mod.MULTIMON_KINDS.items()} @@ -2417,7 +2422,7 @@ def do_POST(self): try: if path == "/api/mode": mode = body.get("mode") - if mode not in ("sweep", "radio", "sensors", "adsb", "receiver", "idle"): + if mode not in ("sweep", "radio", "sensors", "adsb", "ais", "receiver", "idle"): raise ValueError("Modo inválido.") freq = cfloat(body.get("freq"), 0.0) or None if mode == "receiver" and not (app.receiver.installed() or app.demo): @@ -2521,6 +2526,11 @@ def do_POST(self): if body.get("activate") and app.modes.mode != "adsb" and not app.modes.busy: app.modes.switch("adsb") return self._send(200, {"ok": True, "params": prm}) + if path == "/api/ais": + prm = app.ais.set_params(body) if "gain" in body else app.ais.params + if body.get("activate") and app.modes.mode != "ais" and not app.modes.busy: + app.modes.switch("ais") + return self._send(200, {"ok": True, "params": prm}) if path == "/api/location": return self._send(200, {"ok": True, "location": app.adsb.set_location(body.get("lat"), body.get("lon"))}) if path == "/api/wifi/scan": diff --git a/files/web/analyzer.js b/files/web/analyzer.js index 4295da2..6d4cfbb 100644 --- a/files/web/analyzer.js +++ b/files/web/analyzer.js @@ -953,6 +953,7 @@ const An = (() => { receiver: () => _t("El dongle lo está usando el receptor avanzado (OpenWebRX+). Puedes cambiar al analizador."), sensors: () => _t("El dongle lo está usando el decodificador de sensores (rtl_433). Puedes cambiar al analizador."), adsb: () => _t("El dongle lo está usando el radar de aviones (dump1090). Puedes cambiar al analizador."), + ais: () => _t("El dongle lo está usando el mapa de barcos (AIS). Puedes cambiar al analizador."), }[s.mode]; let msg = busy ? busy() : _t("El SDR está en reposo."); if (s.radio && s.radio.monitoring && s.mode === "radio") msg += " " + _t("Ojo: el radio está grabando, escaneando o decodificando; se pausará."); diff --git a/files/web/app.css b/files/web/app.css index 772d5bc..1fcbe16 100644 --- a/files/web/app.css +++ b/files/web/app.css @@ -267,6 +267,7 @@ pre.logs{white-space:pre-wrap;word-break:break-all;font:11px/1.45 var(--mono);ba .fchips{display:flex;flex-wrap:wrap;gap:5px;margin-top:8px} .fchip{background:var(--bg2);border:1px solid var(--line2);border-radius:8px;padding:3px 8px;font-size:13px;white-space:nowrap} .fchip.bad{border-color:#7a3333;color:#ffb3b3} +#aisMap{display:block;width:100%;aspect-ratio:1;max-height:70vh;background:#04101a;border-radius:10px;border:1px solid #16384f} #radar{display:block;width:100%;aspect-ratio:1;max-height:70vh;background:#04120a;border-radius:10px;border:1px solid #164a2c} .tbl tr[data-hex]{cursor:pointer} .biz-tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px} diff --git a/files/web/app.js b/files/web/app.js index a410ed3..31bf3e8 100644 --- a/files/web/app.js +++ b/files/web/app.js @@ -122,7 +122,7 @@ const SDR = { renderChips() { const s = SDR.status; if (!s) return; const cm = $("#chipMode"); - const names = {sweep: _t("Analizador"), radio: _t("Radio"), sensors: _t("Sensores"), adsb: _t("Aviones"), receiver: _t("Receptor avanzado"), idle: _t("En reposo")}; + const names = {sweep: _t("Analizador"), radio: _t("Radio"), sensors: _t("Sensores"), adsb: _t("Aviones"), ais: _t("Barcos"), receiver: _t("Receptor avanzado"), idle: _t("En reposo")}; const label = s.switching ? _t("Cambiando…") : names[s.mode] || s.mode; cm.className = "chip on " + (s.switching ? "busy" : s.mode === "idle" ? "" : "ok"); cm.innerHTML = '' + label; @@ -176,7 +176,7 @@ const Owrx = { render() { const s = SDR.status; if (!s) return; const o = s.owrx; - const modeName = {sweep: _t("analizador"), radio: _t("radio"), sensors: _t("sensores"), adsb: _t("aviones")}[s.mode] || _t("reposo"); + const modeName = {sweep: _t("analizador"), radio: _t("radio"), sensors: _t("sensores"), adsb: _t("aviones"), ais: _t("barcos")}[s.mode] || _t("reposo"); $("#owrxState").textContent = !o.installed && !s.demo ? _t("OpenWebRX+ no está instalado.") : s.mode === "receiver" ? (o.ready ? _t("✅ Receptor encendido y listo.") : _t("⏳ Receptor iniciando…")) : _t("⏸ Apagado (el dongle está en modo {m}).", {m: modeName}); diff --git a/files/web/decode.js b/files/web/decode.js index 558f3cb..9d83e1c 100644 --- a/files/web/decode.js +++ b/files/web/decode.js @@ -1,7 +1,8 @@ /* SDR-Kit · Decodificar: mensajes (multimon-ng), sensores (rtl_433), aviones (dump1090) y catálogo */ "use strict"; const Dec = (() => { - const D = {sub: "msg", timer: null, slow: null, decoded: [], sensors: null, adsb: null, catalog: null, + const D = {sub: "msg", timer: null, slow: null, decoded: [], sensors: null, adsb: null, ais: null, catalog: null, + aisTrails: {}, aisSel: null, trails: {}, sel: null, loc: null, aprs: []}; const KINDS = {pocsag: "POCSAG", flex: "FLEX", aprs: "APRS", eas: "EAS", selcall: "Selcall", cw: "Morse", fms: "FMS"}; // [nombre, MHz, modo, decodificadores] @@ -21,7 +22,7 @@ const Dec = (() => { const OWRX_FREQ = {ais: 161.975e6, acars: 131.55e6, uat: 978e6, sonde: 403e6, lora: 433.775e6, rds: 98.1e6, ft8: 14.074e6, js8: 7.078e6, wxsat: 137.1e6, freedv: 14.236e6}; const PANEL_GO = {pocsag: ["dec", "msg"], aprs: ["dec", "msg"], eas: ["dec", "msg"], selcall: ["dec", "msg"], cw: ["dec", "msg"], - sensors: ["dec", "ism"], adsb: ["dec", "air"], ctcss: ["radio"], dsd: ["radio"], digid: ["radio"]}; + sensors: ["dec", "ism"], adsb: ["dec", "air"], ais: ["dec", "ais"], ctcss: ["radio"], dsd: ["radio"], digid: ["radio"]}; const GAINS = [0, 0.9, 1.4, 2.7, 3.7, 7.7, 8.7, 12.5, 14.4, 15.7, 16.6, 19.7, 20.7, 22.9, 25.4, 28.0, 29.7, 32.8, 33.8, 36.4, 37.2, 38.6, 40.2, 42.1, 43.4, 43.9, 44.5, 48.0, 49.6]; const ago = (t) => { const s = Math.max(0, Math.round(Date.now() / 1000 - t)); return s < 60 ? _t("hace {n} s", {n: s}) : s < 3600 ? _t("hace {n} min", {n: Math.round(s / 60)}) : _t("hace {n} h", {n: (s / 3600).toFixed(1)}); }; @@ -41,6 +42,7 @@ const Dec = (() => { if (s === "msg") { loadDecoded(); D.timer = setInterval(loadDecoded, 2000); } if (s === "ism") { loadSensors(); D.timer = setInterval(loadSensors, 2000); } if (s === "air") { loadAdsb(); loadAprs(); D.timer = setInterval(loadAdsb, 1000); D.slow = setInterval(loadAprs, 5000); } + if (s === "ais") { loadAis(); D.timer = setInterval(loadAis, 2000); } if (s === "all") loadCatalog(); renderStates(); } @@ -55,6 +57,7 @@ const Dec = (() => { const pausa = {sweep: "Al pulsar ▶ el dongle pasa al radio (se pausa el analizador).", sensors: "Al pulsar ▶ el dongle pasa al radio (se pausa la búsqueda de sensores).", adsb: "Al pulsar ▶ el dongle pasa al radio (se pausa el radar de aviones).", + ais: "Al pulsar ▶ el dongle pasa al radio (se pausa el mapa de barcos).", receiver: "Al pulsar ▶ el dongle pasa al radio (se pausa el receptor avanzado)."}[s.mode] || "Al pulsar ▶ el dongle pasa al radio (se pausa lo demás)."; $("#dmState").innerHTML = s.switching ? SDR.esc(_t("Cambiando de modo…")) : on ? _t('● Decodificando {k} en {f} MHz', {k: SDR.esc(rp.data.map(k => KINDS[k] || k).join(", ")), f: (rp.freq / 1e6).toFixed(4)}) + @@ -70,6 +73,10 @@ const Dec = (() => { if (s.mode !== "adsb") $("#adState").textContent = _t("Radar apagado. Al encenderlo, el dongle se dedica sólo a 1090 MHz."); else if (ad.error) $("#adState").textContent = "⚠ " + _t(ad.error); $("#adStart").classList.toggle("on", s.mode === "adsb"); + const ai = s.ais || {}; + if (s.mode !== "ais") $("#aiState").textContent = _t("Mapa apagado. Al encenderlo, el dongle se dedica sólo a 162 MHz (AIS)."); + else if (ai.error) $("#aiState").textContent = "⚠ " + _t(ai.error); + $("#aiStart").classList.toggle("on", s.mode === "ais"); } // ------------------------------------------------------------------ mensajes @@ -208,13 +215,14 @@ const Dec = (() => { `${SDR.esc(a.squawk || "—")}${em ? "
" + em + "" : ""}`; }).join("") : `${SDR.esc(_t("Sin aviones todavía."))}`) + ""; } - function drawRadar() { - const c = $("#radar"); if (!c.clientWidth) return; + // fondo común de los dos radares (aviones y barcos): anillos de distancia, cruz y puntos cardinales + function radarBase(canvas, rangeSel, bg, grid, txt) { + const c = $(canvas); if (!c.clientWidth) return null; const dpr = Math.min(window.devicePixelRatio || 1, 2.5), W = Math.round(c.clientWidth * dpr), H = Math.round(c.clientHeight * dpr); if (c.width !== W || c.height !== H) { c.width = W; c.height = H; } - const ctx = c.getContext("2d"), R = Math.min(W, H) / 2 - 14 * dpr, cx = W / 2, cy = H / 2, range = +$("#adRange").value; - ctx.fillStyle = "#04120a"; ctx.fillRect(0, 0, W, H); - ctx.strokeStyle = "#164a2c"; ctx.fillStyle = "#3a8a5c"; ctx.lineWidth = 1; ctx.font = `${10 * dpr}px ui-monospace,monospace`; + const ctx = c.getContext("2d"), R = Math.min(W, H) / 2 - 14 * dpr, cx = W / 2, cy = H / 2, range = +$(rangeSel).value; + ctx.fillStyle = bg || "#04120a"; ctx.fillRect(0, 0, W, H); + ctx.strokeStyle = grid || "#164a2c"; ctx.fillStyle = txt || "#3a8a5c"; ctx.lineWidth = 1; ctx.font = `${10 * dpr}px ui-monospace,monospace`; for (let k = 1; k <= 4; k++) { ctx.beginPath(); ctx.arc(cx, cy, R * k / 4, 0, 2 * Math.PI); ctx.stroke(); ctx.textAlign = "left"; ctx.fillText((range * k / 4) + " km", cx + 3 * dpr, cy - R * k / 4 + 11 * dpr); @@ -223,6 +231,11 @@ const Dec = (() => { ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.font = `bold ${11 * dpr}px system-ui,sans-serif`; const [cN, cS, cE, cW] = _t("N S E O").split(" "); // iniciales de los puntos cardinales: norte, sur, este, oeste [[cN, 0, -1], [cS, 0, 1], [cE, 1, 0], [cW, -1, 0]].forEach(([t, x, y]) => ctx.fillText(t, cx + x * (R + 8 * dpr), cy + y * (R + 8 * dpr))); + return {ctx, dpr, R, cx, cy, range, W, H}; + } + function drawRadar() { + const B = radarBase("#radar", "#adRange"); if (!B) return; + const {ctx, dpr, R, cx, cy, range} = B; const ctr = center(); if (!ctr) { ctx.fillStyle = "#3a8a5c"; ctx.font = `${13 * dpr}px system-ui,sans-serif`; @@ -261,6 +274,89 @@ const Dec = (() => { ctx.globalAlpha = 1; } + // ------------------------------------------------------------------ barcos (AIS) + const SHIP_KIND = {cargo: ["Carguero", "#3ddc84"], tanker: ["Petrolero", "#ff9f43"], passenger: ["Pasajeros", "#3cc8f5"], fishing: ["Pesca", "#c58bff"], + sailing: ["Velero", "#e6edf3"], pleasure: ["Recreo", "#e6edf3"], tug: ["Remolcador", "#ffd400"], military: ["Militar", "#ff7ab8"], + service: ["Servicio", "#9bb0c4"], other: ["Barco", "#9bb0c4"]}; + const NAV_STATUS = ["navegando con motor", "fondeado", "sin gobierno", "maniobra restringida", "calado limitado", "amarrado", "varado", + "pescando", "navegando a vela"]; + async function loadAis() { + try { + const r = await SDR.api("/api/ais"); D.ais = r; + const loc = r.location; + $("#aiLocNote").textContent = loc ? (loc.source === "manual" ? _t("Ubicación guardada: {lat}, {lon}", {lat: loc.lat.toFixed(4), lon: loc.lon.toFixed(4)}) + : _t("Ubicación estimada con los barcos recibidos (guarda la tuya en Aviones → Tu ubicación).")) : _t("Sin ubicación, el mapa usa el centro de los barcos recibidos."); + if (document.activeElement !== $("#aiGain") && r.params) $("#aiGain").value = String(r.params.gain); + const s = SDR.status; + if (s && s.mode === "ais" && !(r.info && r.info.error)) { + const n = (r.ships || []).length, pos = (r.ships || []).filter(a => a.lat != null).length; + $("#aiState").innerHTML = [_t('● Mapa encendido'), _tn(n, "{n} barco ({p} con posición)", "{n} barcos ({p} con posición)", {p: pos}), + _tn(r.messages || 0, "{n} mensaje", "{n} mensajes"), _tn(r.today || 0, "{n} distinto hoy", "{n} distintos hoy")].join(" · "); + } + const now = Date.now(); + for (const a of r.ships || []) { + if (a.lat == null) continue; + const t = D.aisTrails[a.mmsi] || (D.aisTrails[a.mmsi] = []); + const last = t[t.length - 1]; + if (!last || last[0] !== a.lat || last[1] !== a.lon) t.push([a.lat, a.lon, now]); + if (t.length > 60) t.shift(); + } + for (const k of Object.keys(D.aisTrails)) { const t = D.aisTrails[k]; if (now - t[t.length - 1][2] > 900000) delete D.aisTrails[k]; } + renderShips(); drawShips(); + } catch (e) {} + } + function shipName(a) { return a.name || ("MMSI " + a.mmsi); } + function renderShips() { + const list = ((D.ais && D.ais.ships) || []).slice().sort((a, b) => (a.dist_km == null) - (b.dist_km == null) || (a.dist_km || 0) - (b.dist_km || 0)); + $("#aiTable").innerHTML = `${_t("Barco")}${_t("Tipo y destino")}${_t("Velocidad")}${_t("Distancia")}` + + (list.length ? list.map(a => { + const k = SHIP_KIND[a.kind] || SHIP_KIND.other, st = a.status != null && NAV_STATUS[a.status] ? _t(NAV_STATUS[a.status]) : ""; + const size = a.length ? ` · ${a.length} × ${a.beam || "?"} m` : ""; + return `${SDR.esc(a.name || "—")}
${a.mmsi}${a.emerg ? "
" + SDR.esc(_t("SOCORRO")) + "" : ""}` + + `● ${SDR.esc(_t(k[0]))}${SDR.esc(size)}
${SDR.esc(a.dest || st || "")}` + + `${a.sog == null ? "—" : a.sog.toFixed(1) + " kn
" + Math.round(a.sog * 1.852) + " km/h"}${a.cog == null ? "" : "
" + SDR.esc(_t("rumbo {n}°", {n: Math.round(a.cog)})) + ""}` + + `${a.dist_km == null ? "—" : a.dist_km.toFixed(1) + " km" + (a.bearing != null ? "
" + SDR.esc(_t("hacia {n}°", {n: a.bearing})) + "" : "")}`; + }).join("") : `${SDR.esc(_t("Sin barcos todavía."))}`) + ""; + } + function drawShips() { + const B = radarBase("#aisMap", "#aiRange", "#04101a", "#16384f", "#3a7a9c"); if (!B) return; + const {ctx, dpr, R, cx, cy, range} = B, on = SDR.status && SDR.status.mode === "ais"; + const ships = (D.ais && D.ais.ships) || [], loc = D.ais && D.ais.location; + if (!loc) { + ctx.fillStyle = "#3a7a9c"; ctx.font = `${13 * dpr}px system-ui,sans-serif`; + ctx.fillText(on ? _t("Esperando barcos…") : _t("Mapa apagado"), cx, cy + R / 2); return; + } + const P = (lat, lon) => { const [x, y] = km(loc, lat, lon); return [cx + x / range * R, cy - y / range * R]; }; + if (!on && ships.length) { + ctx.fillStyle = "#ffb020"; ctx.font = `bold ${11 * dpr}px system-ui,sans-serif`; ctx.textAlign = "left"; ctx.textBaseline = "top"; + ctx.fillText(D.ais.now ? _t("Mapa apagado · última imagen de las {t}", {t: new Date(D.ais.now * 1000).toLocaleTimeString()}) : _t("Mapa apagado · última imagen"), 8 * dpr, 8 * dpr); + ctx.globalAlpha = 0.45; + } + ctx.fillStyle = "#3cc8f5"; ctx.beginPath(); ctx.arc(cx, cy, 3 * dpr, 0, 2 * Math.PI); ctx.fill(); + for (const [id, t] of Object.entries(D.aisTrails)) { + if (t.length < 2) continue; + ctx.strokeStyle = +id === D.aisSel ? "rgba(255,212,0,.6)" : "rgba(60,200,245,.3)"; ctx.beginPath(); + t.forEach((p, i) => { const [x, y] = P(p[0], p[1]); if (i) ctx.lineTo(x, y); else ctx.moveTo(x, y); }); ctx.stroke(); + } + ctx.font = `${10 * dpr}px ui-monospace,monospace`; ctx.textAlign = "left"; ctx.textBaseline = "middle"; + for (const a of ships) { + if (a.lat == null) continue; + const [x, y] = P(a.lat, a.lon); if (Math.hypot(x - cx, y - cy) > R + 20 * dpr) continue; + const sel = a.mmsi === D.aisSel, col = a.emerg ? "#ff5c5c" : sel ? "#ffd400" : (SHIP_KIND[a.kind] || SHIP_KIND.other)[1], s = 5 * dpr; + ctx.fillStyle = col; + if (a.cls === "AtoN" || a.cls === "base") { ctx.fillRect(x - s / 2, y - s / 2, s, s); } + else if ((a.sog || 0) < 0.5 || a.cog == null) { ctx.beginPath(); ctx.arc(x, y, s * 0.8, 0, 2 * Math.PI); ctx.fill(); } + else { + ctx.save(); ctx.translate(x, y); ctx.rotate((((a.heading != null ? a.heading : a.cog) || 0) * Math.PI) / 180); + ctx.beginPath(); ctx.moveTo(0, -s * 1.6); ctx.lineTo(s, s); ctx.lineTo(-s, s); ctx.closePath(); ctx.fill(); ctx.restore(); + } + if (a.emerg) { ctx.strokeStyle = "#ff5c5c"; ctx.beginPath(); ctx.arc(x, y, s * 2, 0, 2 * Math.PI); ctx.stroke(); } + ctx.fillStyle = a.emerg ? "#ff9b9b" : "#cfe3ee"; + ctx.fillText(a.name || String(a.mmsi), x + 9 * dpr, y); + } + ctx.globalAlpha = 1; + } + // ------------------------------------------------------------------ catálogo async function loadCatalog() { try { @@ -350,8 +446,22 @@ const Dec = (() => { if (g) { const t = PANEL_GO[g.dataset.go]; goto(t[0], t[1]); } if (o) Owrx.open(OWRX_FREQ[o.dataset.owrx] || null); }); + $("#aiStart").addEventListener("click", async () => { + try { await SDR.api("/api/ais", {activate: true}); SDR.toast(_t("Mapa encendido: los barcos aparecen en 1–5 min.")); setTimeout(SDR.pollStatus, 800); } + catch (e) { SDR.toast(e.message, true); } + }); + $("#aiStop").addEventListener("click", () => SDR.useMode("sweep")); + $("#aiGain").innerHTML = gainOpts; + $("#aiGain").addEventListener("change", async ev => { try { await SDR.api("/api/ais", {gain: ev.target.value}); } catch (e) { SDR.toast(e.message, true); } }); + $("#aiRange").addEventListener("change", drawShips); + $("#aiGoLoc").addEventListener("click", () => showSub("air")); + $("#aiTable").addEventListener("click", ev => { + const tr = ev.target.closest("[data-mmsi]"); if (!tr) return; + D.aisSel = D.aisSel === +tr.dataset.mmsi ? null : +tr.dataset.mmsi; renderShips(); drawShips(); + }); new ResizeObserver(drawRadar).observe($("#radar")); - try { const s = localStorage.getItem("sdrkit.dsub"); if (["msg", "ism", "air", "all"].includes(s)) D.sub = s; } catch (e) {} + new ResizeObserver(drawShips).observe($("#aisMap")); + try { const s = localStorage.getItem("sdrkit.dsub"); if (["msg", "ism", "air", "ais", "all"].includes(s)) D.sub = s; } catch (e) {} $$("#decNav button").forEach(b => b.classList.toggle("active", b.dataset.dsub === D.sub)); $$("#tab-dec .dsub").forEach(s => s.classList.toggle("hidden", s.id !== "dec-" + D.sub)); } diff --git a/files/web/help.js b/files/web/help.js index c498aac..eb12125 100644 --- a/files/web/help.js +++ b/files/web/help.js @@ -92,6 +92,9 @@ const Help = (() => { adsb: {t: "Radar de aviones (ADS-B)", p: "Los aviones transmiten su posición, altura y velocidad en 1090 MHz. El panel los dibuja en un radar sin necesidad de internet.", u: ["Pulsa ▶ y espera 10–60 s.", "Rojo = emergencia (squawk 7700), sin radio (7600) o interferencia ilícita (7500).", "Guarda tu ubicación para ver distancias y rumbos reales."], c: "Antena vertical de 6.9 cm lo más alta posible; con vista despejada se ven aviones a 200–300 km."}, + barcos: {t: "Mapa de barcos (AIS)", p: "Los barcos transmiten su nombre, posición, rumbo y velocidad en 161.975 y 162.025 MHz (AIS). El panel los dibuja en un mapa sin necesidad de internet.", + u: ["Pulsa ▶ y espera 1–5 min: cada barco transmite cada pocos segundos o minutos, según su velocidad.", "Colores por tipo: verde carguero, naranja petrolero, celeste pasajeros, violeta pesca, amarillo remolcador. Rojo = socorro (baliza AIS-SART, hombre al agua o EPIRB).", "El centro del mapa es tu ubicación (la misma de Aviones → Tu ubicación)."], + c: "Antena vertical de unos 46 cm, lo más alta posible y con vista al agua; el alcance típico es de 20–60 km desde la costa. Necesita rtl-ais o AIS-catcher instalado."}, ubicacion: {t: "Tu ubicación", p: "El centro del radar.", u: ["Escribe latitud y longitud en grados decimales (cópialas de Google Maps manteniendo pulsado tu punto).", "El GPS del celular sólo funciona en páginas https."]}, todos: {t: "Todos los modos", p: "La lista completa de lo que este equipo puede decodificar, y si quedó instalado.", u: ["«En este panel» funcionan aquí mismo.", "«En el receptor avanzado» se abren en OpenWebRX+ (otra página), que tiene decenas de decodificadores más: RDS, AIS, ACARS, VDL2, HFDL, radiosondas, FT8, SSTV, FAX, DRM, DAB, HD Radio…"]}, @@ -141,7 +144,7 @@ const Help = (() => { ["tuner", "#tnFreq"], ["modos", "#anMode"], ["receptor", "#inDet"], ["trazas", "#tgMax"], ["mediciones", "#inMeas"], ["unidades", "#inUnit"], ["limite", "#limUp"], ["vigilancia", "#monOn"], ["estados", "#stSave"], ["picos", "#peakRows"], ["squelch", "#rSq"], ["scrambler", "#rInv"], ["panorama", "#pan"], ["subtonos", "#dCtcss"], ["dsd", "#dsdModes"], ["datos", "#rData"], ["grabar", "#rRec"], ["repeticion", "#rReplay"], - ["escaner", "#scStart"], ["memorias", "#memList"], ["mensajes", "#dmKinds"], ["sensores", "#snPreset"], ["adsb", "#radar"], + ["escaner", "#scStart"], ["memorias", "#memList"], ["mensajes", "#dmKinds"], ["sensores", "#snPreset"], ["adsb", "#radar"], ["barcos", "#aisMap"], ["ubicacion", "#adLat"], ["todos", "#allList"], ["vozdigital", "#ambeState"], ["grabaciones", "#recSearch"], ["negocio", "#bizBody"], ["actividad", "#actTable"], ["paises", "#paisSel"], ["decsub", "#toneF"], ["canales", "#chServices"], ["guia", "#refTable"], ["tablas", "#ctcssGrid"], ["tablas", "#dcsGrid"], ["antenas", "#antF"], ["cable", "#cabT"], ["owrx", "#btnRxOpen"], ["wifi", "#btnHotspot"], ["sistema", "#tiles"], ["actualizar", "#btnUpdCheck"], diff --git a/files/web/index.html b/files/web/index.html index 4bc4f1f..8478caf 100644 --- a/files/web/index.html +++ b/files/web/index.html @@ -447,6 +447,7 @@

Memorias

+ @@ -559,6 +560,36 @@

Aviones

+ +