Merge pull request #29 from jkingsman/mqtt

MQTT support
This commit is contained in:
Jack Kingsman
2026-03-01 11:11:46 -08:00
committed by GitHub
23 changed files with 1462 additions and 27 deletions

View File

@@ -85,6 +85,7 @@ export function SettingsModal(props: SettingsModalProps) {
radio: !isMobile,
identity: false,
connectivity: false,
mqtt: false,
database: false,
bot: false,
statistics: false,
@@ -127,6 +128,17 @@ export function SettingsModal(props: SettingsModalProps) {
// Advertisement interval state (displayed in hours, stored as seconds in DB)
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
// MQTT state
const [mqttBrokerHost, setMqttBrokerHost] = useState('');
const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
const [mqttUsername, setMqttUsername] = useState('');
const [mqttPassword, setMqttPassword] = useState('');
const [mqttUseTls, setMqttUseTls] = useState(false);
const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
// Bot state
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
@@ -193,6 +205,15 @@ export function SettingsModal(props: SettingsModalProps) {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setBots(appSettings.bots || []);
setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
setMqttUsername(appSettings.mqtt_username ?? '');
setMqttPassword(appSettings.mqtt_password ?? '');
setMqttUseTls(appSettings.mqtt_use_tls ?? false);
setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
}
}, [appSettings]);
@@ -409,6 +430,34 @@ export function SettingsModal(props: SettingsModalProps) {
}
};
const handleSaveMqtt = async () => {
setSectionError(null);
setBusySection('mqtt');
try {
const update: AppSettingsUpdate = {
mqtt_broker_host: mqttBrokerHost,
mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
mqtt_username: mqttUsername,
mqtt_password: mqttPassword,
mqtt_use_tls: mqttUseTls,
mqtt_tls_insecure: mqttTlsInsecure,
mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
mqtt_publish_messages: mqttPublishMessages,
mqtt_publish_raw_packets: mqttPublishRawPackets,
};
await onSaveAppSettings(update);
toast.success('MQTT settings saved');
} catch (err) {
setSectionError({
section: 'mqtt',
message: err instanceof Error ? err.message : 'Failed to save',
});
} finally {
setBusySection(null);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setSectionError({ section: 'identity', message: 'Private key is required' });
@@ -976,6 +1025,160 @@ export function SettingsModal(props: SettingsModalProps) {
</div>
)}
{shouldRenderSection('mqtt') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('mqtt')}
{isSectionVisible('mqtt') && (
<div className={sectionContentClass}>
<div className="space-y-2">
<Label>Status</Label>
{health?.mqtt_status === 'connected' ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-sm text-green-400">Connected</span>
</div>
) : health?.mqtt_status === 'disconnected' ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-sm text-red-400">Disconnected</span>
</div>
) : (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-sm text-muted-foreground">Disabled</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-host">Broker Host</Label>
<Input
id="mqtt-host"
type="text"
placeholder="e.g. 192.168.1.100"
value={mqttBrokerHost}
onChange={(e) => setMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-port">Broker Port</Label>
<Input
id="mqtt-port"
type="number"
min="1"
max="65535"
value={mqttBrokerPort}
onChange={(e) => setMqttBrokerPort(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-username">Username</Label>
<Input
id="mqtt-username"
type="text"
placeholder="Optional"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-password">Password</Label>
<Input
id="mqtt-password"
type="password"
placeholder="Optional"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
/>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttUseTls}
onChange={(e) => setMqttUseTls(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Use TLS</span>
</label>
{mqttUseTls && (
<>
<label className="flex items-center gap-3 cursor-pointer ml-7">
<input
type="checkbox"
checked={mqttTlsInsecure}
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Skip certificate verification</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Allow self-signed or untrusted broker certificates
</p>
</>
)}
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
<Input
id="mqtt-prefix"
type="text"
value={mqttTopicPrefix}
onChange={(e) => setMqttTopicPrefix(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Topics: {mqttTopicPrefix || 'meshcore'}/dm:&lt;key&gt;,{' '}
{mqttTopicPrefix || 'meshcore'}/gm:&lt;key&gt;, {mqttTopicPrefix || 'meshcore'}
/raw/...
</p>
</div>
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishMessages}
onChange={(e) => setMqttPublishMessages(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Messages</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Forward decrypted DM and channel messages
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishRawPackets}
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Raw Packets</span>
</label>
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
<Button onClick={handleSaveMqtt} disabled={isSectionBusy('mqtt')} className="w-full">
{isSectionBusy('mqtt') ? 'Saving...' : 'Save MQTT Settings'}
</Button>
{getSectionError('mqtt') && (
<div className="text-sm text-destructive">{getSectionError('mqtt')}</div>
)}
</div>
)}
</div>
)}
{shouldRenderSection('database') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('database')}

View File

@@ -2,6 +2,7 @@ export type SettingsSection =
| 'radio'
| 'identity'
| 'connectivity'
| 'mqtt'
| 'database'
| 'bot'
| 'statistics';
@@ -10,6 +11,7 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'identity',
'connectivity',
'mqtt',
'database',
'bot',
'statistics',
@@ -19,6 +21,7 @@ export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
mqtt: '📤 MQTT',
database: '🗄️ Database & Interface',
bot: '🤖 Bot',
statistics: '📊 Statistics',

View File

@@ -38,6 +38,7 @@ const baseHealth: HealthStatus = {
connection_info: 'Serial: /dev/ttyUSB0',
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
mqtt_status: null,
};
const baseSettings: AppSettings = {
@@ -50,10 +51,20 @@ const baseSettings: AppSettings = {
advert_interval: 0,
last_advert_time: 0,
bots: [],
mqtt_broker_host: '',
mqtt_broker_port: 1883,
mqtt_username: '',
mqtt_password: '',
mqtt_use_tls: false,
mqtt_tls_insecure: false,
mqtt_topic_prefix: 'meshcore',
mqtt_publish_messages: false,
mqtt_publish_raw_packets: false,
};
function renderModal(overrides?: {
appSettings?: AppSettings;
health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
onRefreshAppSettings?: () => Promise<void>;
onSave?: (update: RadioConfigUpdate) => Promise<void>;
@@ -79,7 +90,7 @@ function renderModal(overrides?: {
open: overrides?.open ?? true,
pageMode: overrides?.pageMode,
config: baseConfig,
health: baseHealth,
health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings,
onClose,
onSave,
@@ -133,6 +144,11 @@ function openConnectivitySection() {
fireEvent.click(connectivityToggle);
}
function openMqttSection() {
const mqttToggle = screen.getByRole('button', { name: /MQTT/i });
fireEvent.click(mqttToggle);
}
function openDatabaseSection() {
const databaseToggle = screen.getByRole('button', { name: /Database/i });
fireEvent.click(databaseToggle);
@@ -389,6 +405,66 @@ describe('SettingsModal', () => {
expect(screen.getByText('42 msgs')).toBeInTheDocument();
});
it('renders MQTT section with form inputs', () => {
renderModal();
openMqttSection();
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
expect(screen.getByLabelText('Broker Port')).toBeInTheDocument();
expect(screen.getByLabelText('Username')).toBeInTheDocument();
expect(screen.getByLabelText('Password')).toBeInTheDocument();
expect(screen.getByLabelText('Topic Prefix')).toBeInTheDocument();
expect(screen.getByText('Publish Messages')).toBeInTheDocument();
expect(screen.getByText('Publish Raw Packets')).toBeInTheDocument();
});
it('saves MQTT settings through onSaveAppSettings', async () => {
const { onSaveAppSettings } = renderModal();
openMqttSection();
const hostInput = screen.getByLabelText('Broker Host');
fireEvent.change(hostInput, { target: { value: 'mqtt.example.com' } });
fireEvent.click(screen.getByRole('button', { name: 'Save MQTT Settings' }));
await waitFor(() => {
expect(onSaveAppSettings).toHaveBeenCalledWith(
expect.objectContaining({
mqtt_broker_host: 'mqtt.example.com',
mqtt_broker_port: 1883,
})
);
});
});
it('shows MQTT disabled status when mqtt_status is null', () => {
renderModal({
appSettings: {
...baseSettings,
mqtt_broker_host: 'broker.local',
},
});
openMqttSection();
expect(screen.getByText('Disabled')).toBeInTheDocument();
});
it('shows MQTT connected status badge', () => {
renderModal({
appSettings: {
...baseSettings,
mqtt_broker_host: 'broker.local',
},
health: {
...baseHealth,
mqtt_status: 'connected',
},
});
openMqttSection();
expect(screen.getByText('Connected')).toBeInTheDocument();
});
it('fetches statistics when expanded in mobile external-nav mode', async () => {
const mockStats: StatisticsResponse = {
busiest_channels_24h: [],

View File

@@ -29,6 +29,7 @@ export interface HealthStatus {
connection_info: string | null;
database_size_mb: number;
oldest_undecrypted_timestamp: number | null;
mqtt_status: string | null;
}
export interface MaintenanceResult {
@@ -155,6 +156,8 @@ export interface RawPacket {
decrypted_info: {
channel_name: string | null;
sender: string | null;
channel_key: string | null;
contact_key: string | null;
} | null;
}
@@ -180,6 +183,15 @@ export interface AppSettings {
advert_interval: number;
last_advert_time: number;
bots: BotConfig[];
mqtt_broker_host: string;
mqtt_broker_port: number;
mqtt_username: string;
mqtt_password: string;
mqtt_use_tls: boolean;
mqtt_tls_insecure: boolean;
mqtt_topic_prefix: string;
mqtt_publish_messages: boolean;
mqtt_publish_raw_packets: boolean;
}
export interface AppSettingsUpdate {
@@ -188,6 +200,15 @@ export interface AppSettingsUpdate {
sidebar_sort_order?: 'recent' | 'alpha';
advert_interval?: number;
bots?: BotConfig[];
mqtt_broker_host?: string;
mqtt_broker_port?: number;
mqtt_username?: string;
mqtt_password?: string;
mqtt_use_tls?: boolean;
mqtt_tls_insecure?: boolean;
mqtt_topic_prefix?: string;
mqtt_publish_messages?: boolean;
mqtt_publish_raw_packets?: boolean;
}
export interface MigratePreferencesRequest {