diff --git a/AGENTS.md b/AGENTS.md
index 8ff41df..eef8bcf 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -370,10 +370,10 @@ Separate from private MQTT, the community publisher (`app/community_mqtt.py`) sh
- Connects to community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication via Ed25519 JWT signed with the radio's private key. Tokens auto-renew before 24h expiry.
-- Broker address field supports `host:port` format (default port 443 if omitted).
+- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code.
- JWT `email` claim enables node claiming on the community aggregator.
-- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email` in `app_settings`.
+- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` in `app_settings`.
### Server-Side Decryption
@@ -416,7 +416,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
-**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
+**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
diff --git a/app/AGENTS.md b/app/AGENTS.md
index 5273650..7405080 100644
--- a/app/AGENTS.md
+++ b/app/AGENTS.md
@@ -122,13 +122,13 @@ app/
- Independent from the private `MqttPublisher` — different broker, authentication, and topic structure.
- Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours.
-- Broker address supports `host:port` format; defaults to port 443 if omitted.
+- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator).
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default).
- Only raw packets are published — never decrypted messages.
- Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds.
- Health endpoint includes `community_mqtt_status` field.
-- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email`.
+- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`.
## API Surface (all under `/api`)
@@ -238,7 +238,7 @@ Main tables:
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
-- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker`, `community_mqtt_email`
+- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
## Security Posture (intentional)
diff --git a/app/community_mqtt.py b/app/community_mqtt.py
index 63a85e4..8da1165 100644
--- a/app/community_mqtt.py
+++ b/app/community_mqtt.py
@@ -43,17 +43,6 @@ _IATA_RE = re.compile(r"^[A-Z]{3}$")
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
-def _parse_broker_address(broker_str: str) -> tuple[str, int]:
- """Parse 'host' or 'host:port' into (host, port). Defaults to _DEFAULT_PORT."""
- if ":" in broker_str:
- host, port_str = broker_str.rsplit(":", 1)
- try:
- return host, int(port_str)
- except ValueError:
- pass
- return broker_str, _DEFAULT_PORT
-
-
def _base64url_encode(data: bytes) -> str:
"""Base64url encode without padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -308,8 +297,8 @@ class CommunityMqttPublisher(BaseMqttPublisher):
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
pubkey_hex = public_key.hex().upper()
- broker_raw = settings.community_mqtt_broker or _DEFAULT_BROKER
- broker_host, broker_port = _parse_broker_address(broker_raw)
+ broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
+ broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
jwt_token = _generate_jwt_token(
private_key,
public_key,
@@ -330,8 +319,8 @@ class CommunityMqttPublisher(BaseMqttPublisher):
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
- broker_raw = settings.community_mqtt_broker or _DEFAULT_BROKER
- broker_host, broker_port = _parse_broker_address(broker_raw)
+ broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
+ broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
def _on_error(self) -> tuple[str, str]:
diff --git a/app/migrations.py b/app/migrations.py
index 57e9473..cb78940 100644
--- a/app/migrations.py
+++ b/app/migrations.py
@@ -1916,7 +1916,8 @@ async def _migrate_032_add_community_mqtt_columns(conn: aiosqlite.Connection) ->
new_columns = [
("community_mqtt_enabled", "INTEGER DEFAULT 0"),
("community_mqtt_iata", "TEXT DEFAULT ''"),
- ("community_mqtt_broker", "TEXT DEFAULT 'mqtt-us-v1.letsmesh.net'"),
+ ("community_mqtt_broker_host", "TEXT DEFAULT 'mqtt-us-v1.letsmesh.net'"),
+ ("community_mqtt_broker_port", "INTEGER DEFAULT 443"),
("community_mqtt_email", "TEXT DEFAULT ''"),
]
diff --git a/app/models.py b/app/models.py
index 5f1557b..9a46fce 100644
--- a/app/models.py
+++ b/app/models.py
@@ -471,10 +471,14 @@ class AppSettings(BaseModel):
default="",
description="IATA region code for community MQTT topic routing (3 alpha chars)",
)
- community_mqtt_broker: str = Field(
+ community_mqtt_broker_host: str = Field(
default="mqtt-us-v1.letsmesh.net",
description="Community MQTT broker hostname",
)
+ community_mqtt_broker_port: int = Field(
+ default=443,
+ description="Community MQTT broker port",
+ )
community_mqtt_email: str = Field(
default="",
description="Email address for node claiming on the community aggregator (optional)",
diff --git a/app/repository/settings.py b/app/repository/settings.py
index 7cb9540..ef489fd 100644
--- a/app/repository/settings.py
+++ b/app/repository/settings.py
@@ -31,7 +31,8 @@ class AppSettingsRepository:
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
mqtt_publish_messages, mqtt_publish_raw_packets,
community_mqtt_enabled, community_mqtt_iata,
- community_mqtt_broker, community_mqtt_email
+ community_mqtt_broker_host, community_mqtt_broker_port,
+ community_mqtt_email
FROM app_settings WHERE id = 1
"""
)
@@ -107,7 +108,9 @@ class AppSettingsRepository:
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
community_mqtt_enabled=bool(row["community_mqtt_enabled"]),
community_mqtt_iata=row["community_mqtt_iata"] or "",
- community_mqtt_broker=row["community_mqtt_broker"] or "mqtt-us-v1.letsmesh.net",
+ community_mqtt_broker_host=row["community_mqtt_broker_host"]
+ or "mqtt-us-v1.letsmesh.net",
+ community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
community_mqtt_email=row["community_mqtt_email"] or "",
)
@@ -133,7 +136,8 @@ class AppSettingsRepository:
mqtt_publish_raw_packets: bool | None = None,
community_mqtt_enabled: bool | None = None,
community_mqtt_iata: str | None = None,
- community_mqtt_broker: str | None = None,
+ community_mqtt_broker_host: str | None = None,
+ community_mqtt_broker_port: int | None = None,
community_mqtt_email: str | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
@@ -222,9 +226,13 @@ class AppSettingsRepository:
updates.append("community_mqtt_iata = ?")
params.append(community_mqtt_iata)
- if community_mqtt_broker is not None:
- updates.append("community_mqtt_broker = ?")
- params.append(community_mqtt_broker)
+ if community_mqtt_broker_host is not None:
+ updates.append("community_mqtt_broker_host = ?")
+ params.append(community_mqtt_broker_host)
+
+ if community_mqtt_broker_port is not None:
+ updates.append("community_mqtt_broker_port = ?")
+ params.append(community_mqtt_broker_port)
if community_mqtt_email is not None:
updates.append("community_mqtt_email = ?")
diff --git a/app/routers/settings.py b/app/routers/settings.py
index d6af18f..373cd23 100644
--- a/app/routers/settings.py
+++ b/app/routers/settings.py
@@ -106,10 +106,16 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="IATA region code for community MQTT topic routing (3 alpha chars)",
)
- community_mqtt_broker: str | None = Field(
+ community_mqtt_broker_host: str | None = Field(
default=None,
description="Community MQTT broker hostname",
)
+ community_mqtt_broker_port: int | None = Field(
+ default=None,
+ ge=1,
+ le=65535,
+ description="Community MQTT broker port",
+ )
community_mqtt_email: str | None = Field(
default=None,
description="Email address for node claiming on the community aggregator",
@@ -214,8 +220,12 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
kwargs["community_mqtt_iata"] = iata
community_mqtt_changed = True
- if update.community_mqtt_broker is not None:
- kwargs["community_mqtt_broker"] = update.community_mqtt_broker
+ if update.community_mqtt_broker_host is not None:
+ kwargs["community_mqtt_broker_host"] = update.community_mqtt_broker_host
+ community_mqtt_changed = True
+
+ if update.community_mqtt_broker_port is not None:
+ kwargs["community_mqtt_broker_port"] = update.community_mqtt_broker_port
community_mqtt_changed = True
if update.community_mqtt_email is not None:
diff --git a/frontend/src/components/settings/SettingsMqttSection.tsx b/frontend/src/components/settings/SettingsMqttSection.tsx
index 85b3f9c..f2a32c0 100644
--- a/frontend/src/components/settings/SettingsMqttSection.tsx
+++ b/frontend/src/components/settings/SettingsMqttSection.tsx
@@ -30,7 +30,8 @@ export function SettingsMqttSection({
// Community MQTT state
const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false);
const [communityMqttIata, setCommunityMqttIata] = useState('');
- const [communityMqttBroker, setCommunityMqttBroker] = useState('mqtt-us-v1.letsmesh.net');
+ const [communityMqttBrokerHost, setCommunityMqttBrokerHost] = useState('mqtt-us-v1.letsmesh.net');
+ const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443');
const [communityMqttEmail, setCommunityMqttEmail] = useState('');
const [busy, setBusy] = useState(false);
@@ -48,7 +49,8 @@ export function SettingsMqttSection({
setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
setCommunityMqttEnabled(appSettings.community_mqtt_enabled ?? false);
setCommunityMqttIata(appSettings.community_mqtt_iata ?? '');
- setCommunityMqttBroker(appSettings.community_mqtt_broker ?? 'mqtt-us-v1.letsmesh.net');
+ setCommunityMqttBrokerHost(appSettings.community_mqtt_broker_host ?? 'mqtt-us-v1.letsmesh.net');
+ setCommunityMqttBrokerPort(String(appSettings.community_mqtt_broker_port ?? 443));
setCommunityMqttEmail(appSettings.community_mqtt_email ?? '');
}, [appSettings]);
@@ -69,7 +71,8 @@ export function SettingsMqttSection({
mqtt_publish_raw_packets: mqttPublishRawPackets,
community_mqtt_enabled: communityMqttEnabled,
community_mqtt_iata: communityMqttIata,
- community_mqtt_broker: communityMqttBroker || 'mqtt-us-v1.letsmesh.net',
+ community_mqtt_broker_host: communityMqttBrokerHost || 'mqtt-us-v1.letsmesh.net',
+ community_mqtt_broker_port: parseInt(communityMqttBrokerPort, 10) || 443,
community_mqtt_email: communityMqttEmail,
};
await onSaveAppSettings(update);
@@ -113,49 +116,53 @@ export function SettingsMqttSection({