mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-02 11:33:05 +02:00
@@ -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:<key>,{' '}
|
||||
{mqttTopicPrefix || 'meshcore'}/gm:<key>, {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')}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user