Skip to content

Commit b3b7da8

Browse files
authored
Patch (#33)
1 parent 7062f4d commit b3b7da8

14 files changed

Lines changed: 107 additions & 87 deletions

File tree

radioshaq/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,33 @@ Then open **http://localhost:8000/** for the web UI and **http://localhost:8000/
121121

122122
**Remote receiver (SDR):** For listen-only stations (e.g. Raspberry Pi + RTL-SDR) that stream to HQ, run `radioshaq run-receiver` after the same install. Set `JWT_SECRET`, `STATION_ID`, `HQ_URL`; optionally `pip install radioshaq[sdr]` or `radioshaq[hackrf]` for hardware support. HQ accepts uploads at `POST /receiver/upload`.
123123

124+
### HackRF on Windows
125+
126+
The `python-hackrf` package needs the **HackRF SDK** (headers and DLLs) at build time. By default it looks for `C:\Program Files\HackRF\include\hackrf.h` and `C:\Program Files\HackRF\lib\`.
127+
128+
1. **Install the HackRF SDK for Windows** (pick one):
129+
- **Prebuilt (easiest):** Download a Windows build from [greatscottgadgets/hackrf Actions](https://github.com/greatscottgadgets/hackrf/actions) (log in, pick a successful run, download the Windows artifact). Or check [python_hackrf Releases](https://github.com/GvozdevLeonid/python_hackrf/releases) for a ZIP that contains `include/` and `lib/`.
130+
- **Extract** so you have:
131+
- `C:\Program Files\HackRF\include\hackrf.h`
132+
- `C:\Program Files\HackRF\lib\` with `hackrf.dll`, `hackrf.lib` (MSVC), and dependencies (e.g. `libusb-1.0.dll`, `pthreadVC2.dll`).
133+
- **Or build from source:** See [HackRF docs – Windows: Building From Source](https://hackrf.readthedocs.io/en/latest/installing_hackrf_software.html) (Visual Studio, CMake, vcpkg). Then copy the built `include/` and `lib/` (or `.dll`/`.lib`) into `C:\Program Files\HackRF\` or set the env vars below.
134+
135+
2. **Custom install path:** If you put HackRF elsewhere, set before building:
136+
```powershell
137+
$env:PYTHON_HACKRF_INCLUDE_PATH = "C:\path\to\hackrf\include"
138+
$env:PYTHON_HACKRF_LIB_PATH = "C:\path\to\hackrf\lib"
139+
$env:HACKRF_LIB_DIR = "C:\path\to\hackrf\lib"
140+
```
141+
142+
3. **Install the hackrf extra:** In this repo the `hackrf` extra is skipped on Windows by default so `uv sync --all-extras` doesn’t fail. After the SDK is in place, install the binding explicitly:
143+
```powershell
144+
uv pip install python-hackrf
145+
# Or add hackrf to the project and remove the Windows-only marker in pyproject.toml, then:
146+
# uv sync --extra hackrf
147+
```
148+
149+
**Driver:** For the device itself, use [Zadig](https://zadig.akeo.ie/) to install the WinUSB driver for HackRF One. Alternatively, [RadioConda](https://github.com/ryanvolz/radioconda) provides HackRF binaries and a Conda environment on Windows (use that Python/conda if you prefer).
150+
124151
## Development
125152

126153
Install dependencies first (from the **radioshaq** directory): `uv sync --extra dev --extra test`. Then use `uv run` for all commands below so the correct environment (with geoalchemy2, loguru, etc.) is used.

radioshaq/pyproject.toml

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
[project]
22
name = "radioshaq"
3-
version = "0.1.2"
3+
version = "0.1.3"
44
description = "RadioShaq: Ham Radio AI Orchestration and Remote SDR Reception System"
55
readme = "../.github/PYPI_README.md"
66
requires-python = ">=3.11"
77
license = {text = "GPL-2.0-only"}
8-
license-files = ["../LICENSE.md"]
8+
license-files = ["LICENSE.md"]
99
authors = [
1010
{name = "RadioShaq Contributors"}
1111
]
@@ -105,8 +105,11 @@ test = [
105105
sdr = [
106106
"pyrtlsdr>=0.4",
107107
]
108+
# python-hackrf requires HackRF SDK (hackrf.h) at build time; SDK not commonly
109+
# installed on Windows. Install only on non-Windows; use hackrf on Linux/macOS or
110+
# install HackRF for Windows and sync without --all-extras then add hackrf manually.
108111
hackrf = [
109-
"python-hackrf>=1.5",
112+
"python-hackrf>=1.5; sys_platform != 'win32'",
110113
]
111114

112115
# Optional: Hindsight semantic memory. Requires urllib3>=2 (conflicts with boto3 urllib3<2).

radioshaq/radioshaq/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
from __future__ import annotations
99

10-
__version__ = "0.1.2"
10+
__version__ = "0.1.3"
1111
__logo__ = "📡"
1212
__description__ = "Strategic Autonomous Ham Radio and Knowledge Operations Dispatch System"
1313

radioshaq/radioshaq/api/routes/callsigns.py

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212
from radioshaq.auth.jwt import TokenPayload
1313
from radioshaq.compliance_plugin import get_band_plan_source_for_config
1414
from radioshaq.config.schema import Config
15+
from radioshaq.constants import EXPLICIT_CONSENT_REGIONS
1516
from radioshaq.radio.bands import BAND_PLANS
17+
from radioshaq.utils.phone import normalize_e164
1618

1719
router = APIRouter()
1820

@@ -38,14 +40,6 @@ class PatchCallsignBandsBody(BaseModel):
3840
E164_PATTERN = re.compile(r"^\+?[0-9]{10,15}$")
3941

4042

41-
def _normalize_e164(phone: str) -> str:
42-
"""Normalize to E.164: digits only with + prefix."""
43-
digits = re.sub(r"\D", "", (phone or "").strip())
44-
if not digits:
45-
return ""
46-
return "+" + digits
47-
48-
4943
class PatchContactPreferencesBody(BaseModel):
5044
"""Body for PATCH /callsigns/registered/{callsign}/contact-preferences (§8.1)."""
5145

@@ -208,7 +202,7 @@ async def register_from_audio(
208202
Path(temp_path).unlink(missing_ok=True)
209203

210204

211-
@router.patch("/registered/{callsign:path}")
205+
@router.patch("/registered/{callsign}")
212206
async def patch_callsign_bands(
213207
request: Request,
214208
callsign: str,
@@ -240,7 +234,7 @@ async def patch_callsign_bands(
240234
return {"ok": True, "callsign": normalized, "preferred_bands": bands}
241235

242236

243-
@router.get("/registered/{callsign:path}/contact-preferences")
237+
@router.get("/registered/{callsign}/contact-preferences")
244238
async def get_contact_preferences(
245239
request: Request,
246240
callsign: str,
@@ -260,10 +254,10 @@ async def get_contact_preferences(
260254

261255
def _require_explicit_consent_region(region: str) -> bool:
262256
"""True if region requires explicit consent (EU/UK/ZA)."""
263-
return (region or "").upper() in ("CEPT", "FR", "UK", "ES", "BE", "CH", "LU", "MC", "ZA")
257+
return (region or "").strip().upper() in EXPLICIT_CONSENT_REGIONS
264258

265259

266-
@router.patch("/registered/{callsign:path}/contact-preferences")
260+
@router.patch("/registered/{callsign}/contact-preferences")
267261
async def patch_contact_preferences(
268262
request: Request,
269263
callsign: str,
@@ -290,7 +284,7 @@ async def patch_contact_preferences(
290284
if body.notify_sms_phone is not None:
291285
raw = (body.notify_sms_phone or "").strip()
292286
if raw:
293-
sms_phone = _normalize_e164(raw)
287+
sms_phone = normalize_e164(raw)
294288
if not E164_PATTERN.match(sms_phone):
295289
raise HTTPException(status_code=400, detail="notify_sms_phone must be E.164 (10–15 digits)")
296290
else:
@@ -299,7 +293,7 @@ async def patch_contact_preferences(
299293
if body.notify_whatsapp_phone is not None:
300294
raw = (body.notify_whatsapp_phone or "").strip()
301295
if raw:
302-
whatsapp_phone = _normalize_e164(raw)
296+
whatsapp_phone = normalize_e164(raw)
303297
if not E164_PATTERN.match(whatsapp_phone):
304298
raise HTTPException(status_code=400, detail="notify_whatsapp_phone must be E.164 (10–15 digits)")
305299
else:
@@ -326,7 +320,7 @@ async def patch_contact_preferences(
326320
return prefs or {}
327321

328322

329-
@router.delete("/registered/{callsign:path}")
323+
@router.delete("/registered/{callsign}")
330324
async def unregister_callsign(
331325
request: Request,
332326
callsign: str,

radioshaq/radioshaq/api/routes/emergency.py

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,13 @@
1616
from radioshaq.auth.jwt import TokenPayload
1717
from radioshaq.config.schema import Config
1818
from radioshaq.messaging_compliance import emergency_messaging_allowed
19+
from radioshaq.utils.phone import normalize_e164
1920

2021
router = APIRouter()
2122

2223
E164_PATTERN = re.compile(r"^\+?[0-9]{10,15}$")
2324

2425

25-
def _normalize_e164(phone: str) -> str:
26-
digits = re.sub(r"\D", "", (phone or "").strip())
27-
return "+" + digits if digits else ""
28-
29-
3026
class EmergencyRequestBody(BaseModel):
3127
"""Body for POST /emergency/request."""
3228

@@ -67,7 +63,7 @@ async def create_emergency_request(
6763
)
6864
if body.contact_channel not in ("sms", "whatsapp"):
6965
raise HTTPException(status_code=400, detail="contact_channel must be sms or whatsapp")
70-
phone = _normalize_e164(body.contact_phone)
66+
phone = normalize_e164(body.contact_phone)
7167
if not E164_PATTERN.match(phone):
7268
raise HTTPException(status_code=400, detail="contact_phone must be E.164 (10–15 digits)")
7369
initiator = getattr(_user, "callsign", None) or getattr(_user, "sub", "api")
@@ -151,13 +147,14 @@ async def list_emergency_events(
151147
status: str | None = None,
152148
_user: TokenPayload = Depends(get_current_user),
153149
) -> dict[str, Any]:
154-
"""List coordination events with event_type=emergency. Optional filter by status."""
150+
"""List coordination events with event_type=emergency. Optional filter by status (e.g. pending, approved, rejected)."""
155151
db = getattr(request.app.state, "db", None)
156152
if db is None or not hasattr(db, "get_pending_coordination_events"):
157153
return {"events": [], "count": 0}
158-
events = await db.get_pending_coordination_events(max_results=1000, event_type="emergency")
159-
if status:
160-
events = [e for e in events if e.get("status") == status]
154+
status_filter = status if status else "pending"
155+
events = await db.get_pending_coordination_events(
156+
max_results=1000, event_type="emergency", status=status_filter
157+
)
161158
return {"events": events, "count": len(events)}
162159

163160

@@ -234,15 +231,15 @@ async def reject_emergency_event(
234231
) -> dict[str, Any]:
235232
"""Reject an emergency event (do not send). Sets status=rejected and records rejected_at, rejected_by."""
236233
db = getattr(request.app.state, "db", None)
237-
if db is None or not hasattr(db, "get_coordination_event_by_id") or not hasattr(db, "update_coordination_event"):
234+
if db is None or not hasattr(db, "claim_emergency_event_pending") or not hasattr(db, "get_coordination_event_by_id") or not hasattr(db, "update_coordination_event"):
238235
raise HTTPException(status_code=503, detail="Database not available")
236+
claimed = await db.claim_emergency_event_pending(event_id)
237+
if claimed is None:
238+
raise HTTPException(status_code=400, detail="Event already processed")
239239
event = await db.get_coordination_event_by_id(event_id)
240-
if not event:
241-
raise HTTPException(status_code=404, detail="Event not found")
242-
if event.get("event_type") != "emergency":
240+
if not event or event.get("event_type") != "emergency":
241+
await db.update_coordination_event(event_id, status="pending")
243242
raise HTTPException(status_code=400, detail="Not an emergency event")
244-
if event.get("status") != "pending":
245-
raise HTTPException(status_code=400, detail="Event already processed")
246243
rejector = getattr(_user, "sub", None) or getattr(_user, "callsign", "api")
247244
now = datetime.now(timezone.utc).isoformat()
248245
extra = event.get("extra_data") or {}

radioshaq/radioshaq/constants.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22

33
from __future__ import annotations
44

5+
# Regions that require explicit consent for notify-on-relay (§8.1, §8.3)
6+
EXPLICIT_CONSENT_REGIONS: frozenset[str] = frozenset(
7+
("CEPT", "FR", "UK", "ES", "BE", "CH", "LU", "MC", "ZA")
8+
)
9+
510
# ASR (Voxtral) languages supported for UI and validation (en, fr, es)
611
ASR_SUPPORTED_LANGUAGE_CODES: tuple[str, ...] = ("en", "fr", "es")
712
ASR_LANGUAGE_AUTO = "auto"

radioshaq/radioshaq/database/postgres_gis.py

Lines changed: 18 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -567,25 +567,27 @@ async def get_pending_coordination_events(
567567
callsign: str | None = None,
568568
event_type: str | None = None,
569569
max_results: int = 100,
570+
status: str | None = "pending",
570571
) -> list[dict[str, Any]]:
571-
"""Get pending coordination events.
572+
"""Get coordination events, optionally filtered by status.
572573
573574
Args:
574575
callsign: Filter by callsign (initiator or target)
575576
event_type: Filter by event_type (e.g. emergency)
576577
max_results: Maximum results
578+
status: Filter by status (default "pending"). Pass None to get all statuses.
577579
578580
Returns:
579581
List of event dicts
580582
"""
581583
async with self.async_session() as session:
582584
query = (
583585
select(CoordinationEvent)
584-
.where(CoordinationEvent.status == "pending")
585586
.order_by(CoordinationEvent.priority, CoordinationEvent.scheduled_time)
586587
.limit(max_results)
587588
)
588-
589+
if status is not None:
590+
query = query.where(CoordinationEvent.status == status)
589591
if callsign:
590592
callsign_upper = callsign.upper()
591593
query = query.where(
@@ -835,25 +837,26 @@ async def record_opt_out(self, callsign: str, channel: str) -> bool:
835837
return True
836838

837839
async def record_opt_out_by_phone(self, phone: str, channel: str) -> bool:
838-
"""Record opt-out by phone number (finds callsign by notify_sms_phone or notify_whatsapp_phone). Returns True if updated."""
840+
"""Record opt-out by phone number. Opts out all callsigns with this phone. Returns True if at least one row was updated."""
839841
phone = (phone or "").strip()
840842
if not phone or channel not in ("sms", "whatsapp"):
841843
return False
842844
async with self.async_session() as session:
843845
col = RegisteredCallsign.notify_sms_phone if channel == "sms" else RegisteredCallsign.notify_whatsapp_phone
844-
result = await session.execute(select(RegisteredCallsign).where(col == phone))
845-
row = result.scalar_one_or_none()
846-
if not row:
847-
return False
846+
opt_out_col = (
847+
RegisteredCallsign.notify_opt_out_at_sms if channel == "sms" else RegisteredCallsign.notify_opt_out_at_whatsapp
848+
)
848849
now = datetime.now(timezone.utc)
849-
if channel == "sms":
850-
row.notify_opt_out_at_sms = now
851-
row.notify_sms_phone = None
852-
else:
853-
row.notify_opt_out_at_whatsapp = now
854-
row.notify_whatsapp_phone = None
850+
stmt = (
851+
update(RegisteredCallsign)
852+
.where(col == phone)
853+
.values({opt_out_col: now, col: None})
854+
.returning(RegisteredCallsign.id)
855+
)
856+
result = await session.execute(stmt)
857+
updated_ids = result.scalars().all()
855858
await session.commit()
856-
return True
859+
return len(updated_ids) > 0
857860

858861
async def unregister_callsign(self, callsign: str) -> bool:
859862
"""Remove a callsign from the registry. Returns True if a row was deleted."""

radioshaq/radioshaq/listener/relay_delivery.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@
88
from loguru import logger
99

1010
from radioshaq.compliance_plugin import get_band_plan_source_for_config
11+
from radioshaq.constants import EXPLICIT_CONSENT_REGIONS
1112
from radioshaq.radio.bands import BAND_PLANS
1213
from radioshaq.radio.injection import get_injection_queue
1314

14-
# Regions that require explicit consent for notify-on-relay (§8.1, §8.3)
15-
_EXPLICIT_CONSENT_REGIONS = frozenset(("CEPT", "FR", "UK", "ES", "BE", "CH", "LU", "MC", "ZA"))
16-
1715

1816
def _is_consent_valid_for_region(region: str | None, prefs: dict[str, Any]) -> bool:
1917
"""True if contact preferences have valid consent for this region."""
@@ -23,7 +21,7 @@ def _is_consent_valid_for_region(region: str | None, prefs: dict[str, Any]) -> b
2321
if not region:
2422
return True
2523
region_upper = (region or "").strip().upper()
26-
if region_upper in _EXPLICIT_CONSENT_REGIONS:
24+
if region_upper in EXPLICIT_CONSENT_REGIONS:
2725
return bool(consent_at)
2826
return True
2927

radioshaq/radioshaq/specialized/sms_agent.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,12 @@
22

33
from __future__ import annotations
44

5-
import re
65
from typing import Any
76

87
from loguru import logger
98

109
from radioshaq.specialized.base import SpecializedAgent
11-
12-
13-
def _e164(phone: str) -> str:
14-
"""Normalize to E.164-like form: strip all non-digit characters and ensure + prefix."""
15-
s = (phone or "").strip()
16-
if not s:
17-
return ""
18-
digits = re.sub(r"\D", "", s)
19-
if not digits:
20-
return ""
21-
return "+" + digits
10+
from radioshaq.utils.phone import normalize_e164
2211

2312

2413
class SMSAgent(SpecializedAgent):
@@ -60,7 +49,7 @@ async def _send(
6049
self, task: dict[str, Any], upstream_callback: Any
6150
) -> dict[str, Any]:
6251
"""Send SMS via Twilio. Phone numbers are normalized to E.164."""
63-
to = _e164(task.get("to") or "")
52+
to = normalize_e164(task.get("to") or "")
6453
body = task.get("message", "") or task.get("body", "")
6554

6655
await self.emit_progress(upstream_callback, "sending", to=to)
@@ -78,7 +67,7 @@ async def _send(
7867
try:
7968
msg = self.twilio_client.messages.create(
8069
body=body,
81-
from_=_e164(self.from_number),
70+
from_=normalize_e164(self.from_number),
8271
to=to,
8372
)
8473
result = {

0 commit comments

Comments
 (0)