macula_station_client (macula v3.9.0)
View SourceV2 station-client — outbound RPC over macula_peering.
A macula_station_client is a gen_server that owns one macula_peering connection to a single station endpoint. It drives the CONNECT/HELLO handshake as the client side, then sends application-layer CALL frames built with macula_frame:call/1 and matches inbound RESULT/ERROR frames against pending gen_server:call callers using the 16-byte CALL id.
This is the V2 counterpart to macula_mesh_client, which speaks the V1 relay protocol. V1 clients cannot drive V2 stations because V2 stations dispatch the QUIC connection straight into macula_peering:accept/2 — V1 CONNECT frames never reach the V2 handler registry. This module bridges the gap so that a V1-era consumer (e.g. macula-realm's topology subscriber) can issue _dht.find_records_by_type against a V2 station and receive its signed RESULT.
Lifecycle
start_link/1— spawn worker, schedule connect.connect_now/1(cast) — build connect opts, callmacula_peering:connect/1, store the worker pid.- Peering handshake completes →
{macula_peering, connected, Pid, PeerNodeId}arrives → state moves toconnected. call/4from caller → build CALL frame, sign happens inside peering, store{from, deadline_timer}` keyed by CALL id, send frame via `macula_peering:send_frame/2.- RESULT or ERROR arrives as
{macula_peering, frame, Pid, Frame}→ look upcall_id, cancel timer, reply to caller. {macula_peering, disconnected, Pid, Reason}→ fail all pending calls with{error, {disconnected, Reason}}, stop the client (caller is responsible for restart / reconnect).
Reply taxonomy
<table><tr><th>Inbound frame</th><th>call/4 returns</th></tr><tr><td>RESULT(payload={error, Reason})</td><td>{ok, {error, Reason}}</td></tr><tr><td>RESULT(payload=Value)</td><td>{ok, Value}</td></tr><tr><td>ERROR(code=C, name=N)</td><td>{error, {call_error, C, N}}</td></tr><tr><td>(deadline elapses)</td><td>{error, timeout}</td></tr><tr><td>(connection drops)</td><td>{error, {disconnected, Reason}}</td></tr></table>
Realm field
V2 CALL frames carry a 32-byte realm id. Stations deployed today advertise an empty realms list (realm-agnostic infrastructure) and do not enforce a realm match on inbound CALLs — the dispatch path verifies the signature and looks up the procedure, nothing more. Callers therefore pass any 32-byte value; this module defaults to all-zeros when no realm is configured.
Summary
Functions
Issue a CALL frame and block until the station replies, the deadline elapses, or the connection drops.
Convenience wrapper for _dht.find_record. Looks up a record by its macula_record:storage_key/1 (32-byte BLAKE3 digest). Returns {error, not_found} when no record exists at the key. Callers SHOULD verify the returned record's signature with macula_record:verify/1 before trusting its payload.
Convenience wrapper for _dht.find_records_by_type. Returns the decoded list of signed records (CBOR-decoded maps as produced by macula_record).
Convenience wrapper for _dht.put_record. The record must be a fully-signed macula_record:record() map (build via macula_record:envelope/3,4 + macula_record:sign/2). Returns ok on success, {error, Reason} on RPC failure or unexpected reply.
Start a station-client connected to seed. Returns once the gen_server is alive; the QUIC handshake completes asynchronously. Use is_connected/1 to poll readiness or just issue call/4 (which blocks the caller until ready or until its timeout elapses).
Types
-type opts() :: #{seed := url() | #{host := binary() | string(), port := inet:port_number()}, identity => macula_identity:key_pair(), realm => <<_:256>>, capabilities => non_neg_integer(), alpn => [binary()], connect_timeout_ms => non_neg_integer()}.
Functions
Issue a CALL frame and block until the station replies, the deadline elapses, or the connection drops.
Procedure is the V2 procedure name, e.g. <<"_dht.find_records_by_type">>. Payload is any term that macula_frame:call/1 accepts (typically a map).
Convenience wrapper for _dht.find_record. Looks up a record by its macula_record:storage_key/1 (32-byte BLAKE3 digest). Returns {error, not_found} when no record exists at the key. Callers SHOULD verify the returned record's signature with macula_record:verify/1 before trusting its payload.
-spec find_record(pid(), <<_:256>>, pos_integer()) -> {ok, map()} | {error, not_found | term()}.
Convenience wrapper for _dht.find_records_by_type. Returns the decoded list of signed records (CBOR-decoded maps as produced by macula_record).
-spec find_records_by_type(pid(), 0..255, pos_integer()) -> {ok, [map()]} | {error, term()}.
-spec peer_node_id(pid()) -> {ok, macula_identity:pubkey()} | {error, not_connected}.
Convenience wrapper for _dht.put_record. The record must be a fully-signed macula_record:record() map (build via macula_record:envelope/3,4 + macula_record:sign/2). Returns ok on success, {error, Reason} on RPC failure or unexpected reply.
Stations replicate the put across the K-nearest peers in their Kademlia routing table, so a single put_record/2 call against any one connected station propagates to the rest of the DHT.
-spec put_record(pid(), map(), pos_integer()) -> ok | {error, term()}.
Start a station-client connected to seed. Returns once the gen_server is alive; the QUIC handshake completes asynchronously. Use is_connected/1 to poll readiness or just issue call/4 (which blocks the caller until ready or until its timeout elapses).
-spec stop(pid()) -> ok.