5 Commits
Author SHA1 Message Date
cortandClaude Opus 5 f1a2e4133c Make the module files directly runnable again; no install step
The previous commit required "pip install -e ." before anything would
run, and made "python hblink4/hblink.py" fail outright. That trades a
real support burden -- an install step people forget after git pull, and
an error message that looks like a broken checkout -- for an internal
correctness property. Not a good trade for this project's users, who have
run hblink by interpreter-plus-path since HBlink3 and DMRlink.

Establish the package context explicitly instead. When hblink4/hblink.py
or dashboard/server.py is run by path, __package__ is empty, so each now
puts the project root on sys.path and sets __package__ before its
relative imports. That is enough for "from .constants import ..." to
resolve to hblink4.constants -- one copy, not the two the old
except-ImportError fallback produced. Importing the parent package is
cheap because __init__.py is metadata only.

Every invocation style now works and none of them double-load:

  python hblink4/hblink.py config/config.json      <- systemd uses this
  python dashboard/server.py                       <- systemd uses this
  python run_hblink.py config/config.json          <- convenience
  python run_dashboard.py                          <- convenience
  python -m hblink4.hblink
  hblink4                                          <- if pip installed

The systemd units invoke the module files directly rather than going
through a launcher, so there is one less moving part between systemd and
the program. run_hblink.py and run_dashboard.py stay in the project root
as launchers for interactive use, but they are now genuinely logic-free:
the uvicorn call that used to live in run_dashboard.py is dashboard.server.run().

requirements.txt and requirements-dashboard.txt come back as the
documented install path. pyproject.toml stays for those who want
"pip install -e .", but is now optional and reads its dependencies from
those files so the two cannot drift.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:54:37 -05:00
cortandClaude Opus 5 6a644a818c Package hblink4 properly and run it via console-script entry points
Running a module inside a package by file path puts the package's own
directory on sys.path while the package is also importable, which is the
condition for the dual import trap. hblink.py papered over it with a
"try: from .x import ... except ImportError: from x import ..." block --
an idiom that cannot fix the problem, it only picks which of the two
copies you get.

Add pyproject.toml and install the package. The systemd units now invoke
the generated console scripts:

  venv/bin/hblink4 /path/to/config.json
  venv/bin/hblink4-dashboard

Those are still explicit absolute paths, but they enter through the
package rather than around it, so each module is imported exactly once.
The 30-line except-ImportError branch in hblink.py is deleted and the
relative imports stand on their own; dashboard/server.py grows a run()
function for its entry point, with __main__ delegating to it.

tests/test_openbridge.py was the last place doing the same dance --
sys.path.insert into hblink4/ then bare "from hblink import HBProtocol",
loading a second copy of the package alongside the hblink4.* modules the
other eight test modules import. Converted to package imports; the suite
now loads no duplicates.

requirements.txt and requirements-dashboard.txt are removed in favour of
pyproject's dependencies and the [dashboard] / [dev] extras, so there is
one place to declare a dependency.

Verified: 94 tests pass, both entry points resolve to the right
callables, no bare-name duplicates under either, and running hblink.py by
path now fails loudly with ImportError instead of silently double-loading.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 20:37:44 -05:00
cortandClaude Opus 5 18c90f4631 Remove launcher shims; clarify connect/listen roles in dashboard link
The dashboard event link is documented as if both sides "listen on" an
address, which is misleading: hblink4 is the client. It dials the
dashboard and pushes events; the dashboard only sends sync_request back
over the connection it accepted.

Establish one naming convention across both configs -- bind_* is an
address we listen on, host_* is an address we connect out to -- and
state who initiates the connection in the docs, config samples, and
module docstrings. docs/configuration.md previously documented hblink4's
connect target with listener semantics ("Empty = disable IPv4 listener",
"0.0.0.0", "may need different ports if bind error"); those values are
unusable in a connect() and are replaced with real dashboard addresses.
The dashboard's event_receiver keys are renamed host_ipv4/host_ipv6 ->
bind_ipv4/bind_ipv6, which collided with the identically-named
connect-target keys in hblink4's config. Old names still work and log a
deprecation warning.

Drop run.py and run_dashboard.py in favour of direct execution:

  python hblink4/hblink.py [config/config.json]
  python dashboard/server.py [bind] [port]

run.py's only real behaviour (defaulting the config path) moves into
main(). The dashboard was not runnable on its own: server.py had a
__main__ block, but it passed "dashboard.server:app" as an import string,
which cannot resolve in script mode, and it omitted the loop="asyncio"
uvloop workaround that lived only in the shim. It now passes the app
object directly -- the import string would have executed the module a
second time under another name, yielding two DashboardState objects and
two atexit saves, the empty one clobbering real data.

Signal handling: the SIGTERM/SIGINT handlers were guarded by
__name__ == "__main__", never true while uvicorn imported the module.
Making server.py the entry point would have activated them for the first
time, and they sys.exit(0) from inside the handler, pre-empting uvicorn's
graceful shutdown. shutdown_event() already saves, so leave signals to
uvicorn and keep atexit as the fallback.

Add a 'web' section to the dashboard config so its bind address and port
come from disk rather than the command line; CLI arguments remain as an
override. Drop event_receiver.buffer_size and .ipv6 from the defaults --
neither is read, and the defaults block was writing them into every
freshly created config.json.

Remove PrivateTmp=true from both units. It gives each service a private
/tmp, so with the 'unix' transport hblink4 and the dashboard each create
a separate /tmp/hblink4.sock and never see each other -- the dashboard
just reports hblink4 as disconnected, with no error on either side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 18:23:18 -05:00
cortandClaude Opus 4.7 32286ae1ed feat: RPTO slot/TGID translation and outbound rf_src override
Trusted repeaters can now declare per-entry slot/TGID translation rules in
their RPTO (OPTIONS) string, plus an optional single-ID rf_src override for
outbound group voice.

Grammar (operator-facing, goes into MMDVM.ini / DMRGateway.ini):
  TS1 = entry[,entry...]
  TS2 = entry[,entry...]
  SRC = radio_id
  entry = net_tgid_spec[:local_slot[:local_tgid]]
  net_tgid_spec: N | N-M (range, specificity 2) | N (exact, specificity 3)
  local_slot: 1 | 2 | * (preserve net slot)
  local_tgid: N | * (preserve matched net tgid)

Wildcards on the network side (*, N*) are rejected at parse time to keep
the hot path O(1). Ranges expand to concrete map entries at RPTO parse
time (capped at 10,000 tgids). Most-specific rule wins on collision;
losers are dropped with a warning.

Runtime:
- RepeaterState grows inbound_map / outbound_map (inverses) and a single
  tx_src_override.
- _check_inbound_routing translates local -> network before the ACL check
  so subscriptions stay in one vocabulary.
- _calculate_stream_targets uses each target's outbound_map to run the
  slot-busy / hang-time check against the slot the packet will actually
  occupy on air.
- _forward_stream (and the outbound->local forwarding path) rewrite
  bytes 5-7 (rf_src via override), 8-10 (tgid), 11-14 (repeater id for
  outbound targets), and the slot bit in byte 15. Data-sync frames
  (frame_type==2) get bytes 20-52 zeroed so MMDVMHost's BPTC decode
  fails and it falls back to the rewritten DMRD header; voice frames
  keep AMBE vocoder bits intact.
- Untranslated repeaters pay zero overhead (empty-dict short-circuits).
- Unit/private calls are still rejected upstream; SRC= has a defensive
  group-only gate regardless.
- Mid-stream RPTO logs a warning and applies new rules immediately;
  in-flight streams finish on their cached routing.

Validated via parser unit tests (exact/range/collision/error cases, NAT
grammar, specificity precedence) and module imports. NOT yet validated
on live DMR traffic — that's what this branch is for.

Docs:
- docs/dmrd_translation.md: full feature reference (new)
- docs/configuration.md: trust + RPTO extensions section
- docs/connecting_to_hblink4.md: operator Options= examples for
  MMDVMHost / DMRGateway (Pi-Star/WPSD references stripped — out of scope)
- docs/protocol.md: extended RPTO grammar note

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 13:55:24 -05:00
cort 0ddfae89cf Dashboard UX improvements and Last Heard source display
- Change 'Audio On/Off' button to 'Notifications' (icon/color indicate state)
- Rename 'Repeater' column to 'Source' in Last Heard table
- Display source as 'Name (ID)' format for clarity:
  - Repeaters: 'WA0EDA (312100)' - callsign + repeater_id
  - Outbound: 'TGIF-3120 (315001)' - connection_name + originating repeater_id
- Add remote_repeater_id to outbound stream events (minimal hot path impact)
- Add .github/copilot-instructions.md for AI coding assistance
- Remove obsolete design/analysis markdown files
2026-01-21 08:26:51 -06:00