
BATMAN routing algorithm implemented in kotlin at the application layer
A Kotlin/JVM implementation of the BATMAN (Better Approach To Mobile Ad-hoc Networking) routing protocol for heterogeneous, band-limited radio meshes.
The library runs entirely at the application layer — no kernel modules, no raw sockets — making it suitable for embedded JVM targets, any environment where the physical link is exposed as a simple send/receive primitive.
Kiro is available at Maven Central.
Add the dependency to your build.gradle.kts:
dependencies {
implementation("systems.untangle:kiro:0.1.1")
}Requirements:
kotlinx-coroutines-core 1.10+.// 1. Implement the Link interface for your physical medium.
val radio: Link = object : Link {
override val id = "lora0"
override val ogmInterval = 5.seconds // drives relay jitter timing and purge detection
override val bandwidthBps = 50_000L // used as the routing metric (widest-path)
override suspend fun broadcast(frame: ByteArray) = radio.send(frame)
override val frames: Flow<ByteArray> = radioReceiveFlow()
}
// 2. Create a router and start it inside a CoroutineScope.
val router = KiroRouter()
router.start(scope, selfId = 0x001u, links = listOf(radio))
// 3. Send unicast data to another node.
router.send(dstId = 0x002u, payload = "hello".encodeToByteArray())
// 4. Receive unicast data.
router.incomingData.collect { (srcId, payload) ->
println("from $srcId: ${payload.decodeToString()}")
}A UShort (16-bit unsigned) identifier, unique within the mesh. Only the lower 12 bits are transmitted on the wire, limiting deployments to 4 095 nodes — sufficient for virtually all mesh use cases and saving one byte per ID field.
The only interface the library requires you to implement. It abstracts a single broadcast radio interface — LoRa, serial, UDP multicast, Bluetooth, etc.
interface Link {
val id: String // unique name used as a map key
val ogmInterval: Duration // how often this node emits OGMs on this link
val bandwidthBps: Long // physical capacity in bits per second; the routing metric
suspend fun broadcast(frame: ByteArray)
val frames: Flow<ByteArray> // cold Flow of every received raw frame
}bandwidthBps is the primary routing metric. Each OGM carries a running minimum of floor(log₂(bps)) across every link it has traversed. The router always prefers the path whose bottleneck link is widest — a 3-hop all-WiFi path beats a 2-hop WiFi+LoRa path when LoRa is the bottleneck. TTL (hop count) is used only as a tiebreaker when two paths have the same bottleneck tier.
ogmInterval controls how often OGMs are emitted and sets the jitter window for relay suppression. It also determines how quickly a missing neighbour is detected: an entry is evicted after neighborPurgeMultiplier × ogmInterval without a refresh.
One instance per node. Orchestrates OGM emission, relay suppression, route table maintenance, multicast tree building, and frame forwarding.
val router = KiroRouter()
router.start(
scope: CoroutineScope,
selfId: NodeId,
links: List<Link>,
txQueue: TxQueue = TxQueue(),
staleThreshold: Duration = 90.seconds,
neighborPurgeMultiplier: Int = 3
)| Parameter | Effect |
|---|---|
staleThreshold |
How long a multicast branch is kept without a refreshing beacon before eviction |
neighborPurgeMultiplier |
Route is evicted after this many missed OGM cycles on its link |
Call router.start(scope, selfId, links) before using any other method. All protocol loops run inside a child scope derived from scope — cancelling scope also stops the router, but router.stop() stops it independently without affecting scope. Calling start again after stop resets all internal state so the node restarts clean.
Links can be added or removed while the router is running — no restart required.
// Add a new radio interface at runtime
router.addLink(newLink)
// Remove a radio interface (returns false if not found)
router.removeLink(linkId = "lora1")addLink immediately starts the receive, OGM, and TX coroutines for the new link. The link is eligible for OGM relay and unicast forwarding from the next OGM cycle onward.
removeLink cancels those coroutines and immediately evicts any routing table entries that were learned via that link, rather than waiting for the normal purge timeout. This prevents the router from continuing to forward traffic down a dead interface.
router.links always reflects the current live set.
// Send
router.send(dstId = 0x042u, payload = bytes)
// Receive
router.incomingData.collect { (srcId, payload) -> ... }send looks up the best next hop in the routing table and enqueues a DataFrame. If no route is known the frame is silently dropped. Forwarding nodes decrement TTL (initial value 15) and drop frames that reach zero.
// Current snapshot
val table: Map<NodeId, NeighborEntry> = router.routes.value
// Live updates — emits a new snapshot on every add, update, or eviction
router.routes.collect { table -> println(table) }routes is a StateFlow so it always holds the latest snapshot; new collectors receive it immediately without waiting for the next change. The flow is reset to an empty map on each start call, so collectors do not need to resubscribe after a restart.
For UI or monitoring use cases, asRouteSummaryFlow() gives a lower-frequency view that only emits when something routing-relevant changes:
router.routes
.asRouteSummaryFlow()
.collect { table ->
table.forEach { (dst, summary) ->
println("$dst via ${summary.nextHop} on ${summary.linkId} tier=${summary.minBandwidthTier} [${summary.status}]")
}
}Each RouteSummary replaces the raw lastSeen timestamp with a coarse LinkStatus:
| Status | Meaning |
|---|---|
GOOD |
An OGM arrived within the last ogmInterval — path is fresh |
WARNING |
At least one OGM cycle was missed — path may be degrading |
When the purge threshold is exceeded the entry disappears from the map entirely; there is no third "dead" state. The lastSeq field is dropped because it carries no routing-decision value for observers. distinctUntilChanged suppresses any emission where only lastSeen was refreshed within the same status bucket — the common case for a healthy, stable network.
Multicast is built on a per-group spanning tree. Members send periodic beacons toward the group root; every relay node records which links beacons arrive on and which link leads toward the root. Multicast frames then travel the tree in both directions without flooding.
Group identifiers are opaque 20-bit values chosen by the application. The node designated as root joins with roots = listOf(selfId); members join with roots pointing at the root:
// Root node — beacons flow toward it, so no beacon loop is started
val gid = GroupId(0xA01u) // application-assigned opaque id
router.joinGroup(gid, roots = listOf(router.selfId))
// Member node — launches a beacon loop toward the root
router.joinGroup(gid, roots = listOf(rootId), beaconInterval = 30.seconds)// Send to all members of a group
router.sendMulticast(gid, payload = bytes)
// Receive multicast messages
router.incomingMulticast.collect { msg ->
println("${msg.srcId} → ${msg.groupId}: ${msg.payload.decodeToString()}")
}When the primary root goes offline, members independently select the first alternative root with a known route from the roots list passed to joinGroup. Because all nodes share the same OGM view they converge on the same root without coordination. The active root is carried in every BeaconFrame so relay nodes need no local root list.
router.silence() // shut the radio completely
router.unsilence() // resumeWhile silent the node is completely dark on the wire:
send and sendMulticast calls are dropped.Incoming frames are still decoded and the local routing table is still updated, so the node can resume routing immediately on unsilence(). The OGM and beacon loops keep running at their scheduled interval, so the node re-announces itself within one OGM cycle with no burst of stale frames.
All outgoing frames pass through TxQueue — a sorted, pull-model queue shared by all link TX coroutines. Frames are dequeued by whichever link's TX loop calls pollFor first.
Ordering is controlled by a composable list of Rule objects (each a Comparator<TxEntry>). The defaults are:
controlFirst — OGM / BEACON before DATA / MULTICASTolderFirst — FIFO within a priority classinsertionOrderFirst — strict total order tiebreakerCustom rule lists can be injected into TxQueue:
val q = TxQueue(listOf(
compareBy { when (it.flavor) { PacketFlavor.OGM -> 0; PacketFlavor.BEACON -> 1; else -> 2 } },
olderFirst,
insertionOrderFirst
))enqueue returns a TxHandle that lets you track and mutate a queued entry before it is transmitted:
val handle: TxHandle = txQueue.enqueue(entry)
// Non-suspending notification — fires on whichever thread transmits the frame
handle.onSent { log("frame transmitted") }
// Suspending — use inside a coroutine
launch { handle.awaitSent(); doNextStep() }
// Mutations — all return false if the entry was already sent
handle.cancel() // remove from queue
handle.replace(newBytes) // swap frame bytes, keep queue position
handle.swap(otherHandle) // exchange positions with another queued entryswap exchanges enqueuedAt between two entries, inverting their relative order. It has no effect across flavor boundaries — a control frame always transmits before a data frame regardless of a swap.
Use recommendedConfig to derive ogmInterval and neighborPurgeMultiplier from link bandwidth:
val cfg = recommendedConfig(
linkBandwidthBps = 50_000L, // 50 kbps
expectedNodes = 40, // conservative upper bound on network size
dataFraction = 0.5 // reserve at least 50 % for application data
)
// cfg.ogmInterval ≈ 90 ms
// cfg.neighborPurgeMultiplier = 5
// cfg.purgeTimeout ≈ 450 ms (= interval × multiplier)The formula: ogmInterval = N × 56 bits / ((1 − dataFraction) × linkBandwidthBps), floored at minOgmInterval (default 5 s). The purge multiplier is chosen so purgeTimeout ≈ 60 s across all link speeds, clamped to [3, 5].
| Link speed | Nodes | ogmInterval | purgeMultiplier |
|---|---|---|---|
| 50 bps | 40 | ~90 s | 3 |
| 500 bps | 40 | ~9 s | 5 |
| 5 kbps | 40 | ~5 s (floor) | 5 |
| 50 kbps | 40 | ~5 s (floor) | 5 |
| ≥ 1 Gbps | any | 5 s (floor) | 5 |
All frames are bit-packed big-endian. Byte 0 always carries the 4-bit type tag in the high nibble and the high nibble of the first 12-bit node ID in the low nibble.
| Frame | Size | Notes |
|---|---|---|
OgmFrame |
7 bytes | originatorId(12b) + senderId(12b) + ttl(4b) + seqNum(16b) + minBandwidthTier(8b) |
DataFrame |
7 + n bytes | nextHop(12b) + src(12b) + dst(12b) + ttl(4b) + varint(len) + payload |
BeaconFrame |
8 bytes | nextHop(12b) + src(12b) + groupId(20b) + activeRoot(12b) |
MulticastFrame |
8 + n bytes | src(12b) + groupId(20b) + ttl(4b) + seqNum(16b) + varint(len) + payload |
Payload lengths use a 7-bit continuation varint: lengths ≤127 cost 1 byte, ≤16383 cost 2 bytes, ≤2097151 cost 3 bytes. No upper limit is imposed at the codec layer.
Codec.encode and Codec.decode handle serialisation. decode returns null for malformed or unknown frames; the router silently drops them.
Why widest-path bandwidth as the routing metric? A pure hop-count metric (TTL) would prefer a 2-hop LoRa path over a 3-hop WiFi path regardless of the capacity difference — which is the wrong trade-off for heterogeneous radio meshes. Instead, each OGM carries minBandwidthTier = floor(log₂(bps)) for the narrowest link it has traversed. The router keeps whichever path has the highest bottleneck tier; TTL breaks ties. This means a short slow path never wins over a longer fast path, and the log₂ encoding keeps the field in one byte while covering 1 bps (tier 0) through ~9 Pbps (tier 63).
What happens when OGMs from the same source arrive with different tiers? Two sub-cases arise. If the OGMs come via different next-hop neighbours, the higher-tier one wins and the lower-tier one is ignored — the lower-quality alternative only installs itself after the better next-hop's entry expires from inactivity. If OGMs come via the same next-hop but with varying tiers (relay suppression occasionally chose a slower intermediate hop), lastSeen is refreshed but the stored tier is not lowered. The per-hop entry therefore records the best ever observed tier for that next-hop, not the current one. This makes the routing decision resistant to transient relay-path fluctuations, at the cost that a permanent upstream degradation (without the next hop disappearing) is not reflected until the entry is evicted and reinstalled by a fresh OGM.
Why pull-model TxQueue? Each link's TX coroutine suspends in pollFor until a frame is available. A slow LoRa radio never delays a fast Ethernet link. No thread sleeps, no polling timers.
Why separate upstream and downstream links in MulticastTree? It enables two optimisations: leaf suppression (a relay whose downstream members are actively beaconing does not need to send its own beacon) and immediate upstream replacement (when the best route to the root changes, the old upstream link is discarded atomically, preventing duplicate multicast transmissions during reroutes).
Why activeRoot carried in BeaconFrame? Relay nodes need no local configuration. The active root is embedded in every beacon, so any node can forward toward the correct root without knowing the group's root list.
The library ships UdpMulticastLink, a ready-to-use [Link] implementation for JVM and Android. All nodes bound to the same multicast address and port form one broadcast domain, including nodes on different machines on the same LAN.
UDP preserves datagram boundaries so no length-prefix framing is needed, and there is no risk of byte-level interleaving between concurrent writers.
val link = UdpMulticastLink(
id = "udp:239.0.0.1:5001",
multicastGroup = "239.0.0.1", // administratively scoped, stays on the LAN
port = 5001,
ogmInterval = 2.seconds,
bandwidthBps = 100_000_000L, // 100 Mbps — used as the routing metric
)
link.startReading(scope)
val router = KiroRouter()
router.start(scope, selfId = 1u, links = listOf(link))
// When tearing down:
link.close()Multiple instances on different ports or addresses model separate broadcast media. A node connected to two of them bridges them:
val linkA = UdpMulticastLink(id = "a", multicastGroup = "239.0.0.1", port = 5001)
val linkB = UdpMulticastLink(id = "b", multicastGroup = "239.0.0.2", port = 5002)
val router = KiroRouter()
router.start(scope, selfId = 2u, links = listOf(linkA, linkB))outboundTransform and inboundTransform let you pre- and post-process frames independently of the router — the typical use case is encryption. Each is a suspend lambda so it can call async crypto APIs. Returning null drops the frame silently (useful when authentication fails on the inbound side).
val cipher = AesGcmCipher(sharedKey)
val link = UdpMulticastLink(
id = "udp:239.0.0.1:5001",
multicastGroup = "239.0.0.1",
port = 5001,
outboundTransform = { frame -> cipher.encrypt(frame) },
inboundTransform = { frame -> cipher.decrypt(frame) }, // returns null on auth failure
)The :demo submodule ships a self-contained fat JAR that simulates a mesh over named FIFO pipes. Each medium is a directory; nodes on the same medium share a broadcast channel.
./gradlew :demo:shadowJar
# Output: demo/build/libs/kiro-demo.jarjava -jar kiro-demo.jar <nodeId> <medium1> [<medium2> ...]
Each medium path is a directory that will be created on first use. A node on the same directory as another node can hear its broadcasts.
# Terminal 1 — node 1 on net-a only
java -jar kiro-demo.jar 1 /tmp/net-a
# Terminal 2 — node 2 bridges net-a and net-b
java -jar kiro-demo.jar 2 /tmp/net-a /tmp/net-b
# Terminal 3 — node 3 on net-b only
java -jar kiro-demo.jar 3 /tmp/net-bAfter a few seconds node 1 and node 3 discover each other through node 2.
| Command | Description |
|---|---|
send <dstId> <message> |
Unicast a text message to another node |
mcast <groupId> <message> |
Send a multicast to a group |
join <groupId> [<rootId>] |
Join a group; omit rootId to join as root |
leave <groupId> |
Leave a group |
routes |
Print the current routing table |
offline |
Simulate going offline (suppresses all send and receive) |
online |
Come back online |
quit / exit
|
Exit |
A Kotlin/JVM implementation of the BATMAN (Better Approach To Mobile Ad-hoc Networking) routing protocol for heterogeneous, band-limited radio meshes.
The library runs entirely at the application layer — no kernel modules, no raw sockets — making it suitable for embedded JVM targets, any environment where the physical link is exposed as a simple send/receive primitive.
Kiro is available at Maven Central.
Add the dependency to your build.gradle.kts:
dependencies {
implementation("systems.untangle:kiro:0.1.1")
}Requirements:
kotlinx-coroutines-core 1.10+.// 1. Implement the Link interface for your physical medium.
val radio: Link = object : Link {
override val id = "lora0"
override val ogmInterval = 5.seconds // drives relay jitter timing and purge detection
override val bandwidthBps = 50_000L // used as the routing metric (widest-path)
override suspend fun broadcast(frame: ByteArray) = radio.send(frame)
override val frames: Flow<ByteArray> = radioReceiveFlow()
}
// 2. Create a router and start it inside a CoroutineScope.
val router = KiroRouter()
router.start(scope, selfId = 0x001u, links = listOf(radio))
// 3. Send unicast data to another node.
router.send(dstId = 0x002u, payload = "hello".encodeToByteArray())
// 4. Receive unicast data.
router.incomingData.collect { (srcId, payload) ->
println("from $srcId: ${payload.decodeToString()}")
}A UShort (16-bit unsigned) identifier, unique within the mesh. Only the lower 12 bits are transmitted on the wire, limiting deployments to 4 095 nodes — sufficient for virtually all mesh use cases and saving one byte per ID field.
The only interface the library requires you to implement. It abstracts a single broadcast radio interface — LoRa, serial, UDP multicast, Bluetooth, etc.
interface Link {
val id: String // unique name used as a map key
val ogmInterval: Duration // how often this node emits OGMs on this link
val bandwidthBps: Long // physical capacity in bits per second; the routing metric
suspend fun broadcast(frame: ByteArray)
val frames: Flow<ByteArray> // cold Flow of every received raw frame
}bandwidthBps is the primary routing metric. Each OGM carries a running minimum of floor(log₂(bps)) across every link it has traversed. The router always prefers the path whose bottleneck link is widest — a 3-hop all-WiFi path beats a 2-hop WiFi+LoRa path when LoRa is the bottleneck. TTL (hop count) is used only as a tiebreaker when two paths have the same bottleneck tier.
ogmInterval controls how often OGMs are emitted and sets the jitter window for relay suppression. It also determines how quickly a missing neighbour is detected: an entry is evicted after neighborPurgeMultiplier × ogmInterval without a refresh.
One instance per node. Orchestrates OGM emission, relay suppression, route table maintenance, multicast tree building, and frame forwarding.
val router = KiroRouter()
router.start(
scope: CoroutineScope,
selfId: NodeId,
links: List<Link>,
txQueue: TxQueue = TxQueue(),
staleThreshold: Duration = 90.seconds,
neighborPurgeMultiplier: Int = 3
)| Parameter | Effect |
|---|---|
staleThreshold |
How long a multicast branch is kept without a refreshing beacon before eviction |
neighborPurgeMultiplier |
Route is evicted after this many missed OGM cycles on its link |
Call router.start(scope, selfId, links) before using any other method. All protocol loops run inside a child scope derived from scope — cancelling scope also stops the router, but router.stop() stops it independently without affecting scope. Calling start again after stop resets all internal state so the node restarts clean.
Links can be added or removed while the router is running — no restart required.
// Add a new radio interface at runtime
router.addLink(newLink)
// Remove a radio interface (returns false if not found)
router.removeLink(linkId = "lora1")addLink immediately starts the receive, OGM, and TX coroutines for the new link. The link is eligible for OGM relay and unicast forwarding from the next OGM cycle onward.
removeLink cancels those coroutines and immediately evicts any routing table entries that were learned via that link, rather than waiting for the normal purge timeout. This prevents the router from continuing to forward traffic down a dead interface.
router.links always reflects the current live set.
// Send
router.send(dstId = 0x042u, payload = bytes)
// Receive
router.incomingData.collect { (srcId, payload) -> ... }send looks up the best next hop in the routing table and enqueues a DataFrame. If no route is known the frame is silently dropped. Forwarding nodes decrement TTL (initial value 15) and drop frames that reach zero.
// Current snapshot
val table: Map<NodeId, NeighborEntry> = router.routes.value
// Live updates — emits a new snapshot on every add, update, or eviction
router.routes.collect { table -> println(table) }routes is a StateFlow so it always holds the latest snapshot; new collectors receive it immediately without waiting for the next change. The flow is reset to an empty map on each start call, so collectors do not need to resubscribe after a restart.
For UI or monitoring use cases, asRouteSummaryFlow() gives a lower-frequency view that only emits when something routing-relevant changes:
router.routes
.asRouteSummaryFlow()
.collect { table ->
table.forEach { (dst, summary) ->
println("$dst via ${summary.nextHop} on ${summary.linkId} tier=${summary.minBandwidthTier} [${summary.status}]")
}
}Each RouteSummary replaces the raw lastSeen timestamp with a coarse LinkStatus:
| Status | Meaning |
|---|---|
GOOD |
An OGM arrived within the last ogmInterval — path is fresh |
WARNING |
At least one OGM cycle was missed — path may be degrading |
When the purge threshold is exceeded the entry disappears from the map entirely; there is no third "dead" state. The lastSeq field is dropped because it carries no routing-decision value for observers. distinctUntilChanged suppresses any emission where only lastSeen was refreshed within the same status bucket — the common case for a healthy, stable network.
Multicast is built on a per-group spanning tree. Members send periodic beacons toward the group root; every relay node records which links beacons arrive on and which link leads toward the root. Multicast frames then travel the tree in both directions without flooding.
Group identifiers are opaque 20-bit values chosen by the application. The node designated as root joins with roots = listOf(selfId); members join with roots pointing at the root:
// Root node — beacons flow toward it, so no beacon loop is started
val gid = GroupId(0xA01u) // application-assigned opaque id
router.joinGroup(gid, roots = listOf(router.selfId))
// Member node — launches a beacon loop toward the root
router.joinGroup(gid, roots = listOf(rootId), beaconInterval = 30.seconds)// Send to all members of a group
router.sendMulticast(gid, payload = bytes)
// Receive multicast messages
router.incomingMulticast.collect { msg ->
println("${msg.srcId} → ${msg.groupId}: ${msg.payload.decodeToString()}")
}When the primary root goes offline, members independently select the first alternative root with a known route from the roots list passed to joinGroup. Because all nodes share the same OGM view they converge on the same root without coordination. The active root is carried in every BeaconFrame so relay nodes need no local root list.
router.silence() // shut the radio completely
router.unsilence() // resumeWhile silent the node is completely dark on the wire:
send and sendMulticast calls are dropped.Incoming frames are still decoded and the local routing table is still updated, so the node can resume routing immediately on unsilence(). The OGM and beacon loops keep running at their scheduled interval, so the node re-announces itself within one OGM cycle with no burst of stale frames.
All outgoing frames pass through TxQueue — a sorted, pull-model queue shared by all link TX coroutines. Frames are dequeued by whichever link's TX loop calls pollFor first.
Ordering is controlled by a composable list of Rule objects (each a Comparator<TxEntry>). The defaults are:
controlFirst — OGM / BEACON before DATA / MULTICASTolderFirst — FIFO within a priority classinsertionOrderFirst — strict total order tiebreakerCustom rule lists can be injected into TxQueue:
val q = TxQueue(listOf(
compareBy { when (it.flavor) { PacketFlavor.OGM -> 0; PacketFlavor.BEACON -> 1; else -> 2 } },
olderFirst,
insertionOrderFirst
))enqueue returns a TxHandle that lets you track and mutate a queued entry before it is transmitted:
val handle: TxHandle = txQueue.enqueue(entry)
// Non-suspending notification — fires on whichever thread transmits the frame
handle.onSent { log("frame transmitted") }
// Suspending — use inside a coroutine
launch { handle.awaitSent(); doNextStep() }
// Mutations — all return false if the entry was already sent
handle.cancel() // remove from queue
handle.replace(newBytes) // swap frame bytes, keep queue position
handle.swap(otherHandle) // exchange positions with another queued entryswap exchanges enqueuedAt between two entries, inverting their relative order. It has no effect across flavor boundaries — a control frame always transmits before a data frame regardless of a swap.
Use recommendedConfig to derive ogmInterval and neighborPurgeMultiplier from link bandwidth:
val cfg = recommendedConfig(
linkBandwidthBps = 50_000L, // 50 kbps
expectedNodes = 40, // conservative upper bound on network size
dataFraction = 0.5 // reserve at least 50 % for application data
)
// cfg.ogmInterval ≈ 90 ms
// cfg.neighborPurgeMultiplier = 5
// cfg.purgeTimeout ≈ 450 ms (= interval × multiplier)The formula: ogmInterval = N × 56 bits / ((1 − dataFraction) × linkBandwidthBps), floored at minOgmInterval (default 5 s). The purge multiplier is chosen so purgeTimeout ≈ 60 s across all link speeds, clamped to [3, 5].
| Link speed | Nodes | ogmInterval | purgeMultiplier |
|---|---|---|---|
| 50 bps | 40 | ~90 s | 3 |
| 500 bps | 40 | ~9 s | 5 |
| 5 kbps | 40 | ~5 s (floor) | 5 |
| 50 kbps | 40 | ~5 s (floor) | 5 |
| ≥ 1 Gbps | any | 5 s (floor) | 5 |
All frames are bit-packed big-endian. Byte 0 always carries the 4-bit type tag in the high nibble and the high nibble of the first 12-bit node ID in the low nibble.
| Frame | Size | Notes |
|---|---|---|
OgmFrame |
7 bytes | originatorId(12b) + senderId(12b) + ttl(4b) + seqNum(16b) + minBandwidthTier(8b) |
DataFrame |
7 + n bytes | nextHop(12b) + src(12b) + dst(12b) + ttl(4b) + varint(len) + payload |
BeaconFrame |
8 bytes | nextHop(12b) + src(12b) + groupId(20b) + activeRoot(12b) |
MulticastFrame |
8 + n bytes | src(12b) + groupId(20b) + ttl(4b) + seqNum(16b) + varint(len) + payload |
Payload lengths use a 7-bit continuation varint: lengths ≤127 cost 1 byte, ≤16383 cost 2 bytes, ≤2097151 cost 3 bytes. No upper limit is imposed at the codec layer.
Codec.encode and Codec.decode handle serialisation. decode returns null for malformed or unknown frames; the router silently drops them.
Why widest-path bandwidth as the routing metric? A pure hop-count metric (TTL) would prefer a 2-hop LoRa path over a 3-hop WiFi path regardless of the capacity difference — which is the wrong trade-off for heterogeneous radio meshes. Instead, each OGM carries minBandwidthTier = floor(log₂(bps)) for the narrowest link it has traversed. The router keeps whichever path has the highest bottleneck tier; TTL breaks ties. This means a short slow path never wins over a longer fast path, and the log₂ encoding keeps the field in one byte while covering 1 bps (tier 0) through ~9 Pbps (tier 63).
What happens when OGMs from the same source arrive with different tiers? Two sub-cases arise. If the OGMs come via different next-hop neighbours, the higher-tier one wins and the lower-tier one is ignored — the lower-quality alternative only installs itself after the better next-hop's entry expires from inactivity. If OGMs come via the same next-hop but with varying tiers (relay suppression occasionally chose a slower intermediate hop), lastSeen is refreshed but the stored tier is not lowered. The per-hop entry therefore records the best ever observed tier for that next-hop, not the current one. This makes the routing decision resistant to transient relay-path fluctuations, at the cost that a permanent upstream degradation (without the next hop disappearing) is not reflected until the entry is evicted and reinstalled by a fresh OGM.
Why pull-model TxQueue? Each link's TX coroutine suspends in pollFor until a frame is available. A slow LoRa radio never delays a fast Ethernet link. No thread sleeps, no polling timers.
Why separate upstream and downstream links in MulticastTree? It enables two optimisations: leaf suppression (a relay whose downstream members are actively beaconing does not need to send its own beacon) and immediate upstream replacement (when the best route to the root changes, the old upstream link is discarded atomically, preventing duplicate multicast transmissions during reroutes).
Why activeRoot carried in BeaconFrame? Relay nodes need no local configuration. The active root is embedded in every beacon, so any node can forward toward the correct root without knowing the group's root list.
The library ships UdpMulticastLink, a ready-to-use [Link] implementation for JVM and Android. All nodes bound to the same multicast address and port form one broadcast domain, including nodes on different machines on the same LAN.
UDP preserves datagram boundaries so no length-prefix framing is needed, and there is no risk of byte-level interleaving between concurrent writers.
val link = UdpMulticastLink(
id = "udp:239.0.0.1:5001",
multicastGroup = "239.0.0.1", // administratively scoped, stays on the LAN
port = 5001,
ogmInterval = 2.seconds,
bandwidthBps = 100_000_000L, // 100 Mbps — used as the routing metric
)
link.startReading(scope)
val router = KiroRouter()
router.start(scope, selfId = 1u, links = listOf(link))
// When tearing down:
link.close()Multiple instances on different ports or addresses model separate broadcast media. A node connected to two of them bridges them:
val linkA = UdpMulticastLink(id = "a", multicastGroup = "239.0.0.1", port = 5001)
val linkB = UdpMulticastLink(id = "b", multicastGroup = "239.0.0.2", port = 5002)
val router = KiroRouter()
router.start(scope, selfId = 2u, links = listOf(linkA, linkB))outboundTransform and inboundTransform let you pre- and post-process frames independently of the router — the typical use case is encryption. Each is a suspend lambda so it can call async crypto APIs. Returning null drops the frame silently (useful when authentication fails on the inbound side).
val cipher = AesGcmCipher(sharedKey)
val link = UdpMulticastLink(
id = "udp:239.0.0.1:5001",
multicastGroup = "239.0.0.1",
port = 5001,
outboundTransform = { frame -> cipher.encrypt(frame) },
inboundTransform = { frame -> cipher.decrypt(frame) }, // returns null on auth failure
)The :demo submodule ships a self-contained fat JAR that simulates a mesh over named FIFO pipes. Each medium is a directory; nodes on the same medium share a broadcast channel.
./gradlew :demo:shadowJar
# Output: demo/build/libs/kiro-demo.jarjava -jar kiro-demo.jar <nodeId> <medium1> [<medium2> ...]
Each medium path is a directory that will be created on first use. A node on the same directory as another node can hear its broadcasts.
# Terminal 1 — node 1 on net-a only
java -jar kiro-demo.jar 1 /tmp/net-a
# Terminal 2 — node 2 bridges net-a and net-b
java -jar kiro-demo.jar 2 /tmp/net-a /tmp/net-b
# Terminal 3 — node 3 on net-b only
java -jar kiro-demo.jar 3 /tmp/net-bAfter a few seconds node 1 and node 3 discover each other through node 2.
| Command | Description |
|---|---|
send <dstId> <message> |
Unicast a text message to another node |
mcast <groupId> <message> |
Send a multicast to a group |
join <groupId> [<rootId>] |
Join a group; omit rootId to join as root |
leave <groupId> |
Leave a group |
routes |
Print the current routing table |
offline |
Simulate going offline (suppresses all send and receive) |
online |
Come back online |
quit / exit
|
Exit |