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
8 changes: 8 additions & 0 deletions docs/features/automation-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ Sends text to a channel or as a DM, with full `{{ }}` token interpolation in the
The overall send is a **source × channel matrix**: each selected source posts to the matching local
slot of each selected channel.

> **MeshCore channel-send auto-retry** — because a MeshCore channel/broadcast send is an unacked
> flood, the `Send message` action (like every automated MeshCore channel sender) can optionally be
> resent once if no repeater is heard re-flooding it within 30 seconds. This is a global, opt-in,
> one-shot behavior (off by default) configured in **Settings → MeshCore Messaging**; it never
> retries user-typed messages, never retries direct messages (those have their own always-on ACK
> retry), and the resend can never trigger a fresh automation. See
> [Automated Channel-Send Auto-Retry](/features/meshcore#automated-channel-send-auto-retry).

### Manage the node

Runs an admin/management operation on the subject node: **Favorite / Unfavorite**, **Ignore /
Expand Down
14 changes: 14 additions & 0 deletions docs/features/meshcore.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,20 @@ Timer Triggers schedule recurring actions independent of incoming traffic:
- **Three actions** — send a **text** message (token expansion supported) to a channel or contact, fire a MeshCore **advert**, or **run a script** (token-expanded args).
- **Last-run telemetry** — the UI surfaces the last fire time and outcome per trigger.

## Automated Channel-Send Auto-Retry

MeshCore channel (broadcast) messages are unacked, fire-and-forget floods — unlike a direct message, there is no firmware ACK to confirm delivery. MeshMonitor instead listens for nearby repeaters re-flooding your own packet (the "heard repeaters" signal, see [Message route line](#message-route-line)). When **no** repeater is heard, the message likely reached no one.

The **MeshCore Messaging** section of global **Settings** hosts an opt-in toggle, **Auto-retry automated MeshCore channel sends** (default **off**). When enabled:

- An **automated** channel send that hears **zero** repeaters within **30 seconds** is resent **exactly once**.
- It applies only to automated senders: the [Automation Engine](/features/automation-engine) `Send message` action, Auto-Acknowledge, the [Auto-Responder](#auto-responder), [Auto-Announce](#auto-announce), and [Timer Triggers](#timer-triggers).
- **User-initiated sends are never retried** — a message you type into the send bar goes out once regardless of this setting.
- It is **one-shot**: the resend is never itself retried, so at most one extra transmission ever occurs per logical send.
- The resend does **not** create a second message bubble and does **not** re-enter the automation event bus, so it can never trigger a fresh automation.

This is **distinct from the direct-message retry**, which is always on and follows the firmware's own same-path/flood ACK cadence. Because a channel send has no delivery ACK, the retry can only guess from the heard-repeater signal — so with this enabled you may occasionally see a duplicate on the mesh if a late echo arrives right around the 30-second mark. Leave it off if duplicates are unacceptable for your deployment.

## Room Servers

Room servers (advType=3) are BBS-style MeshCore nodes that store posts and push-sync them to connected clients. The **Rooms** view in the MeshCore page lists discovered room servers and provides:
Expand Down
41 changes: 37 additions & 4 deletions src/components/SettingsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ interface SettingsTabProps {
}

const GLOBAL_SECTIONS = new Set([
'settings-language', 'settings-units', 'settings-appearance', 'settings-link-previews', 'settings-map',
'settings-language', 'settings-units', 'settings-appearance', 'settings-link-previews', 'settings-meshcore-messaging', 'settings-map',
'settings-security',
'settings-apprise-server', 'settings-backup', 'settings-channel-database',
'settings-maintenance', 'settings-auto-upgrade', 'settings-analytics',
Expand Down Expand Up @@ -179,6 +179,8 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
setEnableAudioNotifications,
linkPreviewsEnabled,
setLinkPreviewsEnabled,
meshcoreChannelRetryEnabled,
setMeshcoreChannelRetryEnabled,
nodeDimmingEnabled,
setNodeDimmingEnabled,
nodeDimmingStartHours,
Expand Down Expand Up @@ -240,6 +242,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
const [localPacketLogMaxCount, setLocalPacketLogMaxCount] = useState(1000);
const [localPacketLogMaxAgeHours, setLocalPacketLogMaxAgeHours] = useState(24);
const [localLinkPreviewsEnabled, setLocalLinkPreviewsEnabled] = useState(linkPreviewsEnabled);
const [localMeshcoreChannelRetryEnabled, setLocalMeshcoreChannelRetryEnabled] = useState(meshcoreChannelRetryEnabled);
const [localSolarMonitoringEnabled, setLocalSolarMonitoringEnabled] = useState(solarMonitoringEnabled);
const [localSolarMonitoringLatitude, setLocalSolarMonitoringLatitude] = useState(solarMonitoringLatitude);
const [localSolarMonitoringLongitude, setLocalSolarMonitoringLongitude] = useState(solarMonitoringLongitude);
Expand Down Expand Up @@ -337,6 +340,10 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
const linkPreviewsOn = !(settings.linkPreviewsEnabled === '0' || settings.linkPreviewsEnabled === 'false');
setLocalLinkPreviewsEnabled(linkPreviewsOn);

// Load MeshCore channel-send auto-retry (#3979). Absent key => disabled.
const meshcoreChannelRetryOn = settings.meshcoreChannelRetryEnabled === '1' || settings.meshcoreChannelRetryEnabled === 'true';
setLocalMeshcoreChannelRetryEnabled(meshcoreChannelRetryOn);

// Load LocalStats interval setting
const statsInterval = parseInt(settings.localStatsIntervalMinutes || '15', 10);
setLocalLocalStatsIntervalMinutes(statsInterval);
Expand Down Expand Up @@ -407,13 +414,14 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
setLocalNodeHopsCalculation(nodeHopsCalculation);
setLocalDashboardSortOption(preferredDashboardSortOption);
setLocalLinkPreviewsEnabled(linkPreviewsEnabled);
setLocalMeshcoreChannelRetryEnabled(meshcoreChannelRetryEnabled);
setLocalSolarMonitoringEnabled(solarMonitoringEnabled);
setLocalSolarMonitoringLatitude(solarMonitoringLatitude);
setLocalSolarMonitoringLongitude(solarMonitoringLongitude);
setLocalSolarMonitoringAzimuth(solarMonitoringAzimuth);
setLocalSolarMonitoringDeclination(solarMonitoringDeclination);
setLocalHideIncompleteNodes(!showIncompleteNodes);
}, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, nodeHopsCalculation, preferredDashboardSortOption, linkPreviewsEnabled, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme]);
}, [maxNodeAgeHours, inactiveNodeThresholdHours, inactiveNodeCheckIntervalMinutes, inactiveNodeCooldownHours, temperatureUnit, distanceUnit, positionHistoryLineStyle, telemetryVisualizationHours, favoriteTelemetryStorageDays, preferredSortField, preferredSortDirection, timeFormat, dateFormat, mapTileset, mapPinStyle, nodeHopsCalculation, preferredDashboardSortOption, linkPreviewsEnabled, meshcoreChannelRetryEnabled, solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme]);

// Default solar monitoring lat/long to device position if still at 0
useEffect(() => {
Expand Down Expand Up @@ -475,6 +483,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
localSolarMonitoringAzimuth !== solarMonitoringAzimuth ||
localSolarMonitoringDeclination !== solarMonitoringDeclination ||
localLinkPreviewsEnabled !== linkPreviewsEnabled ||
localMeshcoreChannelRetryEnabled !== meshcoreChannelRetryEnabled ||
localHideIncompleteNodes !== !showIncompleteNodes ||
localHomoglyphEnabled !== initialHomoglyphEnabled ||
localLocalStatsIntervalMinutes !== initialLocalStatsIntervalMinutes ||
Expand All @@ -491,6 +500,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
localSolarMonitoringEnabled, localSolarMonitoringLatitude, localSolarMonitoringLongitude, localSolarMonitoringAzimuth, localSolarMonitoringDeclination,
solarMonitoringEnabled, solarMonitoringLatitude, solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination,
localLinkPreviewsEnabled, linkPreviewsEnabled,
localMeshcoreChannelRetryEnabled, meshcoreChannelRetryEnabled,
localHideIncompleteNodes, showIncompleteNodes, localHomoglyphEnabled, initialHomoglyphEnabled,
localLocalStatsIntervalMinutes, initialLocalStatsIntervalMinutes,
nodeDimmingEnabled, nodeDimmingStartHours, nodeDimmingMinOpacity, initialNodeDimmingSettings,
Expand Down Expand Up @@ -533,6 +543,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
setLocalSolarMonitoringAzimuth(solarMonitoringAzimuth);
setLocalSolarMonitoringDeclination(solarMonitoringDeclination);
setLocalLinkPreviewsEnabled(linkPreviewsEnabled);
setLocalMeshcoreChannelRetryEnabled(meshcoreChannelRetryEnabled);
setLocalHideIncompleteNodes(!showIncompleteNodes);
setLocalHomoglyphEnabled(initialHomoglyphEnabled);
setLocalLocalStatsIntervalMinutes(initialLocalStatsIntervalMinutes);
Expand All @@ -548,7 +559,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
dateFormat, mapTileset, mapPinStyle, iconStyle, neighborInfoMinZoom, defaultMapCenterLat, defaultMapCenterLon, defaultMapCenterZoom, defaultLandingPage, appearanceMode, darkTheme, lightTheme, nodeHopsCalculation, preferredDashboardSortOption,
initialPacketMonitorSettings, solarMonitoringEnabled, solarMonitoringLatitude,
solarMonitoringLongitude, solarMonitoringAzimuth, solarMonitoringDeclination, showIncompleteNodes,
linkPreviewsEnabled,
linkPreviewsEnabled, meshcoreChannelRetryEnabled,
initialHomoglyphEnabled, initialLocalStatsIntervalMinutes, initialNodeDimmingSettings,
setNodeDimmingEnabled, setNodeDimmingStartHours, setNodeDimmingMinOpacity,
initialAnalyticsProvider, initialAnalyticsConfig, initialAppriseApiServerUrl]);
Expand Down Expand Up @@ -601,6 +612,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
solarMonitoringAzimuth: localSolarMonitoringAzimuth.toString(),
solarMonitoringDeclination: localSolarMonitoringDeclination.toString(),
linkPreviewsEnabled: localLinkPreviewsEnabled ? '1' : '0',
meshcoreChannelRetryEnabled: localMeshcoreChannelRetryEnabled ? '1' : '0',
hideIncompleteNodes: localHideIncompleteNodes ? '1' : '0',
homoglyphEnabled: String(localHomoglyphEnabled),
localStatsIntervalMinutes: localLocalStatsIntervalMinutes.toString(),
Expand Down Expand Up @@ -653,6 +665,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
onSolarMonitoringAzimuthChange(localSolarMonitoringAzimuth);
onSolarMonitoringDeclinationChange(localSolarMonitoringDeclination);
setLinkPreviewsEnabled(localLinkPreviewsEnabled);
setMeshcoreChannelRetryEnabled(localMeshcoreChannelRetryEnabled);
setShowIncompleteNodes(!localHideIncompleteNodes);

// Update initial packet monitor settings after successful save
Expand Down Expand Up @@ -683,7 +696,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
localTimeFormat, localDateFormat, localMapTileset, localMapPinStyle, localIconStyle, localNeighborInfoMinZoom, localDefaultMapCenterLat, localDefaultMapCenterLon, localDefaultMapCenterZoom, localDefaultLandingPage, localAppearanceMode, localDarkTheme, localLightTheme, getLocalEffectiveTheme,
localNodeHopsCalculation, localDashboardSortOption, localPacketLogEnabled, localPacketLogMaxCount, localPacketLogMaxAgeHours,
localSolarMonitoringEnabled, localSolarMonitoringLatitude, localSolarMonitoringLongitude,
localSolarMonitoringAzimuth, localSolarMonitoringDeclination, localLinkPreviewsEnabled, setLinkPreviewsEnabled, localHideIncompleteNodes, localHomoglyphEnabled, localLocalStatsIntervalMinutes,
localSolarMonitoringAzimuth, localSolarMonitoringDeclination, localLinkPreviewsEnabled, setLinkPreviewsEnabled, localMeshcoreChannelRetryEnabled, setMeshcoreChannelRetryEnabled, localHideIncompleteNodes, localHomoglyphEnabled, localLocalStatsIntervalMinutes,
onMaxNodeAgeChange, onInactiveNodeThresholdHoursChange, onInactiveNodeCheckIntervalMinutesChange,
onInactiveNodeCooldownHoursChange, onTemperatureUnitChange, onDistanceUnitChange, onPositionHistoryLineStyleChange,
onTelemetryVisualizationChange, onFavoriteTelemetryStorageDaysChange, onPreferredSortFieldChange,
Expand Down Expand Up @@ -824,6 +837,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
setLocalSolarMonitoringAzimuth(0);
setLocalSolarMonitoringDeclination(30);
setLocalLinkPreviewsEnabled(true);
setLocalMeshcoreChannelRetryEnabled(false);

// Update parent component with defaults
onMaxNodeAgeChange(24);
Expand All @@ -849,6 +863,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
onSolarMonitoringAzimuthChange(0);
onSolarMonitoringDeclinationChange(30);
setLinkPreviewsEnabled(true);
setMeshcoreChannelRetryEnabled(false);

// Update initial packet monitor settings
setInitialPacketMonitorSettings({ enabled: false, maxCount: 1000, maxAgeHours: 24 });
Expand Down Expand Up @@ -1076,6 +1091,7 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
{ id: 'settings-sorting', label: t('settings.sorting') },
{ id: 'settings-appearance', label: t('settings.appearance') },
{ id: 'settings-link-previews', label: t('settings.link_previews', 'Link Previews') },
{ id: 'settings-meshcore-messaging', label: t('settings.meshcore_messaging', 'MeshCore Messaging') },
{ id: 'settings-map', label: t('settings.map') },
{ id: 'settings-node-display', label: t('settings.node_display') },
{ id: 'settings-telemetry', label: t('settings.telemetry') },
Expand Down Expand Up @@ -1378,6 +1394,23 @@ const SettingsTab: React.FC<SettingsTabProps> = ({
</p>
</div>}

{show('settings-meshcore-messaging') && <div id="settings-meshcore-messaging" className="settings-section">
<h3 style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', margin: 0, cursor: 'pointer' }}>
<input
type="checkbox"
checked={localMeshcoreChannelRetryEnabled}
onChange={(e) => setLocalMeshcoreChannelRetryEnabled(e.target.checked)}
style={{ cursor: 'pointer' }}
/>
<span>{t('settings.meshcore_channel_retry_enabled', 'Auto-retry automated MeshCore channel sends')}</span>
</label>
</h3>
<p className="setting-description">
{t('settings.meshcore_channel_retry_description', 'When enabled, an AUTOMATED MeshCore channel/broadcast message (Automation Engine, Auto-Acknowledge, auto-responder, auto-announce and timer triggers) that hears no repeaters within 30 seconds is resent exactly once. User-initiated sends are never retried. This is opt-in, channel-only, and one-shot — distinct from the always-on retry for direct messages. You may occasionally see a duplicate on the mesh.')}
</p>
</div>}

{show('settings-map') && <div id="settings-map" className="settings-section">
<h3>{t('settings.map')}</h3>
<div className="setting-item">
Expand Down
26 changes: 26 additions & 0 deletions src/contexts/SettingsContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,12 @@ interface SettingsContextType {
enableAudioNotifications: boolean;
/** Global toggle: fetch & render OpenGraph link preview cards for URLs in messages. */
linkPreviewsEnabled: boolean;
/**
* Global opt-in (issue #3979, default false): auto-retry an AUTOMATED MeshCore
* channel send once, 30s later, when zero repeaters were heard. Automated
* senders only; never user-initiated sends. Distinct from the DM ack-retry.
*/
meshcoreChannelRetryEnabled: boolean;
nodeDimmingEnabled: boolean;
nodeDimmingStartHours: number;
nodeDimmingMinOpacity: number;
Expand Down Expand Up @@ -154,6 +160,7 @@ interface SettingsContextType {
setSolarMonitoringDeclination: (declination: number) => void;
setEnableAudioNotifications: (enabled: boolean) => void;
setLinkPreviewsEnabled: (enabled: boolean) => void;
setMeshcoreChannelRetryEnabled: (enabled: boolean) => void;
mutedChannels: MutedChannel[];
mutedDMs: MutedDM[];
muteChannel: (channelId: number, muteUntil: number | null) => Promise<void>;
Expand Down Expand Up @@ -418,6 +425,11 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
// the previous always-on behavior; loaded from the server in loadServerSettings.
const [linkPreviewsEnabled, setLinkPreviewsEnabledState] = useState<boolean>(true);

// MeshCore automated-channel-send auto-retry (issue #3979) - server-backed
// (global). Defaults to false (opt-in); loaded from the server in
// loadServerSettings.
const [meshcoreChannelRetryEnabled, setMeshcoreChannelRetryEnabledState] = useState<boolean>(false);

// Node dimming settings - localStorage only
const [nodeDimmingEnabled, setNodeDimmingEnabledState] = useState<boolean>(() => {
const saved = localStorage.getItem('nodeDimmingEnabled');
Expand Down Expand Up @@ -822,6 +834,11 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
setLinkPreviewsEnabledState(enabled);
};

// MeshCore channel-retry setter updates state only - persisted server-side
const setMeshcoreChannelRetryEnabled = (enabled: boolean) => {
setMeshcoreChannelRetryEnabledState(enabled);
};

// Solar monitoring setters update state only - values are persisted server-side
const setSolarMonitoringEnabled = (enabled: boolean) => {
setSolarMonitoringEnabledState(enabled);
Expand Down Expand Up @@ -1328,6 +1345,13 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
setLinkPreviewsEnabledState(enabled);
}

// MeshCore channel-send auto-retry (#3979) - database-only. Absent key
// means default (disabled); only an explicit '1'/'true' turns it on.
if (settings.meshcoreChannelRetryEnabled !== undefined) {
const enabled = settings.meshcoreChannelRetryEnabled === '1' || settings.meshcoreChannelRetryEnabled === 'true';
setMeshcoreChannelRetryEnabledState(enabled);
}

// Solar monitoring settings - database-only, no localStorage persistence
if (settings.solarMonitoringEnabled !== undefined) {
const enabled = settings.solarMonitoringEnabled === '1' || settings.solarMonitoringEnabled === 'true';
Expand Down Expand Up @@ -1533,6 +1557,7 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
customTilesets,
isLoadingThemes,
linkPreviewsEnabled,
meshcoreChannelRetryEnabled,
solarMonitoringEnabled,
solarMonitoringLatitude,
solarMonitoringLongitude,
Expand Down Expand Up @@ -1581,6 +1606,7 @@ export const SettingsProvider: React.FC<SettingsProviderProps> = ({ children, ba
updateCustomTileset,
deleteCustomTileset,
setLinkPreviewsEnabled,
setMeshcoreChannelRetryEnabled,
setSolarMonitoringEnabled,
setSolarMonitoringLatitude,
setSolarMonitoringLongitude,
Expand Down
Loading
Loading