Add support for community MQTT ingest

This commit is contained in:
Jack Kingsman
2026-03-01 09:55:11 -08:00
parent 2496d70c4b
commit 00ca4afa8d
17 changed files with 1495 additions and 26 deletions
@@ -27,6 +27,12 @@ export function SettingsMqttSection({
const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
// Community MQTT state
const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false);
const [communityMqttIata, setCommunityMqttIata] = useState('');
const [communityMqttBroker, setCommunityMqttBroker] = useState('mqtt-us-v1.letsmesh.net');
const [communityMqttEmail, setCommunityMqttEmail] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -40,6 +46,10 @@ export function SettingsMqttSection({
setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
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');
setCommunityMqttEmail(appSettings.community_mqtt_email ?? '');
}, [appSettings]);
const handleSave = async () => {
@@ -57,6 +67,10 @@ export function SettingsMqttSection({
mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
mqtt_publish_messages: mqttPublishMessages,
mqtt_publish_raw_packets: mqttPublishRawPackets,
community_mqtt_enabled: communityMqttEnabled,
community_mqtt_iata: communityMqttIata,
community_mqtt_broker: communityMqttBroker || 'mqtt-us-v1.letsmesh.net',
community_mqtt_email: communityMqttEmail,
};
await onSaveAppSettings(update);
toast.success('MQTT settings saved');
@@ -69,6 +83,14 @@ export function SettingsMqttSection({
return (
<div className={className}>
<div className="space-y-1">
<h4 className="text-sm font-medium">Private MQTT Broker</h4>
<p className="text-xs text-muted-foreground">
Forward all mesh data to your own MQTT broker for home automation, logging, or alerting.
Publishes both decrypted messages and raw packets to your broker.
</p>
</div>
<div className="space-y-2">
<Label>Status</Label>
{health?.mqtt_status === 'connected' ? (
@@ -218,6 +240,95 @@ export function SettingsMqttSection({
</label>
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
<Separator />
<div className="space-y-3">
<h4 className="text-sm font-medium">Community Analytics</h4>
<p className="text-xs text-muted-foreground">
Share raw packet data with the MeshCore community for coverage mapping and network
analysis. Only raw RF packets are shared never decrypted messages.
</p>
<div className="flex items-center gap-2 mb-2">
{health?.community_mqtt_status === 'connected' ? (
<>
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm text-green-400">Connected</span>
</>
) : health?.community_mqtt_status === 'disconnected' ? (
<>
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm text-red-400">Disconnected</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-sm text-muted-foreground">Disabled</span>
</>
)}
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={communityMqttEnabled}
onChange={(e) => setCommunityMqttEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Enable Community Analytics</span>
</label>
{communityMqttEnabled && (
<div className="space-y-2">
<Label htmlFor="community-broker">Broker Address</Label>
<Input
id="community-broker"
type="text"
placeholder="mqtt-us-v1.letsmesh.net:443"
value={communityMqttBroker}
onChange={(e) => setCommunityMqttBroker(e.target.value)}
/>
<p className="text-xs text-muted-foreground">host or host:port (default port 443)</p>
<Label htmlFor="community-iata">Region Code (IATA)</Label>
<Input
id="community-iata"
type="text"
maxLength={3}
placeholder="e.g. DEN, LAX, NYC"
value={communityMqttIata}
onChange={(e) => setCommunityMqttIata(e.target.value.toUpperCase())}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Your nearest airport&apos;s{' '}
<a
href="https://en.wikipedia.org/wiki/List_of_airports_by_IATA_airport_code:_A"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
IATA code
</a>{' '}
(required)
</p>
{communityMqttIata && (
<p className="text-xs text-muted-foreground">
Topic: meshcore/{communityMqttIata}/&lt;pubkey&gt;/packets
</p>
)}
<Label htmlFor="community-email">Owner Email (optional)</Label>
<Input
id="community-email"
type="email"
placeholder="you@example.com"
value={communityMqttEmail}
onChange={(e) => setCommunityMqttEmail(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Used to claim your node on the community aggregator
</p>
</div>
)}
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save MQTT Settings'}
</Button>
+75 -1
View File
@@ -39,6 +39,7 @@ const baseHealth: HealthStatus = {
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
mqtt_status: null,
community_mqtt_status: null,
};
const baseSettings: AppSettings = {
@@ -60,6 +61,10 @@ const baseSettings: AppSettings = {
mqtt_topic_prefix: 'meshcore',
mqtt_publish_messages: false,
mqtt_publish_raw_packets: false,
community_mqtt_enabled: false,
community_mqtt_iata: '',
community_mqtt_broker: 'mqtt-us-v1.letsmesh.net',
community_mqtt_email: '',
};
function renderModal(overrides?: {
@@ -446,7 +451,9 @@ describe('SettingsModal', () => {
});
openMqttSection();
expect(screen.getByText('Disabled')).toBeInTheDocument();
// Both MQTT and community MQTT show "Disabled" when null status
const disabledElements = screen.getAllByText('Disabled');
expect(disabledElements.length).toBeGreaterThanOrEqual(1);
});
it('shows MQTT connected status badge', () => {
@@ -465,6 +472,73 @@ describe('SettingsModal', () => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('renders community sharing section in MQTT tab', () => {
renderModal();
openMqttSection();
expect(screen.getByText('Community Analytics')).toBeInTheDocument();
expect(screen.getByText('Enable Community Analytics')).toBeInTheDocument();
});
it('shows IATA input only when community sharing is enabled', () => {
renderModal({
appSettings: {
...baseSettings,
community_mqtt_enabled: false,
},
});
openMqttSection();
expect(screen.queryByLabelText('Region Code (IATA)')).not.toBeInTheDocument();
// Enable community sharing
fireEvent.click(screen.getByText('Enable Community Analytics'));
expect(screen.getByLabelText('Region Code (IATA)')).toBeInTheDocument();
});
it('includes community MQTT fields in save payload', async () => {
const { onSaveAppSettings } = renderModal({
appSettings: {
...baseSettings,
community_mqtt_enabled: true,
community_mqtt_iata: 'DEN',
},
});
openMqttSection();
fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({
community_mqtt_enabled: true,
community_mqtt_iata: 'DEN',
})
);
});
});
it('shows community MQTT connected status badge', () => {
renderModal({
appSettings: {
...baseSettings,
community_mqtt_enabled: true,
},
health: {
...baseHealth,
community_mqtt_status: 'connected',
},
});
openMqttSection();
// Community Analytics sub-section should show Connected
const communitySection = screen.getByText('Community Analytics').closest('div');
expect(communitySection).not.toBeNull();
// Both MQTT and community could show "Connected" — check count
const connectedElements = screen.getAllByText('Connected');
expect(connectedElements.length).toBeGreaterThanOrEqual(1);
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],
+9
View File
@@ -30,6 +30,7 @@ export interface HealthStatus {
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
mqtt_status: string | null;
community_mqtt_status: string | null;
}
export interface MaintenanceResult {
@@ -192,6 +193,10 @@ export interface AppSettings {
mqtt_topic_prefix: string;
mqtt_publish_messages: boolean;
mqtt_publish_raw_packets: boolean;
community_mqtt_enabled: boolean;
community_mqtt_iata: string;
community_mqtt_broker: string;
community_mqtt_email: string;
}
export interface AppSettingsUpdate {
@@ -209,6 +214,10 @@ export interface AppSettingsUpdate {
mqtt_topic_prefix?: string;
mqtt_publish_messages?: boolean;
mqtt_publish_raw_packets?: boolean;
community_mqtt_enabled?: boolean;
community_mqtt_iata?: string;
community_mqtt_broker?: string;
community_mqtt_email?: string;
}
export interface MigratePreferencesRequest {