Version: 1.0.0-SNAPSHOT
1. Overview
meshtastic-java-client is an async Java client for Meshtastic radios that provides:
-
transport and reconnect management
-
startup synchronization
-
strongly-typed event dispatching
-
node database + snapshot status model
-
admin/config/channel APIs through
AdminService -
utility request APIs (fire-and-forget and payload-await)
-
BLE transport SPI for platform-specific BLE backends
Javadocs are generated at API Javadocs.
2. Why This Project Exists
This library exists because there was no clean, reusable low-level Meshtastic Java client readily available for normal Java applications.
The Java code that did exist was largely embedded inside the Android client, which is useful as an application but not ideal as a standalone library for:
-
headless services
-
bots that listen and respond to mesh messages
-
server-side Java integrations
-
custom desktop Java UI clients
The original motivation for this project was simple: build a Meshtastic bot in Java that could listen on the mesh and respond to messages without having to repurpose an application codebase.
That led to a broader goal:
-
provide a clean event-driven core for headless and bot-style applications
-
expose enough API surface to build a full Java desktop/mobile UI client on top
The architecture is intentionally designed to balance those two use cases. It tries to stay low-level enough to be flexible, while still offering the higher-level helpers most Meshtastic apps end up needing in practice.
|
There may still be bugs, missing convenience APIs, or firmware-specific edge cases. Contributions, bug reports, and feedback are welcome. |
3. Current Scope And Gaps
Today, this project is strongest as a serial/TCP Meshtastic client library.
-
Production-ready now:
-
Serial and TCP transports
-
Event-driven client model
-
Admin/config/channel refresh + write APIs
-
Request lifecycle + startup lifecycle signaling
-
Pending major items:
-
bundled production BLE backend (BLE SPI exists)
|
If you are evaluating the library for real use, the simplest way to think about it is: serial and TCP are ready first, while BLE is the main area still being rounded out. |
|
Firmware flashing is intentionally out of scope for this library. For firmware updates, use the official Meshtastic tools such as the web flasher. If you need to build a custom upgrade workflow into an application, treat that as application-specific integration work rather than part of this core client. |
4. Feature Map
| Capability | Primary API | Notes |
|---|---|---|
Transport lifecycle |
|
Serial/TCP are production-ready in core; BLE is SPI-backed. |
Startup lifecycle |
|
State transitions: |
Mesh event stream |
|
Text, position, telemetry, node discovery, message status, request lifecycle, admin model updates. |
Admin/config writes |
|
Accepted-only and verify-applied variants + structured |
Snapshot model |
|
Fast read path for UI rendering before a refresh finishes. |
Utility requests |
|
Lets you choose between "request accepted" and "wait for the live payload". |
5. API Catalog (By Class)
Use this quick map when building app-level abstractions:
-
MeshtasticClientHandles connection lifecycle, startup sync, text send helpers, utility node-info/position/telemetry requests, and event fan-out. -
MeshtasticEventListenerSingle callback surface for startup state, request lifecycle, admin model updates, and mesh data events. -
AdminServiceTyped refresh, read, and write operations for owner, metadata, channels, config sections, and module configs. -
ProtocolConstraintsValidation helpers for channel index, name, and PSK, including non-throwing issue lists for forms or settings screens. -
NodeDatabase+MeshNodeSnapshot storage plus node freshness/status handling (LIVE/IDLE/CACHED/OFFLINE). -
SerialTransport/TcpTransport/BleTransportTransport implementations that deal with framing, reconnect behavior, and pacing controls.
6. Node Database Extension Point
NodeDatabase is a public extension point for applications, not just an internal detail.
At a glance:
| Topic | Meaning |
|---|---|
|
Extension point for node storage, freshness tracking, and snapshot reads. |
|
Default implementation used by the quick-start examples. |
Custom implementations |
Useful when an app needs persistence, custom retention rules, or integration with another data layer. |
Common reasons to implement your own NodeDatabase include:
-
persisting node state across restarts
-
integrating with an existing cache or repository layer
-
customizing retention/eviction behavior
-
supporting application-specific indexing or query patterns
For most applications, InMemoryNodeDatabase is the right place to start. If you need different storage behavior,
create MeshtasticClient with your own implementation.
Quick API reference:
| Area | Main methods | Purpose |
|---|---|---|
Self identity |
|
Tracks which node is the current local/self node. |
Inbound updates |
|
Applies radio/user/telemetry/signal updates into the snapshot model. |
Snapshot reads |
|
Lets apps render or query the current node view. |
Observers and lifecycle |
|
Lets apps react to updates and manage database lifecycle. |
Optional cleanup |
|
Supports manual or scheduled purging of very old node records. |
Cleanup behavior depends on the NodeDatabase implementation:
-
InMemoryNodeDatabasesupports both scheduled cleanup and manual one-shot purging -
automatic cleanup is opt-in and does not start unless the application enables it
-
custom implementations may support automatic cleanup, manual cleanup, both, or neither
-
cleanup is purge-oriented; status calculation (
LIVE,IDLE,CACHED,OFFLINE) is a separate concern
Example cleanup policy:
db.startCleanupTask(NodeCleanupPolicy.builder()
.staleAfter(Duration.ofDays(7))
.initialDelay(Duration.ofMinutes(5))
.interval(Duration.ofMinutes(1))
.build());
Manual one-shot purge:
db.purgeStaleNodes(Duration.ofDays(7));
7. Node Status Semantics
MeshNode.getCalculatedStatus() is the library’s best estimate of how fresh a node looks right now. It is calculated
on the client side from the data we have seen, not read from a single field the radio sends us.
Why the client calculates status itself:
-
Meshtastic gives us timestamps, signal metadata, and snapshot data, but it does not give us one ready-made status value that works well for every UI
-
applications usually want to distinguish:
-
a node heard live during this app session
-
a node only known from startup snapshot/history
-
a node that used to be current but has gone quiet
-
the client fills in that gap so applications can use the library as-is instead of having to invent their own status model first
In other words, the status is a convenience layer built on top of the raw radio data. It is meant to be useful and predictable for application code, not a claim that the radio itself has declared "this node is live" or "this node is offline."
Default status reference:
| Status | Default Threshold | Meaning |
|---|---|---|
|
n/a |
This is the local node the client is connected to. |
|
|
This app has heard a real packet from this node recently in the current session. |
|
until |
This app heard the node earlier in the current session, but not recently enough to still call it |
|
until |
The node came from startup snapshot/history, but this app session has not yet heard it speak live. |
|
older than non-live threshold |
The node data is old enough that the client should stop showing it as current. |
Real examples:
| Status | Example |
|---|---|
|
You started the client, and that node sent a text, telemetry, or position packet 2 minutes ago. |
|
That node spoke 40 minutes ago during this app session, but nothing newer has arrived since then. |
|
The node appeared in the startup dump when the client connected, but it has not sent any live traffic since your app started. |
|
The node has not been heard live or via recent radio snapshot data within the non-live threshold window. |
Why these rules exist:
-
they distinguish "heard live by this app" from "present in radio snapshot/history"
-
they give UIs something more useful than a simple online/offline split
-
they avoid treating cached startup data as if the node had actually spoken during the current app session
Example custom policy:
NodeStatusPolicy customPolicy = NodeStatusPolicy.builder()
.liveThreshold(Duration.ofMinutes(2))
.nonLiveThreshold(Duration.ofHours(6))
.build();
MeshNode.NodeStatus status = node.getCalculatedStatus(customPolicy);
How NodeStatusPolicy works:
-
NodeStatusPolicyis the default threshold-based calculator used bynode.getCalculatedStatus() -
it controls the two time windows that drive the status transitions
-
it does not define all five statuses separately because not every status is time-threshold based
Why it only has two settings:
-
SELFdoes not need a threshold; it is determined by identity -
LIVEneeds one threshold: how long a recent live packet keeps a node in theLIVEbucket -
IDLEandCACHEDshare the same outer freshness boundary -
OFFLINEis simply the fallback once a node is older than that outer boundary
So the two settings are:
| Setting | Meaning |
|---|---|
|
How long a locally heard node stays |
|
How long an |
What that example policy does:
| Setting | Example Value | Effect |
|---|---|---|
|
|
A node will stop being |
|
|
An |
Compared with the defaults, that example makes the UI much quicker to stop calling a node "current."
If these semantics do not fit your app, you have a few options:
-
use your own
NodeStatusCalculatorif threshold-based logic is not enough -
interpret
MeshNode.getCalculatedStatus()differently in your UI/application layer -
provide your own
NodeDatabaseimplementation if you want different snapshot/freshness semantics -
if needed, keep the library as-is and apply your own status semantics at the app layer without changing the client itself
The exact thresholds currently come from:
-
MeshConstants.LIVE_THRESHOLD -
MeshConstants.NON_LIVE_NODE_THRESHOLD
8. Quick Start
NodeDatabase db = new InMemoryNodeDatabase();
MeshtasticClient client = new MeshtasticClient(db);
SerialConfig cfg = SerialConfig.builder()
.portName("/dev/cu.usbmodem80B54ED11F101")
// Optional transport pacing between framed writes (default: 200ms)
.outboundPacingDelayMs(200)
.build();
// Optional advanced request-timing tuning (defaults shown)
client.setRequestLockReleaseCooldownMs(200); // default: 200ms
client.setRequestCorrelationTimeout(Duration.ofSeconds(30)); // default: 30s
client.connect(new SerialTransport(cfg));
|
Keep defaults unless you have measured evidence your radio/firmware tolerates tighter pacing. |
|
The quick start uses |
|
|
|
Order matters for some settings:
|
9. Application Use Cases
This repository provides a library, not a bundled UI application or bot.
The following sections show recommended usage patterns for applications built on top of the library:
-
desktop/mobile UI clients
-
headless services
-
bots and integrations
Use them as reference architectures, not as included runtime modules.
9.1. Shared Startup Flow (Recommended)
Use startup state events to drive UI readiness and progress indicators.
client.addEventListener(new MeshtasticEventListener() {
@Override
public void onStartupStateChanged(StartupState previousState, StartupState newState) {
switch (newState) {
case SYNC_LOCAL_CONFIG -> statusLabel.setText("Reading local radio...");
case SYNC_MESH_CONFIG -> statusLabel.setText("Reading mesh state...");
case READY -> statusLabel.setText("Ready");
case DISCONNECTED -> statusLabel.setText("Disconnected");
}
}
});
client.connect(new SerialTransport(cfg));
For tab gating, use client.isReady() and keep settings/message actions disabled until READY.
9.2. UI Client Pattern
This workflow is recommended for desktop/mobile UI applications built on top of the library.
-
Create
NodeDatabaseandMeshtasticClient. -
Register one event listener implementation that forwards to your UI state/store.
-
Connect transport.
-
Gate settings/actions until
StartupState.READY. -
Render settings pages from snapshots first, then trigger section-specific refresh.
NodeDatabase db = new InMemoryNodeDatabase();
MeshtasticClient client = new MeshtasticClient(db);
AdminService admin = client.getAdminService();
client.addEventListener(new MeshtasticEventListener() {
@Override
public void onStartupStateChanged(StartupState previous, StartupState next) {
switch (next) {
case SYNC_LOCAL_CONFIG -> uiStore.setStatus("Reading local radio...");
case SYNC_MESH_CONFIG -> uiStore.setStatus("Reading mesh state...");
case READY -> uiStore.setStatus("Ready");
case DISCONNECTED -> uiStore.setStatus("Disconnected");
}
}
@Override
public void onNodeDiscovery(NodeDiscoveryEvent event) {
uiStore.upsertNode(event.getNode());
}
@Override
public void onTelemetryUpdate(TelemetryUpdateEvent event) {
uiStore.updateTelemetry(event.getNodeId(), event.getTelemetry());
}
@Override
public void onPositionUpdate(PositionUpdateEvent event) {
uiStore.updatePosition(event.getNodeId(), event.getPosition());
}
@Override
public void onAdminModelUpdate(AdminModelUpdateEvent event) {
// Keep settings pages in sync while app is connected.
uiStore.markAdminSectionDirty(event.getSection(), event.getChannelIndex());
}
@Override
public void onRequestLifecycleUpdate(RequestLifecycleEvent event) {
uiStore.logRequest(
event.getStage() + " id=" + event.getRequestId()
+ " port=" + event.getPort()
+ " msg=" + event.getMessage()
);
}
});
client.connect(new SerialTransport(cfg));
9.3. Headless Client Pattern
This workflow is recommended for bots, services, and integrations without a GUI.
-
Connect client with the target transport.
-
Await
READYbefore operational tasks. -
Use listeners for streaming behavior (telemetry, text, node updates).
-
Use scheduled jobs for periodic refresh/write logic.
-
Shutdown cleanly with
client.shutdown().
NodeDatabase db = new InMemoryNodeDatabase();
MeshtasticClient client = new MeshtasticClient(db);
AdminService admin = client.getAdminService();
CountDownLatch readyLatch = new CountDownLatch(1);
client.addEventListener(new MeshtasticEventListener() {
@Override
public void onStartupStateChanged(StartupState previous, StartupState next) {
if (next == StartupState.READY) {
readyLatch.countDown();
}
}
@Override
public void onTextMessage(ChatMessageEvent event) {
botEngine.handleInboundMessage(event);
}
});
client.connect(new SerialTransport(cfg));
readyLatch.await(45, TimeUnit.SECONDS);
// Periodic task: refresh security + mqtt every 10 minutes.
ScheduledExecutorService jobs = Executors.newSingleThreadScheduledExecutor();
jobs.scheduleAtFixedRate(() ->
admin.refreshSecurityConfig()
.thenCompose(cfgSec -> admin.refreshMqttConfig())
.exceptionally(ex -> {
log.warn("Periodic refresh failed: {}", ex.getMessage());
return null;
}),
0, 10, TimeUnit.MINUTES
);
10. Hardware Integration Tests
Hardware-in-the-loop tests are intentionally opt-in and isolated from default unit tests.
The project provides one hardware test profile, hardware-it, with two transport modes:
-
serial: direct USB/serial radio validation -
tcp: Meshtastic TCP socket validation over the network
Both modes run the same RealRadioAdminIT suite. This helps catch transport-specific timing, reconnect, and startup-sync issues while keeping the functional test coverage consistent.
-
Maven profile:
hardware-it -
Test class:
RealRadioAdminIT -
Transport selector:
MESHTASTIC_TEST_TRANSPORT(serialby default, ortcp) -
Required for serial:
MESHTASTIC_TEST_PORT -
Required for TCP:
MESHTASTIC_TEST_TCP_HOST -
Optional environment variables:
-
MESHTASTIC_TEST_TCP_PORT(default4403, only used for TCP) -
MESHTASTIC_TEST_TIMEOUT_SEC(default45) -
MESHTASTIC_TEST_MUTABLE_CHANNEL_INDEX(default2) -
MESHTASTIC_TEST_ENABLE_OWNER_WRITE(defaultfalse) -
MESHTASTIC_TEST_ENABLE_SECURITY_WRITE(defaultfalse) -
MESHTASTIC_TEST_ENABLE_MQTT_WRITE(defaultfalse) -
MESHTASTIC_TEST_ENABLE_REBOOT_TEST(defaultfalse)
Coverage in current hardware integration tests:
-
startup readiness (
READY) -
metadata/owner/likely-channel read path
-
core config read matrix
-
module config read matrix (capability tolerant)
-
request burst lifecycle terminal events
-
reversible channel write/readback/restore
-
optional reversible owner write/readback/restore (enabled with
MESHTASTIC_TEST_ENABLE_OWNER_WRITE=true) -
optional reversible security config write/readback/restore (enabled with
MESHTASTIC_TEST_ENABLE_SECURITY_WRITE=true) -
optional reversible MQTT module config write/readback/restore (enabled with
MESHTASTIC_TEST_ENABLE_MQTT_WRITE=true) -
optional reboot/reconnect resilience (enabled with
MESHTASTIC_TEST_ENABLE_REBOOT_TEST=true)
10.1. Serial Hardware Mode
Use serial mode when validating against a directly attached USB/serial radio. This is usually the simplest and most deterministic hardware path.
Required:
-
MESHTASTIC_TEST_TRANSPORT=serial -
MESHTASTIC_TEST_PORT=/dev/…
Example:
MESHTASTIC_TEST_TRANSPORT=serial \
MESHTASTIC_TEST_PORT=/dev/cu.usbmodem80B54ED11F101 \
mvn -Phardware-it verify
10.2. TCP Hardware Mode
Use TCP mode when validating the same admin/config flows over the radio’s Meshtastic network socket. This is useful for exercising transport-specific concerns such as slower startup sync, reconnect timing, and network latency.
Required:
-
MESHTASTIC_TEST_TRANSPORT=tcp -
MESHTASTIC_TEST_TCP_HOST=<radio-ip>
Optional:
-
MESHTASTIC_TEST_TCP_PORT=4403
Example:
MESHTASTIC_TEST_TRANSPORT=tcp \
MESHTASTIC_TEST_TCP_HOST=192.168.1.40 \
MESHTASTIC_TEST_TCP_PORT=4403 \
mvn -Phardware-it verify
10.3. Shared Optional Flags
These optional flags apply to both serial and TCP hardware runs:
-
MESHTASTIC_TEST_TIMEOUT_SEC -
MESHTASTIC_TEST_MUTABLE_CHANNEL_INDEX -
MESHTASTIC_TEST_ENABLE_OWNER_WRITE -
MESHTASTIC_TEST_ENABLE_SECURITY_WRITE -
MESHTASTIC_TEST_ENABLE_MQTT_WRITE -
MESHTASTIC_TEST_ENABLE_REBOOT_TEST
|
The reversible write test restores original channel state after verification and should target an active non-primary channel slot. |
12. Core API Patterns
12.1. Transport and Request Timing Controls
The client intentionally serializes in-flight requests for radio safety. Timing controls let you tune throughput without changing that safety model.
12.1.1. Transport Pacing
SerialConfig and TcpConfig expose:
-
outboundPacingDelayMs(default200)
Usage:
SerialConfig serialCfg = SerialConfig.builder()
.portName("/dev/cu.usbmodem80B54ED11F101")
.outboundPacingDelayMs(120) // lower for faster radios, 0 disables pacing
.build();
TcpConfig tcpCfg = TcpConfig.builder()
.host("192.168.1.50")
.port(4403)
.outboundPacingDelayMs(120)
.build();
These are transport setup settings. The transport reads them when it is created and connected. If you want to change them later, build a new transport config and reconnect with it.
12.1.2. Request Correlation Controls
MeshtasticClient exposes:
-
setRequestLockReleaseCooldownMs(long)(default200ms) -
setRequestCorrelationTimeout(Duration)(default30s)
Usage:
client.setRequestLockReleaseCooldownMs(120);
client.setRequestCorrelationTimeout(Duration.ofSeconds(20));
These settings can be changed at runtime. You can set them before or after connect(…), and new requests will use
the updated values.
12.1.3. Admin Verification Policy
AdminService exposes:
-
setVerificationPolicy(AdminVerificationPolicy)
Usage:
client.getAdminService().setVerificationPolicy(
AdminVerificationPolicy.builder()
.maxAttempts(3)
.initialRetryDelay(Duration.ofMillis(400))
.retryBackoffMultiplier(1.8)
.maxRetryDelay(Duration.ofSeconds(2))
.build()
);
This setting can also be changed at runtime and affects future verifyApplied=true admin writes.
Example TRACE output when timing diagnostics are enabled:
[TX] Request queue delay=0ms lock_wait=0ms port=ADMIN_APP dst=!53ad0f1e
[TX] Request 1856599835 completed in 6450ms (port=ADMIN_APP dst=!53ad0f1e success=true)
[UTIL] Await TELEMETRY_APP for !699c5400 completed in 312ms (success=true)
12.2. Fire-and-forget Request Correlation
Use these request methods when you only need to know that the request was sent and accepted at the routing level:
client.requestNodeInfo(nodeId);
client.requestPosition(nodeId);
client.requestTelemetry(nodeId);
12.3. Payload-Await Requests
Use payload-await methods when the caller needs to wait for the live data to come back, not just for the request to be accepted:
client.requestNodeInfoAwaitPayload(nodeId);
client.requestPositionAwaitPayload(nodeId);
client.requestTelemetryAwaitPayload(nodeId);
12.4. Payload-Await with Snapshot Fallback
Some radios and firmware versions are not perfectly consistent about when the live payload shows up. In those cases,
the …AwaitPayloadOrSnapshot(…) helpers are often the better choice.
They wait for the live payload first, but if it does not arrive in time they fall back to the latest node snapshot. That gives the caller something useful to work with instead of failing a request that may actually have succeeded.
client.requestNodeInfoAwaitPayloadOrSnapshot(nodeId, Duration.ofSeconds(15));
client.requestPositionAwaitPayloadOrSnapshot(nodeId, Duration.ofSeconds(15));
client.requestTelemetryAwaitPayloadOrSnapshot(nodeId, Duration.ofSeconds(15));
12.5. Messaging APIs
Use convenience methods on MeshtasticClient for common text workflows:
// 1) Typical direct message to a single node on the primary channel.
client.sendDirectText(targetNodeId, "hello from java");
// 2) Broadcast to the public/default chat on the primary channel.
client.sendChannelText(MeshConstants.PRIMARY_CHANNEL_INDEX, "hello mesh");
// 3) Broadcast to a specific shared channel such as channel 1 ("Test").
client.sendChannelText(1, "hello test channel");
// 4) Advanced: direct message to one node while using a non-primary
// shared channel context for encryption/key selection.
client.sendDirectText(targetNodeId, 1, "hello on channel 1");
The text helpers automatically chunk long messages for you.
|
For direct messages, the destination node decides who the packet is going to, while the channel index decides which shared channel key/context is used to send it. For channel messages, the destination is broadcast and the channel index decides which shared channel receives it. |
12.5.1. Which Method Should I Use?
| Goal | Method |
|---|---|
Send a private message to one device using the normal/default path |
|
Send a message to everyone on the public/default channel |
|
Send a message to everyone on a secondary channel such as "Test" |
|
Send a private message to one device while using a specific shared channel’s key/context |
|
12.5.2. UI Example
This is a common UI pattern:
-
DM conversation view: call
sendDirectText(selectedNodeId, message) -
Public chat view: call
sendChannelText(MeshConstants.PRIMARY_CHANNEL_INDEX, message) -
Secondary channel chat view (for example "Test"): call
sendChannelText(selectedChannel.index(), message)
if (conversation.isDirect()) {
client.sendDirectText(conversation.targetNodeId(), messageText);
} else {
client.sendChannelText(conversation.channelIndex(), messageText);
}
12.5.3. Bot Or Integration Example
Bots often need both broadcast behavior and direct replies.
client.addEventListener(new MeshtasticEventListener() {
@Override
public void onChatMessage(ChatMessageEvent event) {
String text = event.getText();
int fromNode = event.getFromNodeId();
if ("ping".equalsIgnoreCase(text)) {
client.sendDirectText(fromNode, "pong");
}
if ("announce".equalsIgnoreCase(text)) {
client.sendChannelText(MeshConstants.PRIMARY_CHANNEL_INDEX, "bot online");
}
}
});
12.5.4. Advanced Channel Context Example
If two nodes share a private secondary channel, a direct message can still use that channel’s shared key/context rather than the primary channel.
int opsNodeId = 0x00ba0dd0;
int opsChannelIndex = 2;
client.sendDirectText(opsNodeId, opsChannelIndex, "check-in complete");
12.6. Admin Service Access
AdminService is obtained from the client:
AdminService admin = client.getAdminService();
AdminService is the library’s settings and configuration layer.
It is there so application code does not have to hand-build every admin protobuf request, keep track of every response
shape, merge snapshots into a working radio model, or reimplement write verification logic.
In practice, MeshtasticClient and AdminService do different jobs:
-
MeshtasticClientowns transport lifecycle, startup sync, request correlation, messaging helpers, and event fan-out. -
AdminServiceowns radio settings/domain state: owner, metadata, channels, config sections, module configs, and admin-oriented write/readback workflows.
Why this layer exists:
-
Meshtastic admin/config data arrives across multiple message types and at multiple times.
-
Settings UIs usually need both instant cached reads and explicit refresh-on-open behavior.
-
Some admin writes are not visible immediately, so the service needs to handle delayed read-back verification.
-
It is easier for app developers to work with a stable service-oriented API than with dozens of raw admin message variants.
What AdminService gives you:
-
a live
RadioModelsnapshot viagetSnapshot() -
typed snapshot accessors for channels/config/module-config sections
-
explicit async refresh methods for page-specific data loads
-
typed write helpers for channel/config/module/owner operations
-
optional verify-applied semantics with structured
AdminWriteResult
The simplest mental model is:
-
use
AdminServiceas your settings/read-write facade -
use
MeshtasticEventListener.onAdminModelUpdate(…)to keep UI state in sync while connected -
use snapshots for instant rendering
-
use refresh methods when a page opens or when a user explicitly asks to reload
This split keeps the lower-level client API focused while still giving UI and headless applications a practical settings API to build on.
12.6.1. Snapshot Model And Refresh Model
AdminService is designed around snapshots first, then refreshes.
-
Snapshot reads are immediate, which makes them a good fit for first paint in a UI.
-
Refresh methods are explicit and async so callers control when radio traffic is generated.
-
Incoming admin/local-state packets continuously update the in-memory
RadioModel.
Typical pattern:
-
render immediately from snapshot
-
trigger a focused refresh for the active page
-
listen for
onAdminModelUpdate(…)to keep the view/store current
That avoids both extremes:
-
blocking UI on a full radio sweep every time
-
forcing application code to manually merge incoming admin packets
12.6.2. Who Should Use It?
Use AdminService when your application needs:
-
settings screens
-
channel editors
-
owner/metadata display
-
config/module configuration
-
lifecycle admin actions such as reboot and factory reset
If you are building a simple bot or telemetry consumer and do not need radio settings, you may spend most of your
time in MeshtasticClient plus event listeners and hardly touch AdminService.
13. Settings Page Patterns
13.1. Read-on-open (Snapshot + Refresh)
Render from snapshot immediately, then refresh only the page-specific state:
AdminService admin = client.getAdminService();
// Immediate render from cached state
renderChannels(admin.getChannelSnapshots());
renderConfig(admin.getConfigSnapshots());
renderModules(admin.getModuleConfigSnapshots());
// On-demand refresh for a channels/security page
admin.refreshChannelsAndSecurity().thenAccept(model -> {
renderChannels(admin.getChannelSnapshots());
renderSecurity(admin.getConfigSnapshot(ConfigType.SECURITY_CONFIG).orElse(null));
renderMqtt(admin.getModuleConfigSnapshot(ModuleConfigType.MQTT_CONFIG).orElse(null));
});
// Explicit deep refresh (all channel slots 0..7)
admin.refreshAllChannelsAndSecurity().thenAccept(model -> {
renderChannels(admin.getChannelSnapshots());
});
13.2. Module/Config Refresh by Section
admin.refreshCore(); // metadata + owner + core config blocks
admin.refreshCoreConfigs(); // lora/device/display/network
admin.refreshModules(); // all supported module config sections
admin.refreshMqttConfig(); // mqtt only
admin.refreshSecurityConfig(); // security only
14. Admin Write Semantics
These are the main write entry points:
-
setChannelResult(index, channel, verifyApplied) -
setConfigResult(config, verifyApplied) -
setModuleConfigResult(moduleConfig, verifyApplied) -
setOwnerResult(targetNodeId, longName, shortName, verifyApplied)
Boolean-returning variants are also available when you do not need structured result details:
-
setChannel(index, channel, verifyApplied) -
setConfig(config, verifyApplied) -
setModuleConfig(moduleConfig, verifyApplied) -
setOwner(targetNodeId, longName, shortName, verifyApplied)
The key choice is whether you only care that the radio accepted the request, or whether you want the library to read
the value back and confirm that it really showed up. That is what verifyApplied controls, and the result-returning
methods expose the outcome through AdminWriteResult.
14.1. Eventual-Consistency Verification Policy
Some firmware and radio combinations apply writes a little later than you would expect. To avoid reporting a false
failure too early, AdminService supports configurable read-back retries when verifyApplied=true.
Defaults:
-
maxAttempts = 1(immediate single verification attempt; legacy behavior) -
initialRetryDelay = 300ms -
retryBackoffMultiplier = 2.0 -
maxRetryDelay = 2s
Configuration example:
admin.setVerificationPolicy(
AdminVerificationPolicy.builder()
.maxAttempts(3)
.initialRetryDelay(Duration.ofMillis(400))
.retryBackoffMultiplier(1.8)
.maxRetryDelay(Duration.ofSeconds(2))
.build()
);
Practical guidance:
-
keep
maxAttempts=1if you want the fastest possible answer -
use
2-3attempts for radios or firmware builds that are slow to reflect changes -
avoid high retry counts unless you have measured a real need for them, because they can slow down settings flows
14.2. Structured Write Results (Recommended for UI)
For UI code and anything user-facing, the result-returning write methods are usually the better choice:
CompletableFuture<AdminWriteResult> resultFuture =
admin.setConfigResult(configPayload, true);
resultFuture.thenAccept(result -> {
switch (result.status()) {
case VERIFIED_APPLIED -> showSuccess("Applied and verified.");
case ACCEPTED -> showSuccess("Accepted by radio.");
case REJECTED -> showError("Rejected: " + result.message());
case TIMEOUT -> showError("Timed out: " + result.message());
case VERIFICATION_FAILED -> showWarning("Accepted but read-back mismatch.");
case FAILED -> showError("Write failed: " + result.message());
}
});
The result statuses are:
-
ACCEPTED -
VERIFIED_APPLIED -
REJECTED -
TIMEOUT -
VERIFICATION_FAILED -
FAILED
15. Common Admin Operations
15.1. Protocol Constraints (Pre-validation)
Use ProtocolConstraints to validate user input before sending requests.
This is recommended for settings UIs to fail fast and avoid avoidable radio round trips.
// Throwing validation.
ProtocolConstraints.validateChannelIndex(slot);
ProtocolConstraints.validateChannelName(channelName);
ProtocolConstraints.validateChannelPsk(pskByteString);
// Non-throwing validation for form-level error display.
List<ProtocolConstraints.ValidationIssue> issues =
ProtocolConstraints.validateChannelIssues(channelPayload);
if (!issues.isEmpty()) {
renderValidationErrors(issues);
}
15.2. Channel Helpers
// Name update (default verifies by readback)
admin.setChannelName(2, "meshMKT");
// Password/PSK update
admin.setChannelPassword(2, "shared-secret");
admin.setPrimaryChannelPassword("primary-secret");
15.3. Security + MQTT Helpers
Config.SecurityConfig security = Config.SecurityConfig.newBuilder()
.setAdminChannelEnabled(true)
.build();
admin.setSecurityConfig(security, true);
ModuleConfig.MQTTConfig mqtt = ModuleConfig.MQTTConfig.newBuilder()
.setEnabled(true)
.setAddress("mqtt.example.com")
.setUsername("mesh-user")
.build();
admin.setMqttConfig(mqtt, true);
16. Settings Cookbook
This section shows practical UI workflows by page. The pattern is consistent:
-
Render immediately from snapshots.
-
Trigger page-specific refresh on page open.
-
On save, use a write method with verification where correctness matters.
-
Handle
AdminWriteResultfor user-facing success/failure messaging.
16.1. Channels Page
Recommended open flow:
// 1) Snapshot render (instant)
renderChannels(admin.getChannelSnapshots());
// 2) Refresh slots on page open
admin.refreshChannels().thenAccept(channels -> {
renderChannels(channels);
});
Recommended writes:
// rename slot 2
admin.setChannelName(2, "meshMKT")
.thenAccept(applied -> refreshChannelsUi());
// set password/psk on slot 2
admin.setChannelPassword(2, "shared-secret")
.thenAccept(applied -> refreshChannelsUi());
// full channel payload update with structured status
admin.setChannelResult(2, updatedChannel, true)
.thenAccept(this::handleWriteResult);
16.2. Security Page
Recommended open flow:
admin.getConfigSnapshot(ConfigType.SECURITY_CONFIG)
.ifPresent(this::renderSecuritySnapshot);
admin.refreshSecurityConfig().thenAccept(config -> {
if (config.hasSecurity()) {
renderSecurity(config.getSecurity());
}
});
Recommended write:
Config.SecurityConfig security = Config.SecurityConfig.newBuilder()
.setAdminChannelEnabled(true)
.build();
admin.setSecurityConfig(security, true)
.thenAccept(applied -> {
if (applied) showSuccess("Security settings applied.");
else showWarning("Security settings accepted but not verified.");
});
16.3. MQTT Page
MQTT in Meshtastic is a module config section, not a top-level config section.
Recommended open flow:
admin.getModuleConfigSnapshot(ModuleConfigType.MQTT_CONFIG)
.ifPresent(snapshot -> {
if (snapshot.hasMqtt()) {
renderMqtt(snapshot.getMqtt());
}
});
admin.refreshMqttConfig().thenAccept(moduleConfig -> {
if (moduleConfig.hasMqtt()) {
renderMqtt(moduleConfig.getMqtt());
}
});
Recommended write:
ModuleConfig.MQTTConfig mqtt = ModuleConfig.MQTTConfig.newBuilder()
.setEnabled(true)
.setAddress("mqtt.example.com")
.setUsername("mesh-user")
// set password/tls/root fields as required by your deployment
.build();
admin.setMqttConfig(mqtt, true).thenAccept(applied -> {
if (applied) {
showSuccess("MQTT settings applied.");
} else {
showWarning("MQTT settings accepted but read-back mismatch.");
}
});
How MQTT settings are used in a client app:
-
Your app writes MQTT module settings via
AdminService. -
The radio firmware applies those settings and controls MQTT bridge behavior.
-
Your Java app usually does not need a separate MQTT client just to enable radio MQTT forwarding.
-
If your app also consumes broker traffic directly (analytics/backend tooling), run a separate MQTT client in your app process; that is independent of radio admin configuration.
16.4. Device / LoRa / Network / Display Pages
Use typed config refresh/write wrappers:
// open page
admin.refreshDeviceConfig().thenAccept(this::renderDevice);
admin.refreshLoraConfig().thenAccept(this::renderLora);
admin.refreshNetworkConfig().thenAccept(this::renderNetwork);
admin.refreshDisplayConfig().thenAccept(this::renderDisplay);
// save page
admin.setDeviceConfig(deviceConfig, true).thenAccept(this::handleBooleanWrite);
admin.setLoraConfig(loraConfig, true).thenAccept(this::handleBooleanWrite);
admin.setNetworkConfig(networkConfig, true).thenAccept(this::handleBooleanWrite);
admin.setDisplayConfig(displayConfig, true).thenAccept(this::handleBooleanWrite);
16.5. Modules Page (Full Refresh)
For a consolidated modules page:
admin.refreshModules().thenAccept(model -> {
renderModules(admin.getModuleConfigSnapshots());
});
For specific module cards, prefer targeted calls:
admin.refreshTelemetryModuleConfig().thenAccept(this::renderTelemetryModule);
admin.refreshSerialModuleConfig().thenAccept(this::renderSerialModule);
admin.refreshStoreForwardModuleConfig().thenAccept(this::renderStoreForwardModule);
16.6. Write Result Handling Pattern
Reusable UI handler:
private void handleWriteResult(AdminWriteResult result) {
switch (result.status()) {
case VERIFIED_APPLIED -> showSuccess(result.operation() + ": verified.");
case ACCEPTED -> showSuccess(result.operation() + ": accepted.");
case REJECTED -> showError(result.operation() + ": rejected (" + result.message() + ")");
case TIMEOUT -> showError(result.operation() + ": timeout (" + result.message() + ")");
case VERIFICATION_FAILED -> showWarning(result.operation() + ": verification failed.");
case FAILED -> showError(result.operation() + ": failed (" + result.message() + ")");
}
}
17. Snapshot-Driven UI
Use snapshot getters for immediate rendering, and refresh methods when you want to go back to the radio for current data:
RadioModel model = admin.getSnapshot();
Map<Integer, Channel> channels = model.getChannels();
Map<ConfigType, Config> configs = admin.getConfigSnapshots();
18. Reconnect and Resync Lifecycle
When a serial link drops, the transport reconnect logic and startup sync sequence run again automatically:
19. Retry Model and Transport Responsibilities
The client uses two different resilience layers:
-
request-level correlation and retry logic in the core
-
link-level reconnect behavior in the transport
The core client layer owns:
-
request correlation by request id
-
request timeout handling
-
radio lock/cooldown sequencing
-
admin write verification and verification retries
-
startup resync sequencing after a transport reconnects
The transport layer owns:
-
opening and closing the physical link
-
detecting disconnects
-
reconnecting when that transport supports it
-
feeding framed Meshtastic bytes into the client
This matters when you add a new transport. A transport does not need to know how admin verification works, how request correlation is tracked, or how startup sync phases are sequenced. It only needs to provide a clean transport boundary and accurate lifecycle callbacks so the existing client code can do the rest.
|
Read-back verification retries are not tied to a specific transport. They work the same way for serial, TCP, BLE, or any future transport as long as the link is still available for the follow-up requests. |
|
Automatic reconnect is transport-specific. Serial and TCP each provide their own reconnect behavior. BLE will benefit from the same core request handling once connected, but reconnect quality still depends on the BLE transport/backend implementation. |
For a transport author, the practical rule is:
-
do implement framed reads/writes and lifecycle callbacks
-
do implement reconnect behavior if the transport should recover automatically
-
do not re-implement request correlation, admin verification retries, or startup resync in the transport
20. Transport Extension Guidance
When writing a new transport:
-
Focus on physical link behavior first.
-
Keep reconnect policy/config on the transport side.
-
Emit accurate connection and disconnect events.
-
Hand complete framed payloads to the client as quickly as possible.
-
Let the core client own Meshtastic-specific retry and startup semantics.
21. Request Lifecycle Events
MeshtasticEventListener exposes request lifecycle updates through:
client.addEventListener(new MeshtasticEventListener() {
@Override
public void onRequestLifecycleUpdate(RequestLifecycleEvent event) {
switch (event.getStage()) {
case SENT -> uiLog("Sent " + event.getPort() + " -> " + MeshUtils.formatId(event.getDestinationNodeId()));
case ACCEPTED -> uiLog("Accepted request #" + event.getRequestId());
case REJECTED -> showError("Rejected: " + event.getMessage());
case TIMED_OUT -> showError("Timed out: " + event.getMessage());
case PAYLOAD_RECEIVED -> uiLog("Payload observed for request #" + event.getRequestId());
case CANCELLED -> uiLog("Cancelled request #" + event.getRequestId());
case FAILED -> showError("Request failed: " + event.getMessage());
}
}
});
This gives application code one consistent way to observe request outcomes across admin reads, admin writes, and utility request methods.
22. Admin Model Update Events
For settings UIs, MeshtasticEventListener exposes onAdminModelUpdate(…) for live model changes.
When this event fires:
-
ADMIN_APPresponses update owner/config/channel/module/metadata -
local
FromRadiosnapshots update local node/config/channel/module/metadata
client.addEventListener(new MeshtasticEventListener() {
@Override
public void onAdminModelUpdate(AdminModelUpdateEvent event) {
switch (event.getSection()) {
case CHANNEL -> {
Integer slot = event.getChannelIndex();
if (slot != null) {
admin.refreshChannel(slot).thenAccept(ch -> renderChannelRow(slot, ch));
} else {
admin.refreshChannels().thenAccept(MainPresenter.this::renderChannels);
}
}
case OWNER -> admin.refreshOwner().thenAccept(MainPresenter.this::renderOwner);
case CONFIG -> admin.refreshCoreConfigs().thenAccept(cfg -> renderConfig(admin.getConfigSnapshots()));
case MODULE_CONFIG -> admin.refreshModules().thenAccept(m -> renderModules(admin.getModuleConfigSnapshots()));
case DEVICE_METADATA -> admin.refreshMetadata().thenAccept(MainPresenter.this::renderMetadata);
case LOCAL_NODE -> { /* optional local node update handling */ }
}
}
});
|
Firmware does not always push channel or config changes made directly on the radio. If you want settings pages to stay predictable:
|
23. Firmware Updates
|
Firmware upgrades are intentionally out of scope for this Java client. |
This library focuses on the client/runtime side of Meshtastic:
-
transport lifecycle
-
startup synchronization
-
message/event handling
-
admin/config/channel workflows
-
snapshot/state management for applications
Firmware flashing is a different problem space. It depends on the board, bootloader, transport, host environment, and the update toolchain that matches that device. In practice, most users are better served by the official Meshtastic tooling, especially the web flasher and other firmware-specific utilities maintained by the project.
That is why this repository does not try to ship a built-in firmware updater.
Recommended approach:
-
use the official Meshtastic web flasher or other official firmware tools for normal upgrades
-
keep firmware-update workflows outside this core client library
-
if your application truly needs an integrated update experience, build that in application code and validate it against the exact hardware and toolchain you plan to support
This keeps the core library focused and avoids giving developers a half-supported upgrade path that looks more finished than it really is.
If you decide to add firmware-update support in your own application, document clearly which boards, transports, and flashing tools you have actually tested. That kind of support is possible in app space, but it is too environment-specific for this library to promise as a general-purpose feature.
24. BLE Transport SPI (Skeleton)
The core includes a BLE transport skeleton with a pluggable backend SPI:
-
BleConfig(connection IDs/UUIDs/timeouts/reconnect policy) -
BleLinkBackend(backend adapter contract to your BLE library) -
BleTransport(meshtastic framing + reconnect + lifecycle wiring)
Example bootstrap:
BleConfig cfg = BleConfig.builder()
.deviceId("AA:BB:CC:DD:EE:FF")
.serviceUuid("service-uuid")
.toRadioCharacteristicUuid("to-radio-char-uuid")
.fromRadioCharacteristicUuid("from-radio-char-uuid")
.connectTimeout(Duration.ofSeconds(10))
.reconnectBackoff(Duration.ofSeconds(2))
.autoReconnect(true)
.build();
BleLinkBackend backend = new MyBleBackend(); // your adapter for a concrete BLE stack
client.connect(new BleTransport(cfg, backend));
This keeps the core API stable while allowing platform-specific BLE implementations without hard-coding one dependency.
|
BLE support is not 100% implemented out of the box. |
|
When implementing a BLE backend, focus on scan/connect/notify/write behavior and reconnect policy. The existing request correlation, admin verification retries, and startup resync flow already live in the core client. |
24.1. Candidate BLE Backends
Depending on OS/runtime, a contributor or application can wire BleLinkBackend to a concrete Java BLE library such as:
-
Gluon Attach BluetoothLE for Gluon/JavaFX-oriented applications
-
TinyB for Linux/BlueZ environments
-
a BlueZ D-Bus integration built on a Java D-Bus library for Linux-specific apps
|
These are examples of possible backend choices, not official bundled dependencies of this project. The right backend depends on packaging constraints, runtime environment, and the target operating system. |
25. Build Outputs
When running mvn package, artifacts include:
-
target/meshtastic-java-client-1.0.0-SNAPSHOT.jar -
target/meshtastic-java-client-1.0.0-SNAPSHOT-sources.jar -
target/meshtastic-java-client-1.0.0-SNAPSHOT-javadoc.jar -
HTML docs under
target/docs
26. Contributor Conventions
This project intentionally uses a few simple conventions to keep the codebase cohesive as it grows.
26.1. Object Modeling
This project uses Lombok-based classes instead of mixing Lombok and Java records.
Use @Value for:
-
immutable request, result, and event-style objects
-
small value objects that should stay fixed after construction
-
helper objects that mainly carry data
Use @Builder when:
-
a type has several fields
-
optional fields or defaults make object creation clearer
-
named construction improves readability at the call site
Use @Data sparingly for:
-
mutable in-memory state holders
-
internal storage objects that are updated over time
Use regular classes for:
-
services
-
engines
-
stateful coordinators
Default to immutability unless mutation is part of the design.
26.2. Logging Levels
The recommended logging split is:
-
TRACEfor transport/framing noise, payload dumps, and detailed timing diagnostics -
DEBUGfor protocol decode details, request correlation, admin model churn, and verification attempts -
INFOfor meaningful lifecycle transitions such as startup sync progress and reconnect-ready events -
WARNfor rejected requests, recoverable firmware inconsistency, and degraded-but-continuing behavior -
ERRORfor parse failures or unrecoverable operation failures
If a message is expected to fire frequently during healthy operation, prefer DEBUG or TRACE.