Skip to content

BlueSky Network Client

Standalone ZMQ client adapted from the BlueSky simulator's own bluesky.network package — it replicates the essential Client/Node functionality without requiring the full BlueSky framework.

WebATM.bluesky_client

WebATM.bluesky_client

Provide a BlueSky-compatible network client adapted from the BlueSky simulator.

This module contains networking code adapted from the BlueSky Air Traffic Management simulator's network infrastructure (the bluesky.network package). It replicates essential BlueSky Client/Node functionality without requiring the full BlueSky framework dependency, following ZMQ best practices.

Original BlueSky project: https://github.com/TUDelft-CNS-ATM/bluesky BlueSky is developed by TU Delft (Delft University of Technology).

Key adaptations from BlueSky's networking components:

  • Node ID generation algorithms (from bluesky.network.common)
  • ZMQ socket management patterns (from bluesky.network.client)
  • Message serialization/deserialization (from bluesky.network)
  • Subscription and signal handling patterns

BlueSkySignal

BlueSkySignal(name)

Provide a simple signal/slot implementation.

Adapted from BlueSky's signal patterns.

Attributes:

Name Type Description
name str

Human-readable signal name, used in warning logs.

callbacks list

Callbacks currently connected to this signal.

Initialize the signal.

Parameters:

Name Type Description Default
name str

Human-readable signal name, used in warning logs.

required
Source code in WebATM/bluesky_client.py
def __init__(self, name):
    """Initialize the signal.

    Args:
        name (str): Human-readable signal name, used in warning logs.
    """
    self.name = name
    self.callbacks = []

connect

connect(callback)

Connect a callback to this signal.

Idempotent: a callback that is already connected is not added again.

Parameters:

Name Type Description Default
callback Callable

Callable invoked whenever the signal is emitted.

required
Source code in WebATM/bluesky_client.py
def connect(self, callback):
    """Connect a callback to this signal.

    Idempotent: a callback that is already connected is not added again.

    Args:
        callback (Callable): Callable invoked whenever the signal is
            emitted.
    """
    if callback not in self.callbacks:
        self.callbacks.append(callback)

disconnect

disconnect(callback)

Disconnect a callback from this signal.

A callback that is not connected is silently ignored.

Parameters:

Name Type Description Default
callback Callable

Callback to remove.

required
Source code in WebATM/bluesky_client.py
def disconnect(self, callback):
    """Disconnect a callback from this signal.

    A callback that is not connected is silently ignored.

    Args:
        callback (Callable): Callback to remove.
    """
    if callback in self.callbacks:
        self.callbacks.remove(callback)

emit

emit(*args, **kwargs)

Emit the signal to all connected callbacks.

Iterates over a snapshot of the callback list so callbacks may connect or disconnect during emission. Exceptions raised by a callback are logged and do not stop delivery to the remaining callbacks.

Parameters:

Name Type Description Default
*args Any

Positional arguments forwarded to each callback.

()
**kwargs Any

Keyword arguments forwarded to each callback.

{}
Source code in WebATM/bluesky_client.py
def emit(self, *args, **kwargs):
    """Emit the signal to all connected callbacks.

    Iterates over a snapshot of the callback list so callbacks may connect
    or disconnect during emission. Exceptions raised by a callback are
    logged and do not stop delivery to the remaining callbacks.

    Args:
        *args (Any): Positional arguments forwarded to each callback.
        **kwargs (Any): Keyword arguments forwarded to each callback.
    """
    callbacks_snapshot = self.callbacks[
        :
    ]  # Make a copy to avoid concurrency issues
    for callback in callbacks_snapshot:
        try:
            callback(*args, **kwargs)
        except Exception as e:
            logger.warning(f"Signal {self.name}: Error in callback {callback}: {e}")
            import traceback

            traceback.print_exc()

BlueSkySubscriber

BlueSkySubscriber()

Provide a simple topic-based subscriber system.

Adapted from BlueSky's subscriber patterns.

Attributes:

Name Type Description
subscribers dict[str, list]

Mapping of topic name to the callbacks subscribed to that topic.

Initialize the subscriber registry with no subscriptions.

Source code in WebATM/bluesky_client.py
def __init__(self):
    """Initialize the subscriber registry with no subscriptions."""
    self.subscribers: dict[str, list] = defaultdict(list)

subscribe

subscribe(topic: str, callback: Callable)

Subscribe a callback to a topic.

Idempotent, like BlueSkySignal.connect: a callback already subscribed to the topic is not added again.

Parameters:

Name Type Description Default
topic str

Topic name to subscribe to.

required
callback Callable

Callable invoked when data is emitted on the topic.

required
Source code in WebATM/bluesky_client.py
def subscribe(self, topic: str, callback: Callable):
    """Subscribe a callback to a topic.

    Idempotent, like ``BlueSkySignal.connect``: a callback already
    subscribed to the topic is not added again.

    Args:
        topic (str): Topic name to subscribe to.
        callback (Callable): Callable invoked when data is emitted on the
            topic.
    """
    if callback not in self.subscribers[topic]:
        self.subscribers[topic].append(callback)

emit

emit(topic: str, *args, **kwargs)

Emit data to all subscribers of a topic.

Exceptions raised by a callback are logged and do not stop delivery to the remaining callbacks. Topics without subscribers are ignored.

Parameters:

Name Type Description Default
topic str

Topic name to emit on.

required
*args Any

Positional arguments forwarded to each callback.

()
**kwargs Any

Keyword arguments forwarded to each callback.

{}
Source code in WebATM/bluesky_client.py
def emit(self, topic: str, *args, **kwargs):
    """Emit data to all subscribers of a topic.

    Exceptions raised by a callback are logged and do not stop delivery to
    the remaining callbacks. Topics without subscribers are ignored.

    Args:
        topic (str): Topic name to emit on.
        *args (Any): Positional arguments forwarded to each callback.
        **kwargs (Any): Keyword arguments forwarded to each callback.
    """
    for callback in self.subscribers.get(topic, []):
        try:
            callback(*args, **kwargs)
        except Exception as e:
            logger.warning(f"Subscriber {topic}: Error in callback {callback}: {e}")
            logger.debug(f"Subscriber {topic}: Error type: {type(e).__name__}")
            logger.debug(f"Subscriber {topic}: Args: {args}")
            logger.debug(f"Subscriber {topic}: Kwargs: {kwargs}")
            import traceback

            traceback.print_exc()

BlueSkyStack

BlueSkyStack()

Provide a simple command stack.

Adapted from BlueSky's command stack patterns.

Attributes:

Name Type Description
cmdstack deque

Queued (command, sender_id) pairs.

sender_id

Sender ID of the command currently being processed, or None.

current str

Command currently being processed, or an empty string.

Initialize an empty command stack.

Source code in WebATM/bluesky_client.py
def __init__(self):
    """Initialize an empty command stack."""
    self.cmdstack = deque()
    self.sender_id = None
    self.current = ""

stack

stack(*cmdlines, sender_id=None)

Queue one or more command lines.

Each command line is stripped of surrounding whitespace; empty lines are ignored. Semicolon-separated compound lines are split into individual commands, each queued with the given sender ID.

Parameters:

Name Type Description Default
*cmdlines str

One or more command lines to queue.

()
sender_id bytes | str | None

Identifier of the command originator, stored alongside each queued command.

None
Source code in WebATM/bluesky_client.py
def stack(self, *cmdlines, sender_id=None):
    """Queue one or more command lines.

    Each command line is stripped of surrounding whitespace; empty lines are
    ignored. Semicolon-separated compound lines are split into individual
    commands, each queued with the given sender ID.

    Args:
        *cmdlines (str): One or more command lines to queue.
        sender_id (bytes | str | None): Identifier of the command originator, stored
            alongside each queued command.
    """
    for cmdline in cmdlines:
        cmdline = cmdline.strip()
        if cmdline:
            for line in cmdline.split(";"):
                self.cmdstack.append((line.strip(), sender_id))

commands

commands()

Iterate over queued commands with sender tracking.

Pops commands from the front of the stack, exposing each one through the current and sender_id attributes while it is being processed. Both attributes are reset when the stack is exhausted.

Yields:

Type Description
str

The next queued command.

Source code in WebATM/bluesky_client.py
def commands(self):
    """Iterate over queued commands with sender tracking.

    Pops commands from the front of the stack, exposing each one through the
    ``current`` and ``sender_id`` attributes while it is being processed.
    Both attributes are reset when the stack is exhausted.

    Yields:
        str: The next queued command.
    """
    while self.cmdstack:
        self.current, self.sender_id = self.cmdstack.popleft()
        yield self.current
    self.current = ""
    self.sender_id = None

BlueSkyContext

BlueSkyContext()

Provide a simple simulation context object.

Adapted from BlueSky's simulation context patterns. Tracks the shared-state action and sender of the message currently being processed.

Attributes:

Name Type Description
action

Action type of the message being processed (e.g. "RESET"), or None.

sender_id

Node ID of the message sender, or None.

Reset str

BlueSky action constant for a simulation reset ("RESET").

ActChange str

BlueSky action constant for an active-node change ("ACTCHANGE").

Initialize the context with no active action or sender.

Source code in WebATM/bluesky_client.py
def __init__(self):
    """Initialize the context with no active action or sender."""
    self.action = None
    self.sender_id = None
    # BlueSky action constants
    self.Reset = "RESET"
    self.ActChange = "ACTCHANGE"

BlueSkyClient

BlueSkyClient(group_id=GROUPID_CLIENT)

Provide a BlueSky-compatible network client.

Adapted from bluesky.network.client.Client. Replicates essential BlueSky Client/Node functionality with proper ZMQ lifecycle management, including:

  • ZMQ socket management and lifecycle
  • Node discovery and server communication
  • Message subscription and publishing patterns
  • Command stacking and processing

Attributes:

Name Type Description
node_id bytes

Unique identifier of this client node.

group_id bytes

Group prefix derived from the node ID.

server_id bytes

Derived ID of the server this client belongs to.

act_id bytes | None

ID of the active simulation node, or None.

connected bool

Whether the client is connected to a server.

running bool

Whether the client is running (accepting I/O).

nodes set

Known simulation node IDs.

servers set

Known server IDs.

node_added BlueSkySignal

Emitted when a new simulation node appears.

node_removed BlueSkySignal

Emitted when a simulation node disappears.

server_added BlueSkySignal

Emitted when a new server appears.

server_removed BlueSkySignal

Emitted when a server disappears.

actnode_changed BlueSkySignal

Emitted when the active node changes.

subscriber BlueSkySubscriber

Topic subscription registry.

stack BlueSkyStack

Command stack.

context BlueSkyContext

Shared-state processing context.

Initialize the client and its identifiers, signals, and state.

Generates the node/group/server IDs, sets up the socket lock and network state tracking, and auto-connects the node_added signal to active-node selection and initial data requests. No network resources are created until connect() is called.

Parameters:

Name Type Description Default
group_id int

Group identifier for node ID generation. Defaults to GROUPID_CLIENT.

GROUPID_CLIENT
Source code in WebATM/bluesky_client.py
def __init__(self, group_id=GROUPID_CLIENT):
    """Initialize the client and its identifiers, signals, and state.

    Generates the node/group/server IDs, sets up the socket lock and network
    state tracking, and auto-connects the ``node_added`` signal to active-node
    selection and initial data requests. No network resources are created
    until ``connect()`` is called.

    Args:
        group_id (int): Group identifier for node ID generation. Defaults to
            ``GROUPID_CLIENT``.
    """
    logger.debug("Initializing...")

    # Node identification
    self.node_id = genid(group_id)
    self.group_id = asbytestr(group_id)[: len(self.node_id) - 1]
    self.server_id = self.node_id[:-1] + seqidx2id(0)
    self.act_id = None

    # Connection state
    self.connected = False
    self.running = False

    # ZMQ resources (created when connecting)
    self.zmq_context = None
    self.sock_recv = None
    self.sock_send = None
    self.poller = None

    # ZMQ sockets are NOT thread-safe, and WebATM drives them from two
    # threads at once: the network-timer thread (receive()/subscription
    # discovery) and the Socket.IO command threads (send(), running under
    # the "threading" async mode). Concurrent access to the same socket
    # corrupts its internal state and can tear the connection down — exactly
    # what happens when GUI commands (rapid ADDWPT, FF…) are sent while data
    # streams in. This reentrant lock serialises every socket operation.
    # It is reentrant because subscription callbacks fired during receive()
    # (e.g. on_node_added_request_data → send()) re-enter the guarded path.
    self._sock_lock = threading.RLock()

    # Network state tracking
    self.nodes = set()
    self.servers = set()
    self.acttopics = defaultdict(set)

    # Signals (simplified)
    self.node_added = BlueSkySignal("node-added")
    self.node_removed = BlueSkySignal("node-removed")
    self.server_added = BlueSkySignal("server-added")
    self.server_removed = BlueSkySignal("server-removed")
    self.actnode_changed = BlueSkySignal("actnode-changed")

    # Message handling
    self.subscriber = BlueSkySubscriber()
    self.stack = BlueSkyStack()
    self.context = BlueSkyContext()

    # Auto-connect node_added to actnode (like BlueSky Client does)
    self.node_added.connect(self.actnode)

    # Connect to node_added signal to request latest data from new nodes
    self.node_added.connect(self.on_node_added_request_data)

    logger.info(f"Initialized with node_id={safe_decode(self.node_id)}")

connect

connect(
    hostname="localhost",
    recv_port=11000,
    send_port=11001,
    protocol="tcp",
)

Connect to a BlueSky server following the ZMQ pattern.

Creates the ZMQ context, SUB (data) and XPUB (command) sockets, tunes buffer and shutdown options, connects both sockets, registers them with a poller, and subscribes to messages targeted at this node so the server can discover it. On failure all partially-created resources are released via close().

Parameters:

Name Type Description Default
hostname str

BlueSky server hostname or IP address.

'localhost'
recv_port int

Port for receiving simulation data.

11000
send_port int

Port for sending commands and events.

11001
protocol str

ZMQ transport protocol (e.g. "tcp").

'tcp'

Returns:

Type Description
bool

True if the connection was established, False otherwise.

Source code in WebATM/bluesky_client.py
def connect(
    self, hostname="localhost", recv_port=11000, send_port=11001, protocol="tcp"
):
    """Connect to a BlueSky server following the ZMQ pattern.

    Creates the ZMQ context, SUB (data) and XPUB (command) sockets, tunes
    buffer and shutdown options, connects both sockets, registers them with a
    poller, and subscribes to messages targeted at this node so the server
    can discover it. On failure all partially-created resources are released
    via ``close()``.

    Args:
        hostname (str): BlueSky server hostname or IP address.
        recv_port (int): Port for receiving simulation data.
        send_port (int): Port for sending commands and events.
        protocol (str): ZMQ transport protocol (e.g. "tcp").

    Returns:
        bool: True if the connection was established, False otherwise.
    """
    logger.info(f"Connecting to {hostname}:{recv_port}/{send_port}...")

    try:
        # Create ZMQ context and sockets (following ZMQ pattern)
        self.zmq_context = zmq.Context()
        self.sock_recv = self.zmq_context.socket(zmq.SUB)
        self.sock_send = self.zmq_context.socket(zmq.XPUB)
        self.poller = zmq.Poller()

        # Tune buffers and shutdown behaviour BEFORE connecting (HWM/LINGER
        # must be set before connect to take effect):
        #  - RCVHWM/SNDHWM absorb bursty traffic instead of dropping it.
        #  - LINGER=0 makes close()/reconnect return immediately rather than
        #    blocking on undeliverable queued messages, so a disconnect can
        #    never hang the timer/command threads.
        self.sock_recv.setsockopt(zmq.RCVHWM, RECV_HWM)
        self.sock_recv.setsockopt(zmq.LINGER, 0)
        self.sock_send.setsockopt(zmq.SNDHWM, SEND_HWM)
        self.sock_send.setsockopt(zmq.LINGER, 0)

        # Connect sockets
        recv_addr = f"{protocol}://{hostname}:{recv_port}"
        send_addr = f"{protocol}://{hostname}:{send_port}"

        self.sock_recv.connect(recv_addr)
        self.sock_send.connect(send_addr)

        # Register with poller
        self.poller.register(self.sock_recv, zmq.POLLIN)
        self.poller.register(self.sock_send, zmq.POLLIN)

        # CRITICAL: Register this node by subscribing to targeted messages
        # This is what makes the node discoverable to the server!
        self._subscribe("", "", self.node_id)

        self.connected = True
        self.running = True

        logger.info(
            f"Connected to BlueSky server host at {hostname} with node_id={safe_decode(self.node_id)}"
        )
        return True

    except Exception as e:
        logger.error(f"Connection failed: {e}")
        self.close()
        return False

close

close()

Close all connections following ZMQ pattern.

Source code in WebATM/bluesky_client.py
def close(self):
    """Close all connections following ZMQ pattern."""
    logger.debug("Closing connections...")

    self.running = False
    self.connected = False

    # Take the socket lock so teardown cannot race a receive()/send() in
    # another thread (the timer thread may still be mid-poll when a command
    # thread or shutdown triggers close()). RLock keeps this safe even if a
    # close path is re-entered.
    with self._sock_lock:
        self._close_locked()

update

update()

Update function - call periodically to receive and process data.

Source code in WebATM/bluesky_client.py
def update(self):
    """Update function - call periodically to receive and process data."""
    if not self.running or not self.connected:
        return False

    try:
        return self.receive(timeout=0)
    except Exception as e:
        logger.error(f"Error in update: {e}")
        return False

receive

receive(timeout=0)

Receive and process incoming messages (following ZMQ recv pattern).

Socket I/O (poll + recv) is done under _sock_lock so it cannot race a concurrent send() from a command thread. Every currently-available message is drained in one pass — a single recv per tick would let a fast-forward burst back up for many ticks (stale map, growing latency). Handler dispatch runs outside the lock: handlers can re-enter the client (e.g. node discovery triggers a REQUEST send), and keeping the lock hold short means command sends are never blocked by serialisation.

Source code in WebATM/bluesky_client.py
def receive(self, timeout=0):
    """Receive and process incoming messages (following ZMQ recv pattern).

    Socket I/O (poll + recv) is done under ``_sock_lock`` so it cannot race
    a concurrent send() from a command thread. Every currently-available
    message is drained in one pass — a single recv per tick would let a
    fast-forward burst back up for many ticks (stale map, growing latency).
    Handler dispatch runs *outside* the lock: handlers can re-enter the
    client (e.g. node discovery triggers a REQUEST send), and keeping the
    lock hold short means command sends are never blocked by serialisation.
    """
    if not self.running or not self.poller:
        return False

    # (is_data_socket, msg) pairs collected under the lock, dispatched after.
    collected: list[tuple[bool, list]] = []

    try:
        with self._sock_lock:
            if not self.poller:
                return False

            events = dict(self.poller.poll(timeout))

            for sock, event in events.items():
                if event != zmq.POLLIN:
                    continue

                is_data_socket = sock is self.sock_recv

                # Drain everything queued on this socket right now, bounded
                # so a flood can't pin the timer thread indefinitely.
                for _ in range(MAX_DRAIN_PER_SOCKET):
                    try:
                        msg = sock.recv_multipart(zmq.DONTWAIT)
                    except zmq.Again:
                        break
                    if msg:
                        collected.append((is_data_socket, msg))

    except zmq.Again:
        # No messages available (expected with timeout=0)
        return True
    except Exception as e:
        if "ENOTSOCK" in str(e):
            logger.debug("Socket closed during receive")
            return False
        else:
            logger.error(f"Error receiving: {e}")
            return False

    # Dispatch outside the socket lock.
    for is_data_socket, msg in collected:
        if is_data_socket:
            self._process_data_message(msg)
        else:
            self._process_subscription_message(msg)

    return True

send

send(topic: str, data='', to_group='')

Send data to a topic.

Source code in WebATM/bluesky_client.py
def send(self, topic: str, data="", to_group=""):
    """Send data to a topic."""
    if not self.running or not self.sock_send:
        return False

    try:
        btopic = asbytestr(topic)
        bto_group = asbytestr(to_group or "")

        header = bto_group.ljust(IDLEN, b"*") + btopic + self.node_id
        payload = msgpack.packb(data, use_bin_type=True)

        # Serialise socket access against the receive loop / other senders;
        # ZMQ sockets are not thread-safe (see _sock_lock). DONTWAIT means a
        # momentarily-full send buffer raises zmq.Again instead of blocking
        # the caller (a Socket.IO command thread) on the socket.
        with self._sock_lock:
            if not self.sock_send:
                return False
            self.sock_send.send_multipart([header, payload], zmq.DONTWAIT)

        return True

    except zmq.Again:
        logger.warning(f"Send buffer full, dropping '{topic}' command")
        return False
    except Exception as e:
        logger.error(f"Error sending: {e}")
        return False

subscribe

subscribe(topic: str, callback: Callable, actonly=False)

Subscribe to a topic with callback.

Source code in WebATM/bluesky_client.py
def subscribe(self, topic: str, callback: Callable, actonly=False):
    """Subscribe to a topic with callback."""
    self.subscriber.subscribe(topic, callback)
    # Subscribe on network level with actonly support
    self._subscribe(topic, GROUPID_DEFAULT, "", actonly=actonly)

actnode

actnode(newact=None)

Set or get the active simulation node.

Source code in WebATM/bluesky_client.py
def actnode(self, newact=None):
    """Set or get the active simulation node."""
    if newact:
        if newact not in self.nodes:
            logger.error(
                f"Error selecting active node (unknown node): {safe_decode(newact)}"
            )
            return None

        if self.act_id is None:
            # First time selecting active node - disconnect auto-selection
            self.node_added.disconnect(self.actnode)

        # Update subscriptions for new active node
        if newact != self.act_id:
            for topic, groupset in self.acttopics.items():
                for to_group in groupset:
                    if self.act_id:
                        self._unsubscribe(topic, self.act_id, to_group)
                    self._subscribe(topic, newact, to_group)

            self.act_id = newact
            self.actnode_changed.emit(newact)

    return self.act_id

addnodes

addnodes(count=1, server_id=None)

Tell server to add nodes.

Source code in WebATM/bluesky_client.py
def addnodes(self, count=1, server_id=None):
    """Tell server to add nodes."""
    target_server = server_id or (
        self.act_id[:-1] + seqidx2id(0) if self.act_id else self.server_id
    )
    return self.send("ADDNODES", {"count": count}, target_server)

delnode

delnode(node_id)

Tell the owning server to terminate a single simulation node.

The DELNODE message carries the raw node id and is addressed to the server that spawned the node (same group, sequence index 0). Servers without DELNODE support ignore unknown topics, so this is safe to send to older BlueSky versions.

Source code in WebATM/bluesky_client.py
def delnode(self, node_id):
    """Tell the owning server to terminate a single simulation node.

    The DELNODE message carries the raw node id and is addressed to the
    server that spawned the node (same group, sequence index 0). Servers
    without DELNODE support ignore unknown topics, so this is safe to
    send to older BlueSky versions.
    """
    target_server = node_id[:-1] + seqidx2id(0)
    return self.send("DELNODE", node_id, target_server)

on_node_added_request_data

on_node_added_request_data(node_id)

When a new node is announced, request the initial/current state of all subscribed shared states.

Source code in WebATM/bluesky_client.py
def on_node_added_request_data(self, node_id):
    """When a new node is announced, request the initial/current state of all
    subscribed shared states."""
    logger.info("A new node has been added! request topics")

    # TODO: fix request
    # Request all BlueSky topics we want to receive add #STACK
    # topics = ['RESET', 'REQUEST', 'PLOT', 'SHOWDIALOG', 'SIMINFO',
    #          'SIMSETTINGS', 'TRAILS', 'ROUTEDATA', 'ACDATA', 'DEFWPT',
    #          'POLY', 'STACKCMDS']

    topics = ["POLY", "STACKCMDS"]

    logger.debug(
        f"Requesting topics {topics} from all nodes (triggered by new node {safe_decode(node_id)})"
    )
    self.send("REQUEST", topics)
    self.send("REQUEST", topics)

genid

genid(group_id=GROUPID_NOGROUP, seqidx=1)

Generate a unique node identifier.

Adapted from bluesky.network.common.genid(). Builds an ID of IDLEN bytes: the group prefix, padded with random bytes if needed (avoiding the * wildcard byte), followed by a one-byte encoding of the sequence index.

Parameters:

Name Type Description Default
group_id int | str | bytes

Group identifier used as the ID prefix. Integers and strings are encoded via the charmap codec; bytes are used as-is.

GROUPID_NOGROUP
seqidx int

Sequence index encoded as the final byte of the ID.

1

Returns:

Type Description
bytes

A node ID of IDLEN bytes.

Source code in WebATM/bluesky_client.py
def genid(group_id=GROUPID_NOGROUP, seqidx=1):
    """Generate a unique node identifier.

    Adapted from ``bluesky.network.common.genid()``. Builds an ID of ``IDLEN``
    bytes: the group prefix, padded with random bytes if needed (avoiding the
    ``*`` wildcard byte), followed by a one-byte encoding of the sequence index.

    Args:
        group_id (int | str | bytes): Group identifier used as the ID prefix.
            Integers and strings are encoded via the charmap codec; bytes are
            used as-is.
        seqidx (int): Sequence index encoded as the final byte of the ID.

    Returns:
        bytes: A node ID of ``IDLEN`` bytes.
    """
    from os import urandom

    # Convert group_id to bytes
    if isinstance(group_id, int):
        group_bytes = chr(group_id).encode("charmap")
    elif isinstance(group_id, str):
        group_bytes = group_id.encode("charmap")
    else:
        group_bytes = group_id

    # Ensure proper length (IDLEN-1 for group + 1 for sequence)
    if len(group_bytes) >= IDLEN:
        return group_bytes[:IDLEN]
    elif len(group_bytes) < IDLEN - 1:
        # Pad with random bytes (avoiding '*' wildcard)
        padding_needed = IDLEN - 1 - len(group_bytes)
        padding = urandom(padding_needed).replace(b"*", b"_")
        group_bytes += padding

    return group_bytes + seqidx2id(seqidx)

asbytestr

asbytestr(data)

Convert a value to a byte string.

Adapted from bluesky.network.common.asbytestr(). Integers are encoded as a single character via the charmap codec, strings are charmap-encoded, and any other value is returned unchanged.

Parameters:

Name Type Description Default
data int | str | bytes

Value to convert.

required

Returns:

Type Description
bytes

The byte-string representation of data.

Source code in WebATM/bluesky_client.py
def asbytestr(data):
    """Convert a value to a byte string.

    Adapted from ``bluesky.network.common.asbytestr()``. Integers are encoded as
    a single character via the charmap codec, strings are charmap-encoded, and
    any other value is returned unchanged.

    Args:
        data (int | str | bytes): Value to convert.

    Returns:
        bytes: The byte-string representation of ``data``.
    """
    if isinstance(data, int):
        return chr(data).encode("charmap")
    elif isinstance(data, str):
        return data.encode("charmap")
    else:
        return data

seqid2idx

seqid2idx(seqid_byte)

Convert a sequence ID byte to a sequence index.

Adapted from bluesky.network.common. The index is the byte value offset by -128, clamped to a minimum of -1.

Parameters:

Name Type Description Default
seqid_byte int | str

Sequence ID byte, as an integer value or a one-character string.

required

Returns:

Type Description
int

The sequence index (at least -1).

Source code in WebATM/bluesky_client.py
def seqid2idx(seqid_byte):
    """Convert a sequence ID byte to a sequence index.

    Adapted from ``bluesky.network.common``. The index is the byte value offset
    by -128, clamped to a minimum of -1.

    Args:
        seqid_byte (int | str): Sequence ID byte, as an integer value or a
            one-character string.

    Returns:
        int: The sequence index (at least -1).
    """
    val = seqid_byte if isinstance(seqid_byte, int) else ord(seqid_byte)
    ret = val - 128
    return max(-1, ret)

seqidx2id

seqidx2id(seqidx)

Convert a sequence index to a sequence ID byte.

Adapted from bluesky.network.common. Inverse of seqid2idx: the byte value is the index offset by +128.

Parameters:

Name Type Description Default
seqidx int

Sequence index to encode.

required

Returns:

Type Description
bytes

A single charmap-encoded byte representing the index.

Source code in WebATM/bluesky_client.py
def seqidx2id(seqidx):
    """Convert a sequence index to a sequence ID byte.

    Adapted from ``bluesky.network.common``. Inverse of ``seqid2idx``: the byte
    value is the index offset by +128.

    Args:
        seqidx (int): Sequence index to encode.

    Returns:
        bytes: A single charmap-encoded byte representing the index.
    """
    return chr(128 + seqidx).encode("charmap")

safe_decode

safe_decode(data)

Decode bytes to a readable string without raising.

Attempts UTF-8 decoding first and returns the result only if it consists entirely of printable ASCII characters; otherwise falls back to ASCII decoding, and finally to an uppercase hexadecimal representation. Non-bytes input is converted with str().

Parameters:

Name Type Description Default
data bytes | object

Value to decode or stringify.

required

Returns:

Type Description
str

A printable string representation of data.

Source code in WebATM/bluesky_client.py
def safe_decode(data):
    """Decode bytes to a readable string without raising.

    Attempts UTF-8 decoding first and returns the result only if it consists
    entirely of printable ASCII characters; otherwise falls back to ASCII
    decoding, and finally to an uppercase hexadecimal representation. Non-bytes
    input is converted with ``str()``.

    Args:
        data (bytes | object): Value to decode or stringify.

    Returns:
        str: A printable string representation of ``data``.
    """
    if isinstance(data, bytes):
        try:
            # First try utf-8 decoding
            decoded = data.decode("utf-8")
            # Check if the decoded string contains only printable ASCII characters
            if all(32 <= ord(c) <= 126 for c in decoded):
                return decoded
            else:
                # Contains non-printable characters, use hex representation
                return data.hex().upper()
        except UnicodeDecodeError:
            try:
                # Try ASCII decoding
                decoded = data.decode("ascii")
                return decoded
            except UnicodeDecodeError:
                # Unable to decode as text, use hex representation
                return data.hex().upper()
    return str(data)