diff --git a/CHANGELOG.md b/CHANGELOG.md index 838a9fc..7346520 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,35 @@ Update an installed kit with `sudo sdrkit update`. ## [Unreleased] +## [3.13.0] - 2026-10-22 + +### Added +- **Listen-only user**: an optional second password (`VIEWER_PASSWORD`, set or removed with `sudo sdrkit viewer`). Logging + in with it opens the same panel, marked **👁 Listen only**, where you can look at everything and listen to the live audio, + recordings and downloads, but every change is refused (`403`): tuning, modes, deleting, settings, reboot. It does not see + the saved WiFi networks, the system logs or the update status. Ignored when it equals the administrator password or when + there is no administrator password. `GET /api/status` now includes `role`. It only guards the panel; OpenWebRX+ (port 8073) + keeps its own login. Covered by `tests/test_viewer.py`. + +### Changed +- **Roadmap trimmed to 32 features**: Home Assistant (MQTT), the installable app (PWA) and the daily Telegram summary were + dropped; the rest were renumbered (the unified server is now #32). Updated in `ROADMAP.md`, the panel's Roadmap card + and the README. + +## [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..86fa43d 100644 --- a/README.es.md +++ b/README.es.md @@ -84,6 +84,8 @@ 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 +- **Clave de solo escucha** para invitados: pueden ver y escuchar, nunca cambiar ni borrar - 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… @@ -421,7 +423,7 @@ Sin nube, sin cuentas, sin telemetría. | [Guía completa en español](docs/es/GUIA.md) | Compras, montaje, instalación, cada pestaña, antenas, voz digital, actualizaciones y problemas | | [Documentación técnica (inglés)](docs/README.md) | Arquitectura, API HTTP, configuración, desarrollo | | [Historial de versiones](CHANGELOG.md) · [Cómo contribuir](CONTRIBUTING.md) · [Seguridad](SECURITY.md) | Proyecto | -| [Plan diario de funciones](ROADMAP.md) | 34 funciones nuevas, una por día, y la visión a futuro (un servidor unificado para escuchar las Pi de todo el mundo): qué viene y qué ya está hecho | +| [Plan diario de funciones](ROADMAP.md) | 31 funciones nuevas, una por día, y la visión a futuro (un servidor unificado para escuchar las Pi de todo el mundo): qué viene y qué ya está hecho | ## ⚖️ Legal diff --git a/README.md b/README.md index eab7023..692b197 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ 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 +- **Listen-only password** for guests: they can look and listen, never change or delete - 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… @@ -420,7 +422,7 @@ HTTP API: [docs/api.md](docs/api.md). ## 🗺️ Roadmap -📅 **Daily feature plan:** [ROADMAP.md](ROADMAP.md) lists 34 new features, one shipped per day, plus the long-term vision (a unified server where everyone can share and listen to each other's Pis worldwide), with what's done and what's next. +📅 **Daily feature plan:** [ROADMAP.md](ROADMAP.md) lists 31 new features, one shipped per day, plus the long-term vision (a unified server where everyone can share and listen to each other's Pis worldwide), with what's done and what's next. - [ ] HackRF One sweeps up to 6 GHz in the analyzer (detected automatically; needs more field testing) - [ ] Second-dongle profiles: aircraft radar or sensors 24/7 while using the analyzer diff --git a/ROADMAP.md b/ROADMAP.md index f4c0aa6..5f63ff1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # 🗺️ SDR-Kit · Plan diario de funciones -Una función nueva por día, en este orden (34 funciones, más una visión a futuro: el servidor unificado). Las primeras aprovechan lo que el kit ya tiene; las que dependen de otras -van después. Cada día se toma **la primera casilla ☐** de la lista (salvo la #35, que primero se diseña: ver su fila). +Una función nueva por día, en este orden (31 funciones, más una visión a futuro: el servidor unificado). Las primeras aprovechan lo que el kit ya tiene; las que dependen de otras +van después. Cada día se toma **la primera casilla ☐** de la lista (salvo la #32, que primero se diseña: ver su fila). Estado: ☐ pendiente · ◐ hecha en parte (ver nota) · ✅ terminada (fecha · versión) @@ -19,8 +19,8 @@ Estado: ☐ pendiente · ◐ hecha en parte (ver nota) · ✅ terminada (fecha 6. **Honesto**: si algo depende de hardware que no se puede probar, se simula, se prueba la parte de software y se dice claramente en la documentación qué falta verificar con hardware real. 7. **Legal**: el kit sólo recibe. Lo que toque privacidad (TPMS, medidores, monitoreo de personal) lleva su aviso; la - única función que transmite por radio (#34) va casi al final, apagada por defecto y sólo para equipos y bandas - autorizadas. La única que **comparte audio por internet** (#35, servidor unificado) es la última: siempre opcional, + única función que transmite por radio (#31) va casi al final, apagada por defecto y sólo para equipos y bandas + autorizadas. La única que **comparte audio por internet** (#32, servidor unificado) es la última: siempre opcional, apagada por defecto, y cada usuario elige qué comparte. 8. **Publicación**: commit a nombre de `romartak `, sin menciones a herramientas de IA en commits ni archivos; push a `main`; versión menor nueva (`VERSION`, `version()` en `sdrkit_server.py`, @@ -33,70 +33,67 @@ 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. | -| 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. | -| 8 | ☐ | **Usuario de solo escucha** | Segunda contraseña con permisos limitados: escucha y ve todo, pero no cambia configuración ni borra nada. | +| 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 | ✅ 2026-10-22 · 3.13.0 | **Usuario de solo escucha** | Segunda contraseña con permisos limitados: escucha y ve todo, pero no cambia configuración ni borra nada. | ## Fase 2 · Herramientas de campo | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 9 | ☐ | **Prueba de interferencias** | Asistente que analiza un canal y dice si hay ruido, otra emisora, saturación o señal débil, con consejos. | -| 10 | ☐ | **Barrido de seguridad** | Modo que busca señales sospechosas que aparecen y desaparecen (micrófonos, rastreadores) y las compara con una «línea base». | -| 11 | ☐ | **Cazador con brújula** | El modo cazador usa la brújula del celular y la intensidad para dibujar hacia dónde está el transmisor. | -| 12 | ☐ | **Mapa de cobertura caminando** | Con el GPS del celular, pinta en un mapa el nivel de una frecuencia mientras caminas; exportable. | -| 13 | ☐ | **Medidor de antena (SWR) casero** | Guía y medición con fuente de ruido + puente: curva de retorno y frecuencia de resonancia en el analizador. | -| 14 | ☐ | **Identificador de señales mejorado** | El clasificador reconoce más tipos (DMR, P25, LoRa, POCSAG, portones, TV digital…) con confianza y explicación. | +| 6 | ☐ | **Prueba de interferencias** | Asistente que analiza un canal y dice si hay ruido, otra emisora, saturación o señal débil, con consejos. | +| 7 | ☐ | **Barrido de seguridad** | Modo que busca señales sospechosas que aparecen y desaparecen (micrófonos, rastreadores) y las compara con una «línea base». | +| 8 | ☐ | **Cazador con brújula** | El modo cazador usa la brújula del celular y la intensidad para dibujar hacia dónde está el transmisor. | +| 9 | ☐ | **Mapa de cobertura caminando** | Con el GPS del celular, pinta en un mapa el nivel de una frecuencia mientras caminas; exportable. | +| 10 | ☐ | **Medidor de antena (SWR) casero** | Guía y medición con fuente de ruido + puente: curva de retorno y frecuencia de resonancia en el analizador. | +| 11 | ☐ | **Identificador de señales mejorado** | El clasificador reconoce más tipos (DMR, P25, LoRa, POCSAG, portones, TV digital…) con confianza y explicación. | ## Fase 3 · Casa inteligente y sensores | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 15 | ☐ | **Medidores de agua, luz y gas** | Lectura de medidores por radio compatibles (rtl_433 / rtlamr), con gráfico de consumo y aviso de privacidad. | -| 16 | ☐ | **Sensores de llantas (TPMS)** | Lista de sensores TPMS con presión y temperatura; marcar «mi carro» para seguir sólo los propios. | +| 12 | ☐ | **Medidores de agua, luz y gas** | Lectura de medidores por radio compatibles (rtl_433 / rtlamr), con gráfico de consumo y aviso de privacidad. | +| 13 | ☐ | **Sensores de llantas (TPMS)** | Lista de sensores TPMS con presión y temperatura; marcar «mi carro» para seguir sólo los propios. | ## Fase 4 · Espacio y mapas | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 17 | ☐ | **Pasos de satélites e ISS** | Cálculo de pasos sin internet (TLE guardados, se actualizan si hay red), aviso y grabación automática al pasar. | -| 18 | ☐ | **Imágenes Meteor-M** | Grabar el paso y obtener la imagen (decodificador opcional); en la Pi Zero se procesa después del paso. | -| 19 | ☐ | **Radiosondas** | Rastreo de globos meteorológicos (400–406 MHz) en el mapa con su trayectoria y punto de caída estimado. | -| 20 | ☐ | **Mapa combinado** | Un solo mapa con aviones, barcos, APRS, radiosondas y tus repetidoras, con capas que se encienden y apagan. | -| 21 | ☐ | **Alimentar ADSBexchange / FlightAware** | Opción de enviar los aviones decodificados a esas redes (y MLAT si aplica), apagada por defecto. | +| 14 | ☐ | **Pasos de satélites e ISS** | Cálculo de pasos sin internet (TLE guardados, se actualizan si hay red), aviso y grabación automática al pasar. | +| 15 | ☐ | **Imágenes Meteor-M** | Grabar el paso y obtener la imagen (decodificador opcional); en la Pi Zero se procesa después del paso. | +| 16 | ☐ | **Radiosondas** | Rastreo de globos meteorológicos (400–406 MHz) en el mapa con su trayectoria y punto de caída estimado. | +| 17 | ☐ | **Mapa combinado** | Un solo mapa con aviones, barcos, APRS, radiosondas y tus repetidoras, con capas que se encienden y apagan. | +| 18 | ☐ | **Alimentar ADSBexchange / FlightAware** | Opción de enviar los aviones decodificados a esas redes (y MLAT si aplica), apagada por defecto. | ## Fase 5 · Radio inteligente | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 22 | ☐ | **Varios canales a la vez** | Escuchar 2–3 canales que caben en el ancho del dongle, mezclados o en estéreo izquierda/derecha. | -| 23 | ☐ | **Quién habla** | «Huella» de cada walkie (subtono, desviación, nivel, ID digital) con nombre, mostrada en el radio y el registro. | -| 24 | ☐ | **Transcripción de voz a texto** | Transcripción sin internet de las grabaciones (motor opcional tipo whisper.cpp), buscable en Grabaciones. | -| 25 | ☐ | **Alertas por palabra clave** | Aviso cuando la transcripción contiene palabras elegidas («emergencia», «fuego»…). Depende de #24. | -| 26 | ☐ | **Trunking** | Seguir sistemas troncalizados P25 / DMR (canal de control + voz) con el decodificador opcional. | +| 19 | ☐ | **Varios canales a la vez** | Escuchar 2–3 canales que caben en el ancho del dongle, mezclados o en estéreo izquierda/derecha. | +| 20 | ☐ | **Quién habla** | «Huella» de cada walkie (subtono, desviación, nivel, ID digital) con nombre, mostrada en el radio y el registro. | +| 21 | ☐ | **Transcripción de voz a texto** | Transcripción sin internet de las grabaciones (motor opcional tipo whisper.cpp), buscable en Grabaciones. | +| 22 | ☐ | **Alertas por palabra clave** | Aviso cuando la transcripción contiene palabras elegidas («emergencia», «fuego»…). Depende de #21. | +| 23 | ☐ | **Trunking** | Seguir sistemas troncalizados P25 / DMR (canal de control + voz) con el decodificador opcional. | ## Fase 6 · Comunidad y aprendizaje | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 27 | ☐ | **Huella de radio de tu ciudad** | Mapa/calendario de qué se transmite, dónde y cuándo, armado con la vigilancia 24/7. | -| 28 | ☐ | **Modo aprendizaje (tour guiado)** | Recorridos paso a paso («hoy escuchamos aviones») que explican lo que se oye y se ve. | -| 29 | ☐ | **Logros y retos** | Insignias por hitos (primer satélite, 100 aviones, primera repetidora…), guardadas en la Pi. | -| 30 | ☐ | **Repetidoras de la comunidad** | Lista abierta de repetidoras (con subtono) aportada por usuarios mediante el repositorio, cargada por país en Canales. | +| 24 | ☐ | **Huella de radio de tu ciudad** | Mapa/calendario de qué se transmite, dónde y cuándo, armado con la vigilancia 24/7. | +| 25 | ☐ | **Modo aprendizaje (tour guiado)** | Recorridos paso a paso («hoy escuchamos aviones») que explican lo que se oye y se ve. | +| 26 | ☐ | **Logros y retos** | Insignias por hitos (primer satélite, 100 aviones, primera repetidora…), guardadas en la Pi. | +| 27 | ☐ | **Repetidoras de la comunidad** | Lista abierta de repetidoras (con subtono) aportada por usuarios mediante el repositorio, cargada por país en Canales. | ## Fase 7 · Hardware y plataforma | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 31 | ☐ | **Dos dongles a la vez** | Asignar un dongle a cada función (p. ej. aviones 24/7 + radio) y usarlos al mismo tiempo. | -| 32 | ☐ | **Respaldo en la nube** | Copia opcional de grabaciones y configuración (rclone: Google Drive, Dropbox…), apagada por defecto. | -| 33 | ☐ | **Más equipos: Airspy, SDRplay, Pi 5** | Soporte detectado automáticamente, con pruebas simuladas como las de la V3. | -| 34 | ☐ | **Hablar por tus radios (PTT)** | Con un walkie conectado por audio + PTT, hablarle a tu equipo desde el panel. Apagado por defecto, con aviso legal: sólo equipos y bandas en las que tengas permiso de transmitir. | +| 28 | ☐ | **Dos dongles a la vez** | Asignar un dongle a cada función (p. ej. aviones 24/7 + radio) y usarlos al mismo tiempo. | +| 29 | ☐ | **Respaldo en la nube** | Copia opcional de grabaciones y configuración (rclone: Google Drive, Dropbox…), apagada por defecto. | +| 30 | ☐ | **Más equipos: Airspy, SDRplay, Pi 5** | Soporte detectado automáticamente, con pruebas simuladas como las de la V3. | +| 31 | ☐ | **Hablar por tus radios (PTT)** | Con un walkie conectado por audio + PTT, hablarle a tu equipo desde el panel. Apagado por defecto, con aviso legal: sólo equipos y bandas en las que tengas permiso de transmitir. | ## Fase 8 · Visión a futuro | # | Estado | Función | Listo cuando… | |---|---|---|---| -| 35 | ☐ | **Servidor unificado (SDR-Kit Network)** | Un servidor al que cualquiera puede conectar su Pi para compartir su recepción y escuchar las Pi de otras personas en todos los países, con mapa mundial. Opt-in y apagado por defecto: cada quien elige qué comparte (sólo canales públicos o sin licencia, nunca cifrados ni privados), con aviso legal por país, moderación, límites de ancho de banda y la opción de apagarlo en cualquier momento. Requiere diseñar el servidor, la seguridad y el protocolo antes de escribir código. | +| 32 | ☐ | **Servidor unificado (SDR-Kit Network)** | Un servidor al que cualquiera puede conectar su Pi para compartir su recepción y escuchar las Pi de otras personas en todos los países, con mapa mundial. Opt-in y apagado por defecto: cada quien elige qué comparte (sólo canales públicos o sin licencia, nunca cifrados ni privados), con aviso legal por país, moderación, límites de ancho de banda y la opción de apagarlo en cualquier momento. Requiere diseñar el servidor, la seguridad y el protocolo antes de escribir código. | diff --git a/VERSION b/VERSION index afad818..4eba2a6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -3.11.0 +3.13.0 diff --git a/docs/api.md b/docs/api.md index cbcb44e..2934e35 100644 --- a/docs/api.md +++ b/docs/api.md @@ -40,6 +40,8 @@ PI=http://sdrpi.local When `ADMIN_PASSWORD` is set, every route except `/login`, `/logout`, `/favicon.svg`, `/icon.svg` and `/manifest.webmanifest` needs the `sdrkit_auth` cookie. Without it, `/api/*` returns `401` and pages redirect to `/login`. +If `VIEWER_PASSWORD` is also set, logging in with it gives a **listen-only** session: every `GET` works (including the live audio and the downloads) except `/api/wifi`, `/api/logs` and `/api/system/*`, and every `POST` other than `/login` answers `403 {"error":"Modo de solo escucha: …"}`. `GET /api/status` includes `"role": "admin" | "viewer"`. + | Method | Path | Body | Response | |---|---|---|---| | `GET` | `/login` | — | Login page (HTML) | diff --git a/docs/architecture.md b/docs/architecture.md index 55d4e24..ffa241b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -279,8 +279,8 @@ Summarised here, detailed in [Updating](updating.md). | Aspect | Design | |---|---| | **Exposure** | Plain HTTP on port 80 (and OpenWebRX+ on 8073), meant for the **local network, the Pi's own WPA2 hotspot and Tailscale**. Do not port-forward it to the internet | -| **Authentication** | One shared password, `ADMIN_PASSWORD`. `POST /login` compares it in constant time; a wrong password costs a 1 s delay | -| **Session cookie** | `sdrkit_auth` = HMAC-SHA256(secret, `"v1:" + password`), `HttpOnly`, `SameSite=Lax`, 30 days. The 32-byte secret is generated once in `/var/lib/sdrkit/secret` (mode 600). Changing the password invalidates every session | +| **Authentication** | `ADMIN_PASSWORD`, plus an optional listen-only `VIEWER_PASSWORD` (see below). `POST /login` compares it in constant time; a wrong password costs a 1 s delay | +| **Session cookie** | `sdrkit_auth` = HMAC-SHA256(secret, `"v1:" + password`), `HttpOnly`, `SameSite=Lax`, 30 days. The 32-byte secret is generated once in `/var/lib/sdrkit/secret` (mode 600). Changing the password invalidates every session. The listen-only session uses the same cookie with `"viewer1:" + viewer_password`; every `POST` except `/login` is refused for it, and `/api/wifi`, `/api/logs` and `/api/system/*` are hidden from it. It only guards the panel: OpenWebRX+ on port 8073 has its own login | | **No password** | If `ADMIN_PASSWORD` is empty, the panel is open to anyone on the network | | **API** | Every `/api/*` route requires the cookie (`401 {"error":"auth"}` otherwise); only `/login`, `/logout`, the icons and the manifest are public | | **Transport** | No TLS. The hotspot is WPA2; Tailscale encrypts remote access end to end (WireGuard). On a shared LAN, traffic — including the login — is readable by others on that LAN | diff --git a/docs/configuration.md b/docs/configuration.md index 6edceec..53760f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,6 +65,7 @@ The **Applied** column in the tables below uses: **panel**, **netwatch**, **inst | Key | Default | Applied | Meaning | |---|---|---|---| | `ADMIN_PASSWORD` | `""` | panel, installer | Password of the web panel and of the OpenWebRX+ `admin` user. The installer asks for one (4+ characters). Change it with `sudo sdrkit password`, which updates both | +| `VIEWER_PASSWORD` | `""` | panel | Optional second password for a **listen-only** user: it can see and listen to everything but changes and deletes nothing, and does not see WiFi networks, system logs or updates. Empty = off; ignored if it equals `ADMIN_PASSWORD` or there is no administrator password. Set it with `sudo sdrkit viewer` | > [!WARNING] > If `ADMIN_PASSWORD` is empty, the panel runs **without a login**. The installer always sets one; do not clear it on a kit that joins shared networks. diff --git a/docs/es/GUIA.md b/docs/es/GUIA.md index 2d6ffa3..f2725a9 100644 --- a/docs/es/GUIA.md +++ b/docs/es/GUIA.md @@ -498,6 +498,7 @@ Otros comandos: | `sudo sdrkit hotspot` / `sudo sdrkit auto` | Forzar hotspot / volver a automático | | `sudo sdrkit wifi "Red" "clave"` | Guardar una red WiFi | | `sudo sdrkit clave` / `clave-hotspot` | Cambiar claves | +| `sudo sdrkit escucha` | Crear o quitar la clave de **solo escucha** (ve y escucha todo, no cambia ni borra nada) | | `sdrkit diagnóstico` | Revisión completa con ✔/✖ y qué hacer si algo falla | | `sudo sdrkit copia [archivo]` / `restaurar archivo` | Guardar / recuperar tu configuración y memorias (sin grabaciones) | | `sudo sdrkit probar-sdr` | Probar el dongle | diff --git a/docs/user-guide.md b/docs/user-guide.md index 66ecd04..b3acf46 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 @@ -424,6 +435,7 @@ Dimensioned drawings for dipole, ground-plane, J-pole, 3-element Yagi, satellite (stars kept only on your device), **📤 Share** (share sheet, WhatsApp, Telegram, X, Facebook, email, copy link) and **👋 Introduction** to see the welcome tour again. - **Language:** English / Español. +- **Listen-only user:** run `sudo sdrkit viewer` to set a second password. Whoever logs in with it sees the panel marked **👁 Listen only**: they can look at every tab, hear the live audio and download recordings, but any change (tuning, modes, deleting, settings) is refused, and they do not see the WiFi networks, system logs or updates. Handy for a shared tablet or for someone who only needs to listen. The same command with an empty password turns it off. It only guards this panel; the advanced receiver (port 8073) keeps its own login. - **Maintenance:** **📄 View logs** (last 200 lines of the SDR-Kit services), **🔄 Restart** (reboots the Pi), **⏻ Shut down**, **Log out** (shown only when a password is set). > [!WARNING] diff --git a/files/sdrkit-cli.sh b/files/sdrkit-cli.sh index af92d42..c846065 100755 --- a/files/sdrkit-cli.sh +++ b/files/sdrkit-cli.sh @@ -23,6 +23,7 @@ sdrkit — atajos de SDR-Kit sudo sdrkit wifi "Red" "clave" guarda una red WiFi (ej. el hotspot del celular) sdrkit networks lista las redes guardadas sudo sdrkit password cambia la clave del panel y de OpenWebRX+ (admin) + sudo sdrkit viewer crea o quita la clave de solo escucha (ve y escucha, no cambia nada) sudo sdrkit hotspot-password cambia la clave del hotspot sudo sdrkit profiles reescribe los perfiles de bandas de OpenWebRX+ sudo sdrkit test-sdr prueba el dongle con rtl_test (pausa el panel unos segundos) @@ -37,7 +38,7 @@ sdrkit — atajos de SDR-Kit sudo sdrkit language en|es idioma de estos mensajes y del instalador sdrkit logs registros en vivo (Ctrl+C para salir) sudo sdrkit restart reinicia los servicios de SDR-Kit -Los comandos en español también valen, en el mismo orden: estado, diagnóstico, copia, restaurar, redes, clave, clave-hotspot, +Los comandos en español también valen, en el mismo orden: estado, diagnóstico, copia, restaurar, redes, clave, escucha, clave-hotspot, perfiles, probar-sdr, grabaciones, decodificadores, actualizar (--buscar), volver, versión, configurar, instalar-dsd, idioma, reiniciar, ayuda. EOF @@ -53,6 +54,7 @@ sdrkit — SDR-Kit shortcuts sudo sdrkit wifi "Name" "pass" save a WiFi network (e.g. your phone's hotspot) sdrkit networks list the saved networks sudo sdrkit password change the panel and OpenWebRX+ (admin) password + sudo sdrkit viewer set or remove the listen-only password (sees and listens, changes nothing) sudo sdrkit hotspot-password change the hotspot password sudo sdrkit profiles rewrite the OpenWebRX+ band profiles sudo sdrkit test-sdr test the dongle with rtl_test (pauses the panel for a few seconds) @@ -67,7 +69,7 @@ sdrkit — SDR-Kit shortcuts sudo sdrkit language en|es language of these messages and of the installer sdrkit logs live logs (Ctrl+C to quit) sudo sdrkit restart restart the SDR-Kit services -Spanish aliases work too, in the same order: estado, diagnóstico, copia, restaurar, redes, clave, clave-hotspot, perfiles, +Spanish aliases work too, in the same order: estado, diagnóstico, copia, restaurar, redes, clave, escucha, clave-hotspot, perfiles, probar-sdr, grabaciones, decodificadores, actualizar (--buscar), volver, versión, configurar, instalar-dsd, idioma, reiniciar, ayuda. EOF @@ -191,6 +193,25 @@ case "${1:-help}" in fi systemctl restart sdrkit && L "Password changed." "Clave cambiada." ;; + viewer|escucha) + need_root "$@" + L "Listen-only password: that login can see and listen to everything but cannot change or delete anything. Leave empty to turn it off." \ + "Clave de solo escucha: con ella se puede ver y escuchar todo, pero no cambiar ni borrar nada. Déjala vacía para desactivarla." + read -r -s -p "$(Ln "New listen-only password (8+ characters, empty = off): " "Nueva clave de solo escucha (8+ caracteres, vacía = desactivar): ")" p1; echo + if [ -z "$p1" ]; then + sdrkit_set_conf VIEWER_PASSWORD "" "$CONF" + systemctl restart sdrkit && L "Listen-only password removed." "Clave de solo escucha desactivada." + else + read -r -s -p "$(Ln "Repeat the password: " "Repite la clave: ")" p2; echo + [ "$p1" = "$p2" ] || { L "They do not match." "No coinciden."; exit 1; } + [ ${#p1} -ge 8 ] || { L "The password must have at least 8 characters." "La clave debe tener al menos 8 caracteres."; exit 1; } + case "$p1" in *'"'*) L 'The password cannot contain double quotes (").' 'La clave no puede contener comillas dobles (").'; exit 1;; esac + [ "$p1" != "${ADMIN_PASSWORD:-}" ] || { L "It must be different from the administrator password." "Debe ser distinta de la clave de administrador."; exit 1; } + sdrkit_set_conf VIEWER_PASSWORD "$p1" "$CONF" + systemctl restart sdrkit && L "Listen-only password saved. Log in with it at the panel to get the listen-only view." \ + "Clave de solo escucha guardada. Entra con ella al panel para ver la vista de solo escucha." + fi + ;; hotspot-password|clave-hotspot) need_root "$@" read -r -s -p "$(Ln "New hotspot password (8+ characters): " "Nueva clave del hotspot (8+ caracteres): ")" p1; echo 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..225e65b 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.13.0" VERSION = _read_version() @@ -79,6 +79,7 @@ def _read_version(): DEFAULTS = { "PANEL_PORT": "80", "ADMIN_PASSWORD": "", + "VIEWER_PASSWORD": "", "OWRX_PORT": "8073", "DEFAULT_MODE": "last", "SWEEP_ENGINE": "auto", @@ -1546,12 +1547,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 +1565,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 +1576,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,11 +1902,15 @@ 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", "") + # segunda clave, de solo escucha: ve y escucha todo pero no cambia nada (ignorada si no hay clave de administrador o es igual) + vp = conf.get("VIEWER_PASSWORD", "") + self.viewer_password = vp if self.password and vp != self.password else "" self._login_fails = {} self._login_lock = threading.Lock() self.secret = self._secret() @@ -1953,7 +1959,9 @@ def login_ok(self, ip): with self._login_lock: self._login_fails.pop(ip, None) - def token(self): + def token(self, role="admin"): + if role == "viewer": + return hmac.new(self.secret, ("viewer1:" + self.viewer_password).encode(), hashlib.sha256).hexdigest() return hmac.new(self.secret, ("v1:" + self.password).encode(), hashlib.sha256).hexdigest() # -- actualizaciones desde GitHub ------------------------------------------ @@ -2072,6 +2080,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()), } @@ -2271,16 +2280,27 @@ def _same_origin(self): return origin is None return urlparse(origin).netloc.lower() == (self.headers.get("Host") or "").lower() - def _authed(self): + def _role(self): + """«admin» (todo), «viewer» (solo ver y escuchar) o None (sin sesión).""" app = self.app if not app.password: - return True + return "admin" cookie = self.headers.get("Cookie", "") for part in cookie.split(";"): k, _, v = part.strip().partition("=") - if k == "sdrkit_auth" and hmac.compare_digest(v, app.token()): - return True - return False + if k != "sdrkit_auth": + continue + if hmac.compare_digest(v, app.token()): + return "admin" + if app.viewer_password and hmac.compare_digest(v, app.token("viewer")): + return "viewer" + return None + + def _authed(self): + return self._role() is not None + + # lo que el usuario de solo escucha no puede ni ver: WiFi guardadas, registros del sistema y actualizaciones + VIEWER_BLOCKED = ("/api/wifi", "/api/logs", "/api/system/") # -- rutas -- def do_HEAD(self): @@ -2300,6 +2320,8 @@ def do_GET(self): if path.startswith("/api/"): return self._send(401, {"error": "auth"}) return self._send(302, "", headers={"Location": "/login"}) + if self._role() == "viewer" and path.startswith(self.VIEWER_BLOCKED): + return self._send(403, {"error": "Modo de solo escucha: esto es sólo para el administrador."}) if path in ("/", "/index.html"): return self._static("index.html") if re.match(r"^/[a-z0-9_-]+\.(js|css)$", path): @@ -2350,6 +2372,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()} @@ -2366,7 +2390,7 @@ def do_GET(self): return self._send(200, ("\ufeff" + "\n".join(rows) + "\n").encode("utf-8"), "text/csv; charset=utf-8", {"Content-Disposition": "attachment; filename=mensajes-%s.csv" % time.strftime("%Y-%m-%d")}) if path == "/api/status": - return self._send(200, self.app.status()) + return self._send(200, dict(self.app.status(), role=self._role())) if path == "/api/sweep": s = self.app.sweep return self._send(200, {"params": s.params, "info": s.info, "cal": s.cal}) @@ -2404,9 +2428,11 @@ def do_POST(self): err = '
Demasiados intentos. Prueba de nuevo en unos minutos.
' return self._send(429, LOGIN_HTML.replace("__ERR__", err), "text/html; charset=utf-8", headers={"Retry-After": str(wait)}) - if app.password and hmac.compare_digest(pw.encode(), app.password.encode()): + role = "admin" if app.password and hmac.compare_digest(pw.encode(), app.password.encode()) else \ + "viewer" if app.viewer_password and hmac.compare_digest(pw.encode(), app.viewer_password.encode()) else None + if role: app.login_ok(ip) - cookie = "sdrkit_auth=%s; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax" % app.token() + cookie = "sdrkit_auth=%s; Path=/; Max-Age=2592000; HttpOnly; SameSite=Lax" % app.token(role) return self._send(303, "", headers={"Location": "/", "Set-Cookie": cookie}) app.login_failed(ip) time.sleep(1.0) @@ -2414,10 +2440,12 @@ def do_POST(self): return self._send(200, LOGIN_HTML.replace("__ERR__", err), "text/html; charset=utf-8") if not self._authed(): return self._send(401, {"error": "auth"}) + if self._role() == "viewer": + return self._send(403, {"error": "Modo de solo escucha: no se puede cambiar nada."}) 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 +2549,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..828d6a1 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} @@ -278,6 +279,7 @@ pre.logs{white-space:pre-wrap;word-break:break-all;font:11px/1.45 var(--mono);ba .biz-chart i{flex:1;min-width:2px;background:var(--accent,#ffd400);border-radius:2px 2px 0 0;opacity:.85} .biz-chart i.zero{background:var(--line);height:2px!important} .biz-axis{display:flex;justify-content:space-between;color:var(--muted);font-size:10px;margin-top:2px} +body.viewer #btnReboot,body.viewer #btnOff{display:none} .tbl tr.sel td{background:rgba(255,212,0,.08)} .tbl tr.emerg td{color:#ffb3b3;background:rgba(255,92,92,.08)} code{font-family:var(--mono);font-size:.92em;background:var(--bg2);border:1px solid var(--line);border-radius:5px;padding:0 4px} diff --git a/files/web/app.js b/files/web/app.js index a410ed3..af15b14 100644 --- a/files/web/app.js +++ b/files/web/app.js @@ -15,7 +15,11 @@ const SDR = { if (r.status === 401) { location.href = "/login"; throw new Error(_t("Sesión expirada")); } const ct = r.headers.get("content-type") || ""; const data = ct.includes("json") ? await r.json() : await r.text(); - if (!r.ok) throw new Error((data && data.error) || r.statusText); + if (!r.ok) { + const msg = (data && data.error) || r.statusText; + if (r.status === 403 && /^Modo de solo escucha/.test(msg)) SDR.toast(_t(msg), true); // el usuario de solo escucha siempre ve el aviso + throw new Error(msg); + } return data; }, toast(msg, err) { @@ -122,7 +126,9 @@ 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")}; + document.body.classList.toggle("viewer", s.role === "viewer"); + $("#chipViewer").classList.toggle("hidden", s.role !== "viewer"); + 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 +182,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..3c673f4 100644 --- a/files/web/index.html +++ b/files/web/index.html @@ -30,6 +30,7 @@
+ … … … @@ -447,6 +448,7 @@

Memorias

+
@@ -559,6 +561,36 @@

Aviones

+ +