Skip to content

feat: support multiple simultaneous TCP interfaces#2

Merged
torlando-tech merged 1 commit into
mainfrom
feat/multi-tcp-interfaces
Mar 24, 2026
Merged

feat: support multiple simultaneous TCP interfaces#2
torlando-tech merged 1 commit into
mainfrom
feat/multi-tcp-interfaces

Conversation

@torlando-tech

Copy link
Copy Markdown
Owner

Summary

  • Previously only the first enabled TCP client was used; additional entries were silently ignored. This caused custom local servers (added after onboarding) to be shadowed by the community server.
  • AppServices.tcpInterfaces is now a [String: TCPInterface] dict keyed by entity ID, supporting N concurrent TCP connections.
  • Startup and the interface management UI both connect every enabled TCP client independently.

Changes

AppServices

  • tcpInterface: TCPInterface? (stored) → tcpInterfaces: [String: TCPInterface]; tcpInterface kept as computed values.first for backward compat
  • New connectTCPInterface(entityId:host:port:) — connects or replaces a specific TCP interface by entity ID
  • New stopTCPInterface(entityId:) — stops one; existing stopTCPInterface() now stops all
  • startStateObserver aggregates .connected/.reconnecting across all TCP interfaces

ColumbaApp._initializeServicesOnce

  • Passes "" to initialize() (skips built-in TCP setup)
  • Loops all enabled TCP entities and calls connectTCPInterface(entityId: iface.id, ...)

InterfaceManagementViewModel.applyChanges

  • Loops all enabled TCP clients instead of taking only first
  • Stops interfaces whose entity IDs are no longer in the enabled set
  • Status observer tracks each TCP entity's state independently (removed activeInterfaceId)

Test plan

  • Add two TCP client interfaces (e.g., community server + local 10.x.x.x server) — both should show Connected
  • Disable one TCP interface — only that one disconnects, the other stays up
  • Delete a TCP interface — it stops and is removed from the transport
  • Fresh install with no TCP interfaces — app starts offline cleanly
  • Identity switch — all TCP interfaces reconnect correctly on the new identity

🤖 Generated with Claude Code

Previously only the first enabled TCP client was connected; additional
TCP interfaces were silently ignored. This caused custom local servers
added after onboarding to be shadowed by the community server.

- AppServices: replace single `tcpInterface` with `tcpInterfaces: [String: TCPInterface]`
  keyed by entity ID. Add `connectTCPInterface(entityId:host:port:)` and
  `stopTCPInterface(entityId:)` for per-interface control. `stopTCPInterface()`
  (no args) stops all. `startStateObserver` aggregates state across all TCP interfaces.
- ColumbaApp startup: connect each enabled TCP entity independently via
  `connectTCPInterface(entityId: iface.id)` so each gets its own transport ID.
- InterfaceManagementViewModel: loop all enabled TCP clients in `applyChanges()`,
  stop removed ones. Status observer tracks each entity's state independently.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@torlando-tech torlando-tech merged commit 99ce1a7 into main Mar 24, 2026
@torlando-tech torlando-tech deleted the feat/multi-tcp-interfaces branch March 24, 2026 16:41
torlando-tech pushed a commit that referenced this pull request May 26, 2026
Closes the loop on the lxst-wiring batch. With this commit a call placed
from sim1 to sim2 fully establishes through the canonical Mark Qvist
Python RNS+LXST embedded in BeeWare — the full Reticulum LXST signal
exchange (AVAILABLE → LINKIDENTIFY → RINGING → CONNECTING → ESTABLISHED
+ PREFERRED_PROFILE) crosses the bridge.

Bridge wiring completed in this commit:

  - Compat Link: identifyHook (parallels sendBytesHook), wired by
    AppServices to PythonRNSBackend.linkIdentify(linkId:) so the
    caller's `link.identify(identity:)` call propagates through Python
    and the remote sees the proper IDENTIFY signal.
  - Compat Link.identifyCallbacks: was weakly held, became strong.
    Telephone.handleIncomingLink constructs a
    `TelephoneIdentifyHandler(telephone: self)` inline with no caller-
    held reference; under weak storage it deallocated immediately and
    the inbound call silently stalled at AVAILABLE, never receiving
    the identify-callback invocation when Python's link_identified
    event arrived. Strong storage fixes it.
  - app/rns_bridge.py: announce_telephony() function so the LXST
    telephony destination announces alongside the LXMF delivery one;
    sendAllAnnounces() invokes both. Without this, sim2 couldn't see
    sim1's voice destination in its path table.
  - URL test triggers: lxma://test-call?to=HEX[&profile=...] places
    an outbound call through CallManager.initiateCall; lxma://test-answer
    auto-accepts the ringing call. Mirrors the existing lxma://test-* set.
  - CallManager auto-answer escape hatch keyed off env var
    COLUMBA_AUTO_ANSWER=1. Reason: simctl openurl only delivers URLs
    to the frontmost simulator window; sim2 stays backgrounded during
    the sim1 → sim2 test so we can't drive the answer via URL. The
    env var lets the smoke test reliably push sim2 past RINGING.

Smoke test (iPhone 17 Pro / 17 Pro Max sims, fresh boots):

  sim1 → sim2:  [TEST-CALL] to=e6abaf41... profile=qualityMedium
  sim1:         [TEL_BRIDGE] opened outbound link 1 → e6abaf41
  sim2:         [PY] link 1 state=established inbound=true
  sim2:         [CALL] handleIncomingLink
  sim2:         [TEL] sendData #1: 4B          (AVAILABLE)
  sim1:         [TEL] handlePacket first4=81009103 (AVAILABLE)
  sim1:         (link.identify → linkIdentifyHook → Python linkIdentify)
  sim2:         [PY] link 1 identified=cb54d9a4
  sim2:         [CALL] Ringing from: cb54d9a4...
  sim2:         [TEL] sendData #2: 4B          (RINGING)
  sim2:         [TEL] PREFERRED_PROFILE: Medium Quality
  sim1:         [CALL] Ringing from: 4e4f5bcb...
  sim2:         [CALL] COLUMBA_AUTO_ANSWER=1 — auto-answering
  sim2:         [TEL] answer(): sending CONNECTING, profile=Medium Quality
  sim2:         [TEL] sendData #3: 4B          (CONNECTING)
  sim2:         [TEL] answer(): pipeline started, codec=opus
  sim2:         [TEL] answer(): sent ESTABLISHED
  sim2:         [CALL] establishedCallback fired
  sim2:         [AUDIO] startAudio() creating AudioManager
  sim1:         [TEL] handlePacket first4=81009105 (CONNECTING)
  sim1:         [TEL] handlePacket first4=81009106 (ESTABLISHED)
  sim1:         [AUDIO] startAudio() creating AudioManager

Audio frame round-trip (what we'd ideally see next, e.g. opus
"frame #1: hdr=0x01 audioBytes=N") doesn't fire on the simulator:
both sides reach [AUDIO] startAudio() but then log "deferring engine
start until didActivateAudioSession" because CallKit on the simulator
doesn't drive its audio-session-activation delegate the way a physical
device does (the sim has no real audio hardware to bind to). The
signaling path is end-to-end across the Python bridge; verifying
actual audio bytes traversing requires installing on Torlando's USB-
attached iPhone, which is a separate workstream from this 5-commit
batch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant