Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3189,6 +3189,7 @@
"lora_config.override_frequency_description": "HAM/Advanced: Override channel calculation and use this frequency. Frequency offset still applies. Value of 0 = disabled",
"lora_config.region": "Region Code",
"lora_config.region_description": "Select your regulatory region for frequency/power limits",
"lora_config.amateur_band_warning": "This region operates in a licensed amateur (ham) radio band. A valid amateur radio license is required, and you are responsible for complying with local band-plan, power, and station identification rules.",
"lora_config.hop_limit": "Hop Limit",
"lora_config.hop_limit_description": "Maximum number of hops for mesh packets. Range: 1-7 (default: 3)",
"lora_config.tx_power": "Transmit Power (dBm)",
Expand Down
19 changes: 18 additions & 1 deletion src/components/admin-commands/RadioConfigurationSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { MODEM_PRESET_OPTIONS, REGION_OPTIONS } from '../configuration/constants';
import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, isAmateurRadioRegion } from '../configuration/constants';
import type { Channel } from '../../types/device';

interface RadioConfigurationSectionProps {
Expand Down Expand Up @@ -214,6 +214,23 @@ export const RadioConfigurationSection: React.FC<RadioConfigurationSectionProps>
</option>
))}
</select>
{isAmateurRadioRegion(region) && (
<div
role="alert"
className="setting-description"
style={{
marginTop: '0.5rem',
padding: '0.6rem 0.75rem',
border: '1px solid var(--ctp-yellow)',
borderRadius: '4px',
backgroundColor: 'rgba(249, 226, 175, 0.12)',
color: 'var(--ctp-yellow)',
lineHeight: '1.4'
}}
>
⚠️ {t('lora_config.amateur_band_warning')}
</div>
)}
</div>
<div className="setting-item">
<label>Hop Limit (1-7)</label>
Expand Down
36 changes: 35 additions & 1 deletion src/components/configuration/LoRaConfigSection.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { MODEM_PRESET_OPTIONS, FEM_LNA_MODE_OPTIONS } from './constants';
import { MODEM_PRESET_OPTIONS, FEM_LNA_MODE_OPTIONS, AMATEUR_RADIO_REGIONS, isAmateurRadioRegion, REGION_MAP } from './constants';

/**
* LoRaConfigSection Tests
Expand Down Expand Up @@ -137,6 +137,40 @@ describe('LoRaConfigSection', () => {
});
});

describe('Amateur Radio Region Warning (#3924)', () => {
it('flags the ITU 2m amateur regions (27, 28) as amateur bands', () => {
expect(isAmateurRadioRegion(27)).toBe(true); // ITU1_2M
expect(isAmateurRadioRegion(28)).toBe(true); // ITU23_2M
});

it('keeps the amateur region set in sync with REGION_MAP values', () => {
expect(AMATEUR_RADIO_REGIONS[27]).toBe('ITU1_2M');
expect(AMATEUR_RADIO_REGIONS[28]).toBe('ITU23_2M');
expect(REGION_MAP['ITU1_2M']).toBe(27);
expect(REGION_MAP['ITU23_2M']).toBe(28);
});

it('does not flag standard ISM/SRD regions as amateur bands', () => {
expect(isAmateurRadioRegion(1)).toBe(false); // US
expect(isAmateurRadioRegion(3)).toBe(false); // EU_868
expect(isAmateurRadioRegion(6)).toBe(false); // ANZ
expect(isAmateurRadioRegion(32)).toBe(false); // EU_N_868
});

it('does not flag UNSET (0) as an amateur band', () => {
expect(isAmateurRadioRegion(0)).toBe(false);
});

it('handles null/undefined region without throwing', () => {
expect(isAmateurRadioRegion(null)).toBe(false);
expect(isAmateurRadioRegion(undefined)).toBe(false);
});

it('does not flag an out-of-range region value', () => {
expect(isAmateurRadioRegion(999)).toBe(false);
});
});

describe('Save Configuration Logic', () => {
it('includes femLnaMode in the LoRa save payload', () => {
// Mirrors the Device Configuration save (ConfigurationTab) and Remote Admin
Expand Down
19 changes: 18 additions & 1 deletion src/components/configuration/LoRaConfigSection.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { useState, useRef, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, FEM_LNA_MODE_OPTIONS } from './constants';
import { MODEM_PRESET_OPTIONS, REGION_OPTIONS, FEM_LNA_MODE_OPTIONS, isAmateurRadioRegion } from './constants';
import { useSaveBar } from '../../hooks/useSaveBar';

interface LoRaConfigSectionProps {
Expand Down Expand Up @@ -387,6 +387,23 @@ const LoRaConfigSection: React.FC<LoRaConfigSectionProps> = ({
</option>
))}
</select>
{isAmateurRadioRegion(region) && (
<div
role="alert"
className="setting-description"
style={{
marginTop: '0.5rem',
padding: '0.6rem 0.75rem',
border: '1px solid var(--ctp-yellow, #f9e2af)',
borderRadius: '4px',
backgroundColor: 'rgba(249, 226, 175, 0.12)',
color: 'var(--ctp-yellow, #f9e2af)',
lineHeight: '1.4'
}}
>
⚠️ {t('lora_config.amateur_band_warning')}
</div>
)}
</div>
<div className="setting-item">
<label htmlFor="hopLimit">
Expand Down
36 changes: 36 additions & 0 deletions src/components/configuration/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,42 @@ export const REGION_OPTIONS: RegionOption[] = [
{ value: 32, label: 'EU_N_868 - European Union 868MHz Narrow' }
];

// Licensed amateur-radio LoRa regions.
//
// The ITU amateur-radio RegionCode entries map onto the 2m amateur band
// (144-148 MHz), which is licensed spectrum in every ITU region. Operating a
// Meshtastic node on one of these regions legally requires a valid amateur
// radio license, and the operator is responsible for complying with local
// band-plan and identification rules. The LoRa config UI surfaces a warning
// when one of these regions is selected.
//
// Values mirror Config.LoRaConfig.RegionCode (meshtastic/protobufs config.proto):
// 27 = ITU1_2M (ITU Region 1 amateur 2m, 144-146 MHz)
// 28 = ITU23_2M (ITU Region 2/3 amateur 2m, 144-148 MHz)
//
// NOTE: Firmware 2.8 introduces a region -> preset legality map (sent once by
// the device) that the official mobile apps use to filter the preset picker to
// presets that are legal for the selected region. MeshMonitor cannot consume
// that map yet because the vendored protobufs (v2.7.26) predate it; when the
// protobuf submodule is bumped to a 2.8 release this list can be replaced by
// the device-reported legality data. See issue #3924.
export const AMATEUR_RADIO_REGIONS: Record<number, string> = {
27: 'ITU1_2M',
28: 'ITU23_2M'
};

/**
* Returns true when the given RegionCode value corresponds to a licensed
* amateur-radio band (an ITU 2m region). Used to surface a compliance warning
* in the LoRa configuration UI.
*/
export function isAmateurRadioRegion(region: number | null | undefined): boolean {
if (region === null || region === undefined) {
return false;
}
return Object.prototype.hasOwnProperty.call(AMATEUR_RADIO_REGIONS, region);
}

// Config.LoRaConfig.FEM_LNA_Mode (firmware >= v2.7.20, meshtastic/firmware#9809).
// Value 0 (DISABLED) is the proto3 zero/default and a real selectable mode.
export const FEM_LNA_MODE_OPTIONS: RegionOption[] = [
Expand Down
Loading