Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ Update an installed kit with `sudo sdrkit update`.

## [Unreleased]

## [3.11.0] - 2026-10-22

### Added
- **Business panel** (Recordings → **Panel del negocio / Business panel**): charts of how much the radio is used — transmissions
per hour of the day and per day, total airtime, the busiest hour, the five busiest channels and the longest transmission —
for Today, 7 or 30 days, computed from the activity log. New API `GET /api/activity/stats?days=N`. The activity log now
keeps the last 5000 transmissions (was 1000). Demo mode shows sample data (in memory only). Covered by
`tests/test_activity_stats.py`; help sheet ⓘ and English translation included.

## [3.10.0] - 2026-10-18

### Changed
Expand Down
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Estado: ☐ pendiente · ◐ hecha en parte (ver nota) · ✅ terminada (fecha
| # | Estado | Función | Listo cuando… |
|---|---|---|---|
| 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 | ☐ | **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. |
| 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. |
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.10.0
3.11.0
2 changes: 1 addition & 1 deletion docs/es/GUIA.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ Con GitHub, la configuración queda en `/etc/sdrkit/sdrkit.conf` con valores por
- **Analizador:** espectro, cascada, sintonizador, marcadores, mediciones, span cero, límites y vigilancia.
- **Radio:** escuchar (también digital), buscar señal, subtonos, datos, grabar, escáner, memorias y modo cazador.
- **Decodificar:** mensajes (POCSAG/FLEX/APRS/EAS), sensores de 433 MHz, radar de aviones y la lista de todos los modos.
- **Grabaciones:** audios por día y el registro de actividad.
- **Grabaciones:** audios por día, el **panel del negocio** (gráficos de uso por hora y por día, canales más usados) y el registro de actividad.
- **Más:**
- **Canales**: todos los servicios.
- Guía de frecuencias y tonos.
Expand Down
1 change: 1 addition & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,7 @@ Antenna: a vertical quarter-wave of **6.5–6.9 cm**, as high as possible. Typic
- The header shows how many recordings there are, the space used against `REC_MAX_MB`, and the free space on the card.
- Recordings are grouped by day, with time, frequency, mode, duration and badges (*auto*/*manual*, CTCSS, DCS, DTMF). Search by frequency, name or tone.
- ▶ plays, ⬇ downloads; select several (or **Select all**) for **⬇ ZIP** or **🗑 Delete**.
- The **Business panel** (above the activity log) charts the log: transmissions per hour and per day, airtime, the busiest channels and the longest transmission, for today, 7 or 30 days.
- The **Activity log** records every transmission the radio or scanner detects, even when nothing is recorded: time, frequency, duration, peak level, tones and digital type. **⬇ CSV (Excel)** exports it; 🎧 returns to that frequency.

Over SSH, `sdrkit recs` shows the folder, the number of files and how to copy them to a PC with `scp`.
Expand Down
66 changes: 65 additions & 1 deletion files/sdrkit_radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"""

import collections
import datetime
import io
import json
import math
Expand Down Expand Up @@ -729,7 +730,7 @@ def snapshot(self):


class ActivityLog:
def __init__(self, path, keep=1000):
def __init__(self, path, keep=5000):
self.path = path
self.lock = threading.Lock()
self.items = collections.deque(maxlen=keep)
Expand Down Expand Up @@ -768,6 +769,67 @@ def clear(self):
except OSError:
pass

def stats(self, days=7, now=None):
with self.lock:
items = list(self.items)
return activity_stats(items, days, now)


def demo_activity(now=None, days=7):
"""Made-up activity for demo mode (shown only in memory), so the business panel has something to draw."""
now = time.time() if now is None else now
chans = [("Canal 1 (FRS/GMRS)", 462562500), ("Taxi", 151625000), ("Canal 16 marina", 156800000), ("Seguridad", 464550000)]
out = []
for d in range(days):
for i in range(18 + (d * 7) % 9):
hour = 7 + (i * 5 + d) % 14
t = now - d * 86400 - ((now % 86400) - hour * 3600) % 86400 + (i * 97) % 3000
if t > now:
continue
name, freq = chans[(i * 3 + d) % 4 if i % 3 else 0]
out.append({"t": round(t, 1), "freq": freq, "mod": "nfm", "name": name, "peak_db": -40.0, "dur": round(2 + (i * 13 + d * 7) % 40 / 2, 1)})
return sorted(out, key=lambda a: a["t"])


def activity_stats(items, days=7, now=None):
"""Summary of the activity log for the business panel: per hour of the day, per day, busiest channels, longest transmission."""
now = time.time() if now is None else now
days = max(1, min(int(days), 90))
today = datetime.date.fromtimestamp(now)
first = today - datetime.timedelta(days=days - 1)
start = time.mktime(first.timetuple())
hours = [{"n": 0, "secs": 0.0} for _ in range(24)]
per_day = {(first + datetime.timedelta(days=i)).isoformat(): {"n": 0, "secs": 0.0} for i in range(days)}
chans, longest, total, secs = {}, None, 0, 0.0
for a in items:
t, dur = a.get("t"), a.get("dur")
if not isinstance(t, (int, float)) or not isinstance(dur, (int, float)) or t < start or t > now + 60:
continue
lt = time.localtime(t)
day = time.strftime("%Y-%m-%d", lt)
if day not in per_day:
continue
total += 1
secs += dur
hours[lt.tm_hour]["n"] += 1
hours[lt.tm_hour]["secs"] += dur
per_day[day]["n"] += 1
per_day[day]["secs"] += dur
key = (a.get("name") or "").strip() or "%.4f" % (a.get("freq", 0) / 1e6)
c = chans.setdefault(key, {"name": a.get("name") or "", "freq": a.get("freq"), "mod": a.get("mod"), "n": 0, "secs": 0.0})
c["n"] += 1
c["secs"] += dur
if longest is None or dur > longest["dur"]:
longest = dict(a)
for h in hours:
h["secs"] = round(h["secs"], 1)
top = sorted(chans.values(), key=lambda c: (-c["n"], -c["secs"]))[:5]
for c in top:
c["secs"] = round(c["secs"], 1)
return {"days": days, "total": total, "secs": round(secs, 1), "hours": hours,
"per_day": [{"day": d, "n": v["n"], "secs": round(v["secs"], 1)} for d, v in per_day.items()],
"top": top, "longest": longest}


def ui_lang(conf=None):
"""Idioma de los textos que crea el servidor (memorias de ejemplo): SDRKIT_LANG o el idioma del sistema."""
Expand Down Expand Up @@ -969,6 +1031,8 @@ def __init__(self, conf, state, open_device, error_cls, folder, demo=False):
max_mb=float(conf.get("REC_MAX_MB", 2000) or 2000),
min_sec=float(conf.get("REC_MIN_SEC", 1.0) or 1.0))
self.activity = ActivityLog(os.path.join(folder, "actividad.jsonl"))
if demo and not self.activity.items:
self.activity.items.extend(demo_activity())
rs = str(conf.get("REPLAY_SEC", "30")).strip()
self.replay = ReplayBuffer(float(rs) if re.match(r"^\d+(\.\d+)?$", rs) else 30.0)
self._clip = None # última repetición pedida: lo que se escuchó es lo que se guarda
Expand Down
4 changes: 3 additions & 1 deletion files/sdrkit_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ def _read_version():
return v
except OSError:
pass
return "3.10.0"
return "3.11.0"


VERSION = _read_version()
Expand Down Expand Up @@ -2333,6 +2333,8 @@ def do_GET(self):
return self._zip(q.get("f") or [])
if path == "/api/activity":
return self._send(200, {"items": app.radio.activity.list(cint((q.get("n") or ["300"])[0], 300))})
if path == "/api/activity/stats":
return self._send(200, app.radio.activity.stats(cint((q.get("days") or ["7"])[0], 7)))
if path == "/api/activity.csv":
return self._activity_csv()
if path == "/api/monitor":
Expand Down
9 changes: 9 additions & 0 deletions files/web/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,15 @@ pre.logs{white-space:pre-wrap;word-break:break-all;font:11px/1.45 var(--mono);ba
.fchip.bad{border-color:#7a3333;color:#ffb3b3}
#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}
.biz-tiles div{background:var(--panel2,rgba(255,255,255,.04));border:1px solid var(--line);border-radius:10px;padding:8px;text-align:center}
.biz-tiles b{display:block;font:700 20px var(--mono);font-variant-numeric:tabular-nums}
.biz-tiles span{color:var(--muted);font-size:11px}
.biz-h{font:600 11px system-ui,sans-serif;text-transform:uppercase;letter-spacing:.5px;color:var(--muted);margin:12px 0 4px}
.biz-chart{display:flex;align-items:flex-end;gap:2px;height:84px;border-bottom:1px solid var(--line)}
.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}
.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}
Expand Down
3 changes: 2 additions & 1 deletion files/web/help.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions files/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,16 @@ <h3>Grabaciones</h3>
<div id="recList"></div>
</div>
<div>
<div class="card" id="bizCard">
<h3>Panel del negocio</h3>
<p class="muted small">Cuánto se usa la radio: transmisiones por hora y por día, los canales más activos y la transmisión más larga.</p>
<div class="seg" id="bizDays" style="margin-bottom:10px">
<button data-d="1">Hoy</button>
<button data-d="7" class="active">7 días</button>
<button data-d="30">30 días</button>
</div>
<div id="bizBody"></div>
</div>
<div class="card">
<h3>Registro de actividad</h3>
<p class="muted small">Cada vez que alguien transmite (en el radio o el escáner) queda anotado: hora, frecuencia, duración, nivel y tonos.</p>
Expand Down
1 change: 1 addition & 0 deletions files/web/lang-en-help.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ I18N.data.help = {
u: ["In the panel: DMR, P25, D-Star, Fusion, NXDN, dPMR and M17 with DSD-FME (optional).", "In the advanced receiver: the same modes with an AMBE3000 USB stick (the licensed route).",
"Impossible with any equipment: encrypted voice (AES, ARC4, TETRA with TEA), cell phones, encrypted WiFi or Bluetooth."]},
grabaciones: {t: "Recordings", p: "All recorded audio, by day, with frequency, duration and tone.", u: ["▶ plays, ⬇ downloads; select several to download a ZIP or delete them.", "Search by frequency, name or tone."]},
negocio: {t: "Business panel", p: "Summarizes the activity log: how many transmissions there were, at what hours and on which days, which channels are used most and which transmission was the longest.", u: ["Choose Today, 7 days or 30 days.", "The bars show the number of transmissions; tap a bar (or hold it) to see the value.", "🎧 on a channel goes back to that frequency."], c: "It is computed from the activity log: if you clear it, the charts start from zero. Demo mode uses sample data."},
actividad: {t: "Activity log", p: "Every transmission the radio or the scanner detects is logged even if you're not recording: time, frequency, duration, level, tone and digital type.", u: ["Download it as CSV for Excel.", "🎧 goes back to that frequency."]},
paises: {t: "Your country", p: "Choose your country and the panel shows what applies there: which license-free walkie-talkies are used, the ham bands, where the repeater outputs are and which tones they usually use.",
u: ["Your country's own services come first in the list below; other countries' services are dimmed.", "“📡 Search” scans that band's repeater outputs: the scanner stops on each one that is active.",
Expand Down
5 changes: 5 additions & 0 deletions files/web/lang-en-html.js
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,11 @@ I18N.add({
"Seleccionar todo": "Select all",
"🗑 Borrar": "🗑 Delete",
"Registro de actividad": "Activity log",
"Panel del negocio": "Business panel",
"Cuánto se usa la radio: transmisiones por hora y por día, los canales más activos y la transmisión más larga.": "How much the radio is used: transmissions per hour and per day, the busiest channels and the longest transmission.",
"Hoy": "Today",
"7 días": "7 days",
"30 días": "30 days",
"Cada vez que alguien transmite (en el radio o el escáner) queda anotado: hora, frecuencia, duración, nivel y tonos.":
"Every time someone transmits (on the radio or the scanner) it is logged: time, frequency, duration, level and tones.",
"Borrar registro": "Clear log",
Expand Down
10 changes: 10 additions & 0 deletions files/web/lang-en-radio.js
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ I18N.add({
// ---------------------------------------------------------------- recs.js · recordings and activity log
"{n} grabación · {used} de {max} · libre en la microSD: {free}": "{n} recording · {used} of {max} · free on the microSD: {free}",
"{n} grabaciones · {used} de {max} · libre en la microSD: {free}": "{n} recordings · {used} of {max} · free on the microSD: {free}",
"Todavía no hay actividad en este período.": "No activity in this period yet.",
"transmisiones": "transmissions",
"en el aire": "on the air",
"hora más activa": "busiest hour",
"Por hora del día": "By hour of the day",
"Por día": "By day",
"Canales más usados": "Busiest channels",
"Transmisión más larga": "Longest transmission",
"{n} transmisión": "{n} transmission",
"{n} transmisiones": "{n} transmissions",
"Nada coincide con la búsqueda.": "Nothing matches your search.",
"Todavía no hay grabaciones. En la pestaña Radio pulsa «Grabar ahora» o activa «Grabar automáticamente cada transmisión».":
"No recordings yet. On the Radio tab tap “Record now” or turn on “Automatically record every transmission”.",
Expand Down
Loading
Loading