Compare commits
11 Commits
signal-vie
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| dd305aabeb | |||
| 816bc84f9a | |||
| b27b04dbc0 | |||
| 53f912f21a | |||
| 543517f0ca | |||
| ba1804a5f9 | |||
| febb1bd6cf | |||
| 5f68fd936d | |||
| 99447a581a | |||
| 2f6b5ced18 | |||
| eb5b4ce839 |
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -1,5 +1,24 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.1.7] - 2026-05-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Human-readable agent names** — `ria-agent register` now generates a default `adjective-colour-animal` name (e.g. `swift-teal-falcon`) via the new `namegen` module when `--name` is omitted, instead of registering with an empty string.
|
||||||
|
- **Structured registration error messages** — `ria-agent register` translates hub responses into actionable English for the known failure reasons (`invalid_key`, `expired`, `revoked`, `already_consumed`) and rate-limit (`HTTP 429`) responses, instead of surfacing raw `HTTP 4xx` text.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **`ria-agent register` `--api-key` help** — now describes the personal `ria_reg_*` registration key flow (minted from **Settings → RIA Agents** on the hub, shown once at mint time). The legacy shared `[wac] API_KEY` is still accepted by the hub for back-compat, but the CLI documents the per-user flow as preferred.
|
||||||
|
- **`ria-agent register` success output** — now prints both the hub-assigned agent ID and the chosen name: `Registered agent: <id> (<name>)`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`ria-agent register` blocked by Cloudflare on hubs behind it** — set an explicit `User-Agent` (`ria-agent/<package-version> (+https://riahub.ai/qoherent/ria-toolkit-oss)`) so the request isn't rejected as `Python-urllib/<ver>` (Cloudflare Browser Integrity Check returns HTTP 403, edge error code 1010). Version is read from package metadata so it tracks releases automatically.
|
||||||
|
- **`ria-agent register` could hang indefinitely** — added a 15-second timeout to the hub request; previously `urllib`'s default of no timeout meant a stuck hub would block the CLI forever.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [0.1.0] - 2026-02-20
|
## [0.1.0] - 2026-02-20
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ sys.path.insert(0, os.path.abspath(os.path.join('..', '..')))
|
||||||
project = 'ria-toolkit-oss'
|
project = 'ria-toolkit-oss'
|
||||||
copyright = '2026, Qoherent Inc'
|
copyright = '2026, Qoherent Inc'
|
||||||
author = 'Qoherent Inc.'
|
author = 'Qoherent Inc.'
|
||||||
release = '0.1.6'
|
release = '0.1.7'
|
||||||
|
|
||||||
# -- General configuration ---------------------------------------------------
|
# -- General configuration ---------------------------------------------------
|
||||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||||
|
|
|
||||||
4
poetry.lock
generated
4
poetry.lock
generated
|
|
@ -1,4 +1,4 @@
|
||||||
# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "alabaster"
|
name = "alabaster"
|
||||||
|
|
@ -1264,7 +1264,7 @@ files = [
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
attrs = ">=22.2.0"
|
attrs = ">=22.2.0"
|
||||||
jsonschema-specifications = ">=2023.3.6"
|
jsonschema-specifications = ">=2023.03.6"
|
||||||
referencing = ">=0.28.4"
|
referencing = ">=0.28.4"
|
||||||
rpds-py = ">=0.25.0"
|
rpds-py = ">=0.25.0"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[project]
|
[project]
|
||||||
name = "ria-toolkit-oss"
|
name = "ria-toolkit-oss"
|
||||||
version = "0.1.6"
|
version = "0.1.7"
|
||||||
description = "An open-source version of the RIA Toolkit, including the fundamental tools to get started developing, testing, and deploying radio intelligence applications"
|
description = "An open-source version of the RIA Toolkit, including the fundamental tools to get started developing, testing, and deploying radio intelligence applications"
|
||||||
license = { text = "AGPL-3.0-only" }
|
license = { text = "AGPL-3.0-only" }
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,11 @@ Subcommands:
|
||||||
- ``ria-agent run [legacy args]`` — legacy long-poll NodeAgent (unchanged).
|
- ``ria-agent run [legacy args]`` — legacy long-poll NodeAgent (unchanged).
|
||||||
- ``ria-agent stream`` — new WebSocket-based IQ streamer.
|
- ``ria-agent stream`` — new WebSocket-based IQ streamer.
|
||||||
- ``ria-agent detect`` — print SDR drivers whose modules import cleanly.
|
- ``ria-agent detect`` — print SDR drivers whose modules import cleanly.
|
||||||
- ``ria-agent register --hub URL --api-key KEY`` — register with the hub and
|
- ``ria-agent register --hub URL --api-key KEY`` — register with the hub
|
||||||
save credentials (and optional TX interlocks) to ``~/.ria/agent.json``.
|
using a personal registration key (minted from **Settings → RIA Agents**
|
||||||
|
on the hub, shown once at mint time) and save credentials (and optional
|
||||||
|
TX interlocks) to ``~/.ria/agent.json``. The hub also accepts the legacy
|
||||||
|
shared ``[wac] API_KEY`` for back-compat, but that path is deprecated.
|
||||||
|
|
||||||
Invoking ``ria-agent`` with no subcommand falls through to the legacy
|
Invoking ``ria-agent`` with no subcommand falls through to the legacy
|
||||||
long-poll behavior for back-compatibility with existing deployments.
|
long-poll behavior for back-compatibility with existing deployments.
|
||||||
|
|
@ -28,6 +31,79 @@ from .namegen import generate_agent_name
|
||||||
_LEGACY_ALIASES = {"--hub", "--key", "--name", "--device", "--insecure", "--log-level", "--config"}
|
_LEGACY_ALIASES = {"--hub", "--key", "--name", "--device", "--insecure", "--log-level", "--config"}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_agent() -> str:
|
||||||
|
"""Build the User-Agent header for hub requests.
|
||||||
|
|
||||||
|
Set explicitly so we don't fall back to Python's default `Python-urllib/<ver>`,
|
||||||
|
which is blocked by Cloudflare's Browser Integrity Check on `riahub.ai`
|
||||||
|
(HTTP 403 edge code 1010). Version is read from package metadata so it
|
||||||
|
tracks releases instead of going stale.
|
||||||
|
"""
|
||||||
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
|
|
||||||
|
try:
|
||||||
|
pkg_version = version("ria-toolkit-oss")
|
||||||
|
except PackageNotFoundError:
|
||||||
|
pkg_version = "unknown"
|
||||||
|
return f"ria-agent/{pkg_version} (+https://riahub.ai/qoherent/ria-toolkit-oss)"
|
||||||
|
|
||||||
|
|
||||||
|
# How long to wait on the hub before giving up. The register endpoint is a
|
||||||
|
# small DB lookup + insert; anything past this is a stuck hub, not a slow one.
|
||||||
|
_REGISTER_TIMEOUT_S = 15
|
||||||
|
|
||||||
|
|
||||||
|
REGISTRATION_REASON_MESSAGES = {
|
||||||
|
"invalid_key": (
|
||||||
|
"Registration key not recognized. Generate a fresh key from "
|
||||||
|
"Settings → RIA Agents on the hub."
|
||||||
|
),
|
||||||
|
"expired": (
|
||||||
|
"This registration key has expired. Generate a new one from "
|
||||||
|
"Settings → RIA Agents on the hub."
|
||||||
|
),
|
||||||
|
"revoked": (
|
||||||
|
"This registration key was revoked. Generate a new one from "
|
||||||
|
"Settings → RIA Agents on the hub."
|
||||||
|
),
|
||||||
|
"already_consumed": (
|
||||||
|
"This single-use registration key has already been used. "
|
||||||
|
"Generate a new one, or mint a reusable key instead."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _explain_registration_failure(status: int, body: bytes) -> str:
|
||||||
|
"""Return a human-readable explanation for a failed register call."""
|
||||||
|
try:
|
||||||
|
parsed = json.loads(body) if body else None
|
||||||
|
except ValueError:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
if status == 429:
|
||||||
|
# 429 carries a plain string detail, never a reason code.
|
||||||
|
if isinstance(parsed, dict) and parsed.get("detail"):
|
||||||
|
detail = parsed["detail"]
|
||||||
|
else:
|
||||||
|
detail = body.decode("utf-8", "replace") or "rate limited"
|
||||||
|
return f"Registration rate-limited by the hub: {detail}"
|
||||||
|
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
text = body.decode("utf-8", "replace")
|
||||||
|
return f"HTTP {status}: {text or 'no body'}"
|
||||||
|
|
||||||
|
detail = parsed.get("detail")
|
||||||
|
if isinstance(detail, dict):
|
||||||
|
reason = detail.get("reason")
|
||||||
|
if reason in REGISTRATION_REASON_MESSAGES:
|
||||||
|
return REGISTRATION_REASON_MESSAGES[reason]
|
||||||
|
if reason:
|
||||||
|
return f"Registration rejected ({reason})"
|
||||||
|
if isinstance(detail, str) and detail:
|
||||||
|
return f"Registration rejected: {detail}"
|
||||||
|
return f"HTTP {status}: {parsed}"
|
||||||
|
|
||||||
|
|
||||||
def _cmd_detect(_args: argparse.Namespace) -> int:
|
def _cmd_detect(_args: argparse.Namespace) -> int:
|
||||||
devices = available_devices()
|
devices = available_devices()
|
||||||
if not devices:
|
if not devices:
|
||||||
|
|
@ -39,6 +115,7 @@ def _cmd_detect(_args: argparse.Namespace) -> int:
|
||||||
|
|
||||||
|
|
||||||
def _cmd_register(args: argparse.Namespace) -> int:
|
def _cmd_register(args: argparse.Namespace) -> int:
|
||||||
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
hub_url = args.hub.rstrip("/")
|
hub_url = args.hub.rstrip("/")
|
||||||
|
|
@ -51,11 +128,20 @@ def _cmd_register(args: argparse.Namespace) -> int:
|
||||||
headers={
|
headers={
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"X-API-Key": args.api_key,
|
"X-API-Key": args.api_key,
|
||||||
|
"User-Agent": _user_agent(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(req) as resp:
|
with urllib.request.urlopen(req, timeout=_REGISTER_TIMEOUT_S) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
err_body = e.read()
|
||||||
|
except Exception:
|
||||||
|
err_body = b""
|
||||||
|
msg = _explain_registration_failure(e.code, err_body)
|
||||||
|
print(f"error: registration failed: {msg}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"error: registration failed: {e}", file=sys.stderr)
|
print(f"error: registration failed: {e}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
@ -80,7 +166,7 @@ def _cmd_register(args: argparse.Namespace) -> int:
|
||||||
cfg.tx_allowed_freq_ranges = [[float(lo), float(hi)] for lo, hi in freq_ranges]
|
cfg.tx_allowed_freq_ranges = [[float(lo), float(hi)] for lo, hi in freq_ranges]
|
||||||
path = _config.save(cfg)
|
path = _config.save(cfg)
|
||||||
|
|
||||||
print(f"Registered agent: {agent_id}")
|
print(f"Registered agent: {agent_id} ({name})")
|
||||||
if cfg.tx_enabled:
|
if cfg.tx_enabled:
|
||||||
caps: list[str] = []
|
caps: list[str] = []
|
||||||
if cfg.tx_max_gain_db is not None:
|
if cfg.tx_max_gain_db is not None:
|
||||||
|
|
@ -141,7 +227,16 @@ def main() -> None:
|
||||||
|
|
||||||
p_reg = sub.add_parser("register", help="Register agent with RIA Hub and save credentials")
|
p_reg = sub.add_parser("register", help="Register agent with RIA Hub and save credentials")
|
||||||
p_reg.add_argument("--hub", required=True, help="RIA Hub URL (e.g. http://whitehorse:3005)")
|
p_reg.add_argument("--hub", required=True, help="RIA Hub URL (e.g. http://whitehorse:3005)")
|
||||||
p_reg.add_argument("--api-key", dest="api_key", required=True, help="Hub API key")
|
p_reg.add_argument(
|
||||||
|
"--api-key",
|
||||||
|
dest="api_key",
|
||||||
|
required=True,
|
||||||
|
help=(
|
||||||
|
"Personal registration key from the RIA Agents page on the hub "
|
||||||
|
"(format: ria_reg_...). Shown once when generated; save it then. "
|
||||||
|
"The legacy shared API key is also accepted but deprecated."
|
||||||
|
),
|
||||||
|
)
|
||||||
p_reg.add_argument("--name", default=None, help="Human-friendly agent name")
|
p_reg.add_argument("--name", default=None, help="Human-friendly agent name")
|
||||||
p_reg.add_argument("--insecure", action="store_true", help="Skip TLS verification")
|
p_reg.add_argument("--insecure", action="store_true", help="Skip TLS verification")
|
||||||
p_reg.add_argument(
|
p_reg.add_argument(
|
||||||
|
|
|
||||||
|
|
@ -45,14 +45,7 @@ def is_annotation_contained(inner: Annotation, outer: Annotation) -> bool:
|
||||||
outer_sample_stop = outer.sample_start + outer.sample_count
|
outer_sample_stop = outer.sample_start + outer.sample_count
|
||||||
|
|
||||||
if inner.sample_start > outer.sample_start and inner_sample_stop < outer_sample_stop:
|
if inner.sample_start > outer.sample_start and inner_sample_stop < outer_sample_stop:
|
||||||
if (
|
if inner.freq_lower_edge > outer.freq_lower_edge and inner.freq_upper_edge < outer.freq_upper_edge:
|
||||||
inner.freq_lower_edge is not None
|
|
||||||
and inner.freq_upper_edge is not None
|
|
||||||
and outer.freq_lower_edge is not None
|
|
||||||
and outer.freq_upper_edge is not None
|
|
||||||
and inner.freq_lower_edge > outer.freq_lower_edge
|
|
||||||
and inner.freq_upper_edge < outer.freq_upper_edge
|
|
||||||
):
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ class Annotation:
|
||||||
:type sample_start: int
|
:type sample_start: int
|
||||||
:param sample_count: The index of the ending sample of the annotation, inclusive.
|
:param sample_count: The index of the ending sample of the annotation, inclusive.
|
||||||
:type sample_count: int
|
:type sample_count: int
|
||||||
:param freq_lower_edge: The lower frequency of the annotation. Optional; None if not specified in source.
|
:param freq_lower_edge: The lower frequency of the annotation.
|
||||||
:type freq_lower_edge: float, optional
|
:type freq_lower_edge: float
|
||||||
:param freq_upper_edge: The upper frequency of the annotation. Optional; None if not specified in source.
|
:param freq_upper_edge: The upper frequency of the annotation.
|
||||||
:type freq_upper_edge: float, optional
|
:type freq_upper_edge: float
|
||||||
:param label: The label that will be displayed with the bounding box in compatible viewers including IQEngine.
|
:param label: The label that will be displayed with the bounding box in compatible viewers including IQEngine.
|
||||||
Defaults to an emtpy string.
|
Defaults to an emtpy string.
|
||||||
:type label: str, optional
|
:type label: str, optional
|
||||||
|
|
@ -34,8 +34,8 @@ class Annotation:
|
||||||
self,
|
self,
|
||||||
sample_start: int,
|
sample_start: int,
|
||||||
sample_count: int,
|
sample_count: int,
|
||||||
freq_lower_edge: Optional[float] = None,
|
freq_lower_edge: float,
|
||||||
freq_upper_edge: Optional[float] = None,
|
freq_upper_edge: float,
|
||||||
label: Optional[str] = "",
|
label: Optional[str] = "",
|
||||||
comment: Optional[str] = "",
|
comment: Optional[str] = "",
|
||||||
detail: Optional[dict] = None,
|
detail: Optional[dict] = None,
|
||||||
|
|
@ -43,8 +43,8 @@ class Annotation:
|
||||||
"""Initialize a new Annotation instance."""
|
"""Initialize a new Annotation instance."""
|
||||||
self.sample_start = int(sample_start)
|
self.sample_start = int(sample_start)
|
||||||
self.sample_count = int(sample_count)
|
self.sample_count = int(sample_count)
|
||||||
self.freq_lower_edge = float(freq_lower_edge) if freq_lower_edge is not None else None
|
self.freq_lower_edge = float(freq_lower_edge)
|
||||||
self.freq_upper_edge = float(freq_upper_edge) if freq_upper_edge is not None else None
|
self.freq_upper_edge = float(freq_upper_edge)
|
||||||
self.label = str(label)
|
self.label = str(label)
|
||||||
self.comment = str(comment)
|
self.comment = str(comment)
|
||||||
|
|
||||||
|
|
@ -62,8 +62,6 @@ class Annotation:
|
||||||
:returns: True if valid, False if not.
|
:returns: True if valid, False if not.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if self.freq_lower_edge is None or self.freq_upper_edge is None:
|
|
||||||
return self.sample_count > 0
|
|
||||||
return self.sample_count > 0 and self.freq_lower_edge < self.freq_upper_edge
|
return self.sample_count > 0 and self.freq_lower_edge < self.freq_upper_edge
|
||||||
|
|
||||||
def overlap(self, other):
|
def overlap(self, other):
|
||||||
|
|
@ -75,14 +73,6 @@ class Annotation:
|
||||||
|
|
||||||
:returns: The area of the overlap in samples*frequency, or 0 if they do not overlap."""
|
:returns: The area of the overlap in samples*frequency, or 0 if they do not overlap."""
|
||||||
|
|
||||||
if (
|
|
||||||
self.freq_lower_edge is None
|
|
||||||
or self.freq_upper_edge is None
|
|
||||||
or other.freq_lower_edge is None
|
|
||||||
or other.freq_upper_edge is None
|
|
||||||
):
|
|
||||||
return 0
|
|
||||||
|
|
||||||
sample_overlap_start = max(self.sample_start, other.sample_start)
|
sample_overlap_start = max(self.sample_start, other.sample_start)
|
||||||
sample_overlap_end = min(self.sample_start + self.sample_count, other.sample_start + other.sample_count)
|
sample_overlap_end = min(self.sample_start + self.sample_count, other.sample_start + other.sample_count)
|
||||||
|
|
||||||
|
|
@ -101,8 +91,6 @@ class Annotation:
|
||||||
|
|
||||||
:returns: sample length multiplied by bandwidth."""
|
:returns: sample length multiplied by bandwidth."""
|
||||||
|
|
||||||
if self.freq_lower_edge is None or self.freq_upper_edge is None:
|
|
||||||
return 0
|
|
||||||
return self.sample_count * (self.freq_upper_edge - self.freq_lower_edge)
|
return self.sample_count * (self.freq_upper_edge - self.freq_lower_edge)
|
||||||
|
|
||||||
def __eq__(self, other: Annotation) -> bool:
|
def __eq__(self, other: Annotation) -> bool:
|
||||||
|
|
@ -115,16 +103,13 @@ class Annotation:
|
||||||
|
|
||||||
annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count}
|
annotation_dict = {SigMFFile.START_INDEX_KEY: self.sample_start, SigMFFile.LENGTH_INDEX_KEY: self.sample_count}
|
||||||
|
|
||||||
metadata = {
|
annotation_dict["metadata"] = {
|
||||||
SigMFFile.LABEL_KEY: self.label,
|
SigMFFile.LABEL_KEY: self.label,
|
||||||
SigMFFile.COMMENT_KEY: self.comment,
|
SigMFFile.COMMENT_KEY: self.comment,
|
||||||
|
SigMFFile.FHI_KEY: self.freq_upper_edge,
|
||||||
|
SigMFFile.FLO_KEY: self.freq_lower_edge,
|
||||||
"ria:detail": self.detail,
|
"ria:detail": self.detail,
|
||||||
}
|
}
|
||||||
if self.freq_upper_edge is not None:
|
|
||||||
metadata[SigMFFile.FHI_KEY] = self.freq_upper_edge
|
|
||||||
if self.freq_lower_edge is not None:
|
|
||||||
metadata[SigMFFile.FLO_KEY] = self.freq_lower_edge
|
|
||||||
annotation_dict["metadata"] = metadata
|
|
||||||
|
|
||||||
if _is_jsonable(annotation_dict):
|
if _is_jsonable(annotation_dict):
|
||||||
return annotation_dict
|
return annotation_dict
|
||||||
|
|
|
||||||
|
|
@ -81,8 +81,6 @@ def view_annotations(
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
for annotation in sorted(annotations, key=_threshold_sort_key, reverse=True):
|
for annotation in sorted(annotations, key=_threshold_sort_key, reverse=True):
|
||||||
if annotation.freq_lower_edge is None or annotation.freq_upper_edge is None:
|
|
||||||
continue
|
|
||||||
t_start = annotation.sample_start / sample_rate
|
t_start = annotation.sample_start / sample_rate
|
||||||
t_width = annotation.sample_count / sample_rate
|
t_width = annotation.sample_count / sample_rate
|
||||||
f_start = annotation.freq_lower_edge
|
f_start = annotation.freq_lower_edge
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,9 @@ def save_recording_auto(recording, output_path, input_path, quiet=False, overwri
|
||||||
input_path = Path(input_path)
|
input_path = Path(input_path)
|
||||||
fmt = detect_input_format(input_path)
|
fmt = detect_input_format(input_path)
|
||||||
|
|
||||||
output_path = determine_output_path(input_path=input_path, output_path=output_path, fmt=fmt, overwrite=overwrite)
|
output_path = determine_output_path(
|
||||||
|
input_path=input_path, output_path=output_path, fmt=fmt, overwrite=overwrite
|
||||||
|
)
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
if fmt == "sigmf":
|
if fmt == "sigmf":
|
||||||
|
|
@ -256,11 +258,7 @@ def list(input, verbose):
|
||||||
user_comment = ann.comment or ""
|
user_comment = ann.comment or ""
|
||||||
|
|
||||||
# Basic info
|
# Basic info
|
||||||
freq_range = (
|
freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}"
|
||||||
f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}"
|
|
||||||
if ann.freq_lower_edge is not None and ann.freq_upper_edge is not None
|
|
||||||
else "N/A"
|
|
||||||
)
|
|
||||||
click.echo(
|
click.echo(
|
||||||
f" [{i}] Samples {format_sample_count(ann.sample_start)}-"
|
f" [{i}] Samples {format_sample_count(ann.sample_start)}-"
|
||||||
f"{format_sample_count(ann.sample_start + ann.sample_count)}: {ann.label}"
|
f"{format_sample_count(ann.sample_start + ann.sample_count)}: {ann.label}"
|
||||||
|
|
@ -504,7 +502,8 @@ def clear(input, output, overwrite, force, quiet):
|
||||||
help="Annotation type",
|
help="Annotation type",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sample-rate", type=float, default=None, help="Sample rate in Hz (overrides metadata; required if not in file)"
|
"--sample-rate", type=float, default=None,
|
||||||
|
help="Sample rate in Hz (overrides metadata; required if not in file)"
|
||||||
)
|
)
|
||||||
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
||||||
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
||||||
|
|
@ -618,7 +617,8 @@ def energy(
|
||||||
help="Annotation type",
|
help="Annotation type",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sample-rate", type=float, default=None, help="Sample rate in Hz (overrides metadata; required if not in file)"
|
"--sample-rate", type=float, default=None,
|
||||||
|
help="Sample rate in Hz (overrides metadata; required if not in file)"
|
||||||
)
|
)
|
||||||
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
||||||
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
||||||
|
|
@ -707,7 +707,8 @@ def cusum(input, label, min_duration, window_size, tolerance, annotation_type, s
|
||||||
)
|
)
|
||||||
@click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)")
|
@click.option("--channel", type=int, default=0, help="Channel index to annotate (default: 0)")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sample-rate", type=float, default=None, help="Sample rate in Hz (overrides metadata; required if not in file)"
|
"--sample-rate", type=float, default=None,
|
||||||
|
help="Sample rate in Hz (overrides metadata; required if not in file)"
|
||||||
)
|
)
|
||||||
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
||||||
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
||||||
|
|
@ -786,7 +787,8 @@ def threshold(input, threshold, label, window_size, annotation_type, channel, sa
|
||||||
@click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)")
|
@click.option("--noise-threshold-db", type=float, help="Noise floor threshold in dB (auto-estimated if not specified)")
|
||||||
@click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz")
|
@click.option("--min-component-bw", type=float, default=50e3, help="Min component bandwidth in Hz")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--sample-rate", type=float, default=None, help="Sample rate in Hz (overrides metadata; required if not in file)"
|
"--sample-rate", type=float, default=None,
|
||||||
|
help="Sample rate in Hz (overrides metadata; required if not in file)"
|
||||||
)
|
)
|
||||||
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
@click.option("--output", "-o", type=click.Path(), help="Output file path")
|
||||||
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
@click.option("--overwrite", is_flag=True, help="Overwrite input file (non-SigMF only)")
|
||||||
|
|
@ -807,8 +809,7 @@ def _log_separate_start(quiet, recording, indices_list, nfft, noise_threshold_db
|
||||||
|
|
||||||
|
|
||||||
def separate(
|
def separate(
|
||||||
input, indices, nfft, noise_threshold_db, min_component_bw, sample_rate, output, overwrite, quiet, verbose
|
input, indices, nfft, noise_threshold_db, min_component_bw, sample_rate, output, overwrite, quiet, verbose):
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Auto-detect parallel frequency-offset signals and split into sub-bands.
|
Auto-detect parallel frequency-offset signals and split into sub-bands.
|
||||||
|
|
||||||
|
|
@ -882,11 +883,7 @@ def separate(
|
||||||
click.echo("\n Details:")
|
click.echo("\n Details:")
|
||||||
for i in range(initial_count, final_count):
|
for i in range(initial_count, final_count):
|
||||||
ann = recording.annotations[i]
|
ann = recording.annotations[i]
|
||||||
freq_range = (
|
freq_range = f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}"
|
||||||
f"{format_frequency(ann.freq_lower_edge)} - {format_frequency(ann.freq_upper_edge)}"
|
|
||||||
if ann.freq_lower_edge is not None and ann.freq_upper_edge is not None
|
|
||||||
else "N/A"
|
|
||||||
)
|
|
||||||
click.echo(
|
click.echo(
|
||||||
f" [{i}] samples {format_sample_count(ann.sample_start)}-"
|
f" [{i}] samples {format_sample_count(ann.sample_start)}-"
|
||||||
f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}"
|
f"{format_sample_count(ann.sample_start + ann.sample_count)}: {freq_range}"
|
||||||
|
|
|
||||||
142
tests/agent/test_cli_register_errors.py
Normal file
142
tests/agent/test_cli_register_errors.py
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
"""Structured error reporting for `ria-agent register` (T2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
from io import BytesIO
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ria_toolkit_oss.agent import cli as agent_cli
|
||||||
|
|
||||||
|
|
||||||
|
def _structured(reason: str) -> bytes:
|
||||||
|
return json.dumps({"detail": {"reason": reason}}).encode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"reason",
|
||||||
|
["invalid_key", "expired", "revoked", "already_consumed"],
|
||||||
|
)
|
||||||
|
def test_explain_maps_known_reasons(reason):
|
||||||
|
msg = agent_cli._explain_registration_failure(403, _structured(reason))
|
||||||
|
assert msg == agent_cli.REGISTRATION_REASON_MESSAGES[reason]
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_unknown_reason_falls_through_with_code():
|
||||||
|
msg = agent_cli._explain_registration_failure(403, _structured("brand_new_thing"))
|
||||||
|
assert "brand_new_thing" in msg
|
||||||
|
assert "rejected" in msg.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_string_detail():
|
||||||
|
body = json.dumps({"detail": "Forbidden"}).encode()
|
||||||
|
msg = agent_cli._explain_registration_failure(403, body)
|
||||||
|
assert msg == "Registration rejected: Forbidden"
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_429_with_string_detail():
|
||||||
|
body = json.dumps({"detail": "Too many attempts; try again shortly"}).encode()
|
||||||
|
msg = agent_cli._explain_registration_failure(429, body)
|
||||||
|
assert "rate-limited" in msg
|
||||||
|
assert "Too many attempts" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_429_with_no_body():
|
||||||
|
msg = agent_cli._explain_registration_failure(429, b"")
|
||||||
|
assert "rate-limited" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_malformed_json():
|
||||||
|
msg = agent_cli._explain_registration_failure(500, b"<html>boom</html>")
|
||||||
|
assert msg.startswith("HTTP 500")
|
||||||
|
assert "boom" in msg
|
||||||
|
|
||||||
|
|
||||||
|
def test_explain_empty_body():
|
||||||
|
msg = agent_cli._explain_registration_failure(502, b"")
|
||||||
|
assert msg == "HTTP 502: no body"
|
||||||
|
|
||||||
|
|
||||||
|
def _http_error(status: int, body: bytes) -> urllib.error.HTTPError:
|
||||||
|
return urllib.error.HTTPError(
|
||||||
|
url="http://hub/screens/agents/register",
|
||||||
|
code=status,
|
||||||
|
msg="",
|
||||||
|
hdrs=None, # type: ignore[arg-type]
|
||||||
|
fp=BytesIO(body),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_is_set_and_not_python_default():
|
||||||
|
"""Cloudflare on `riahub.ai` returns 403 code 1010 to `Python-urllib/*`.
|
||||||
|
|
||||||
|
Guarding the UA explicitly is the entire point of the register-flow fix;
|
||||||
|
if this test ever breaks, the production bug is back.
|
||||||
|
"""
|
||||||
|
ua = agent_cli._user_agent()
|
||||||
|
assert ua, "User-Agent must not be empty"
|
||||||
|
assert not ua.lower().startswith("python-urllib"), (
|
||||||
|
f"User-Agent must not be Python's default (got {ua!r}) — Cloudflare blocks it"
|
||||||
|
)
|
||||||
|
assert ua.startswith("ria-agent/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_request_carries_explicit_user_agent(tmp_path):
|
||||||
|
"""Capture the outbound urllib Request and verify the UA header is set."""
|
||||||
|
cfg_path = tmp_path / "agent.json"
|
||||||
|
captured: dict = {}
|
||||||
|
|
||||||
|
def _fake_urlopen(req, *args, **kwargs):
|
||||||
|
# urllib normalizes header names; get_header takes the title-cased form.
|
||||||
|
captured["ua"] = req.get_header("User-agent")
|
||||||
|
captured["api_key"] = req.get_header("X-api-key")
|
||||||
|
captured["timeout"] = kwargs.get("timeout")
|
||||||
|
raise urllib.error.HTTPError(
|
||||||
|
url=req.full_url, code=403, msg="", hdrs=None, # type: ignore[arg-type]
|
||||||
|
fp=BytesIO(_structured("invalid_key")),
|
||||||
|
)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"RIA_AGENT_CONFIG": str(cfg_path)}, clear=False),
|
||||||
|
patch("urllib.request.urlopen", side_effect=_fake_urlopen),
|
||||||
|
patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
["ria-agent", "register", "--hub", "http://hub:3005", "--api-key", "ria_reg_x"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
agent_cli.main()
|
||||||
|
|
||||||
|
assert captured["ua"], "User-Agent header was not sent"
|
||||||
|
assert not captured["ua"].lower().startswith("python-urllib")
|
||||||
|
assert captured["api_key"] == "ria_reg_x"
|
||||||
|
assert captured["timeout"] is not None, "register must pass a timeout to urlopen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_surfaces_reason_on_http_error(tmp_path, capsys):
|
||||||
|
cfg_path = tmp_path / "agent.json"
|
||||||
|
err = _http_error(403, _structured("revoked"))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.dict("os.environ", {"RIA_AGENT_CONFIG": str(cfg_path)}, clear=False),
|
||||||
|
patch("urllib.request.urlopen", side_effect=err),
|
||||||
|
patch.object(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
["ria-agent", "register", "--hub", "http://hub:3005", "--api-key", "ria_reg_x"],
|
||||||
|
),
|
||||||
|
):
|
||||||
|
with pytest.raises(SystemExit) as exc:
|
||||||
|
agent_cli.main()
|
||||||
|
|
||||||
|
assert exc.value.code == 1
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "revoked" in captured.err.lower()
|
||||||
|
assert "Settings → RIA Agents" in captured.err
|
||||||
|
# Config must NOT be written on failure.
|
||||||
|
assert not cfg_path.exists()
|
||||||
|
|
@ -199,44 +199,3 @@ def test_annotation_to_sigmf_format_values():
|
||||||
values = list(result.values())
|
values = list(result.values())
|
||||||
assert 50 in values or ann.sample_start in values
|
assert 50 in values or ann.sample_start in values
|
||||||
assert 100 in values or ann.sample_count in values
|
assert 100 in values or ann.sample_count in values
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# None freq-edge regression tests (SigMF optional fields)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotation_no_freq_edges():
|
|
||||||
ann = Annotation(sample_start=0, sample_count=10, label="burst")
|
|
||||||
assert ann.freq_lower_edge is None
|
|
||||||
assert ann.freq_upper_edge is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotation_is_valid_no_freq_edges():
|
|
||||||
ann = Annotation(sample_start=0, sample_count=10, label="burst")
|
|
||||||
assert ann.is_valid() is True
|
|
||||||
|
|
||||||
ann_zero = Annotation(sample_start=0, sample_count=0, label="burst")
|
|
||||||
assert ann_zero.is_valid() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotation_overlap_none_edges_returns_zero():
|
|
||||||
ann1 = Annotation(sample_start=0, sample_count=10)
|
|
||||||
ann2 = Annotation(sample_start=0, sample_count=10, freq_lower_edge=0, freq_upper_edge=100)
|
|
||||||
assert ann1.overlap(ann2) == 0
|
|
||||||
assert ann2.overlap(ann1) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotation_area_none_edges_returns_zero():
|
|
||||||
ann = Annotation(sample_start=0, sample_count=10, label="burst")
|
|
||||||
assert ann.area() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_annotation_to_sigmf_omits_freq_keys_when_none():
|
|
||||||
from sigmf import SigMFFile
|
|
||||||
|
|
||||||
ann = Annotation(sample_start=0, sample_count=10, label="burst")
|
|
||||||
result = ann.to_sigmf_format()
|
|
||||||
metadata = result["metadata"]
|
|
||||||
assert SigMFFile.FLO_KEY not in metadata
|
|
||||||
assert SigMFFile.FHI_KEY not in metadata
|
|
||||||
|
|
|
||||||
|
|
@ -189,21 +189,3 @@ def test_sigmf_3(tmp_path):
|
||||||
to_sigmf(recording=recording1, path=tmp_path, filename=filename.name)
|
to_sigmf(recording=recording1, path=tmp_path, filename=filename.name)
|
||||||
except IOError as e:
|
except IOError as e:
|
||||||
assert str(e) == "File already exists"
|
assert str(e) == "File already exists"
|
||||||
|
|
||||||
|
|
||||||
def test_sigmf_annotation_without_freq_edges(tmp_path):
|
|
||||||
# Regression: annotations that omit the optional SigMF freq edge fields must
|
|
||||||
# load without error; edges should be None and the annotation still valid.
|
|
||||||
ann = Annotation(sample_start=0, sample_count=5, label="burst")
|
|
||||||
recording1 = Recording(data=complex_data_1, metadata=sample_metadata, annotations=[ann])
|
|
||||||
|
|
||||||
filename = tmp_path / "test"
|
|
||||||
to_sigmf(recording=recording1, path=tmp_path, filename=filename.name, overwrite=True)
|
|
||||||
recording2 = from_sigmf(filename)
|
|
||||||
|
|
||||||
assert len(recording2.annotations) == 1
|
|
||||||
loaded = recording2.annotations[0]
|
|
||||||
assert loaded.freq_lower_edge is None
|
|
||||||
assert loaded.freq_upper_edge is None
|
|
||||||
assert loaded.is_valid() is True
|
|
||||||
assert loaded.label == "burst"
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user