Skip to content
Open
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
2 changes: 2 additions & 0 deletions config/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ relay:
burst_size: 5
min_relay_rssi: -110.0
max_relay_rssi: -50.0
# channel_throttle_percent: # per-channel relay duty budget (est. ToA)
# "0": 100 # omit = 100% all channels; EU capped at 1%

upstream:
enabled: true
Expand Down
14 changes: 14 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,19 @@ relay:

The relay path is independent from RX: transmission never blocks packet reception. Packets are deduplicated by ID, rate-limited, and filtered by signal strength before relay.

### Per-channel relay throttle (est.)

Optional rolling 1-hour ToA budget per Meshtastic channel index. **Relay TX only** — does not change native `transmit` messaging. Omitted channels default to 100%.

```yaml
relay:
channel_throttle_percent:
"0": 100
"1": 50
```

On EU868 the effective ceiling is capped at the 1% regional hint. Values are **estimates**, not spectrum-analyser measurements. Configure from **Configuration → Advanced → Relay channel throttle** or edit `local.yaml`.

---

## Transmit (Native Messaging)
Expand Down Expand Up @@ -717,6 +730,7 @@ relay: # experimental: re-broadcast captured packets via USB rad
burst_size: 5
min_relay_rssi: -110.0
max_relay_rssi: -50.0
channel_throttle_percent: {} # per-channel relay ToA budget (est.); omit = 100%

upstream: # cloud (Meshradar) connection
enabled: true
Expand Down
99 changes: 86 additions & 13 deletions frontend/css/configuration.css
Original file line number Diff line number Diff line change
Expand Up @@ -41,19 +41,6 @@
line-height: 1.5;
}

.cfg-card__hint--nested {
margin: -4px 0 4px;
}

.cfg-inline-link {
color: var(--accent-cyan, #22d3ee);
text-decoration: none;
}

.cfg-inline-link:hover {
text-decoration: underline;
}

.cfg-form {
display: flex;
flex-direction: column;
Expand Down Expand Up @@ -242,6 +229,92 @@ select.cfg-field__input option:checked {
transform: translateX(-50%) translateY(0);
}

.cfg-id-list {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.5rem;
}

.cfg-id-empty {
margin: 0;
font-size: 12px;
color: rgba(243, 244, 246, 0.45);
font-style: italic;
}

.cfg-id-row {
display: flex;
align-items: center;
gap: 0.5rem;
}

.cfg-id-chip {
font-family: 'JetBrains Mono', Menlo, monospace;
font-size: 12px;
padding: 0.15rem 0.45rem;
background: rgba(6, 182, 212, 0.1);
border: 1px solid rgba(6, 182, 212, 0.25);
border-radius: 6px;
color: #e2e8f0;
}

.cfg-id-remove {
background: transparent;
border: none;
color: rgba(243, 244, 246, 0.5);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 0.25rem;
}

.cfg-id-remove:hover {
color: #ef4444;
}

.cfg-id-add {
display: flex;
gap: 0.5rem;
align-items: center;
}

.cfg-id-add .cfg-field__input {
flex: 1;
}

.cfg-throttle-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem 1rem;
margin-top: 0.5rem;
}

.cfg-throttle-row {
display: grid;
grid-template-columns: 2.5rem 1fr 3rem;
align-items: center;
gap: 0.5rem;
font-size: 12px;
}

.cfg-throttle-label {
color: rgba(243, 244, 246, 0.65);
font-family: 'JetBrains Mono', Menlo, monospace;
}

.cfg-throttle-range {
width: 100%;
accent-color: var(--brand-cyan, #06b6d4);
}

.cfg-throttle-value {
text-align: right;
color: var(--brand-amber, #ffb84d);
font-family: 'JetBrains Mono', Menlo, monospace;
font-size: 11px;
}

@keyframes cfgFadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
Expand Down
62 changes: 62 additions & 0 deletions frontend/css/stats.css
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,68 @@
max-height: 220px;
}

.stats-card--hourly {
min-height: 300px;
}

.stats-card--hourly canvas {
max-height: 260px;
}

.stats-duty-meter {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-top: 0.5rem;
}

.stats-duty-row {
display: grid;
grid-template-columns: 2.5rem 1fr 5.5rem;
align-items: center;
gap: 0.5rem;
font-size: 11px;
}

.stats-duty-ch {
color: var(--text-secondary, #94a3b8);
font-family: 'JetBrains Mono', monospace;
}

.stats-duty-bar {
height: 8px;
background: rgba(30, 41, 59, 0.8);
border-radius: 4px;
overflow: hidden;
}

.stats-duty-fill {
height: 100%;
background: linear-gradient(90deg, #06b6d4, #f59e0b);
border-radius: 4px;
transition: width 0.3s ease;
}

.stats-duty-val {
text-align: right;
color: var(--text-secondary, #94a3b8);
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
}

.stats-duty-empty {
margin: 0;
font-size: 12px;
color: var(--text-secondary, #94a3b8);
font-style: italic;
}

.stats-duty-total {
margin: 0.35rem 0 0;
font-size: 12px;
color: var(--text-primary, #e2e8f0);
}

/* --- Range cards --- */
.stats-range-grid {
display: grid;
Expand Down
1 change: 1 addition & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ <h3 class="dangerous-panel__title">Service actions</h3>
<script src="js/configuration/gps_stats_column.js"></script>
<script src="js/configuration/gps_card.js"></script>
<script src="js/configuration/advanced_card.js"></script>
<script src="js/configuration/relay_throttle_card.js"></script>
<script src="js/configuration/configuration_panel.js"></script>
<script src="/vendor/xterm/xterm.js"></script>
<script src="/vendor/xterm/xterm-addon-fit.js"></script>
Expand Down
18 changes: 13 additions & 5 deletions frontend/js/configuration/configuration_panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,13 +121,21 @@ class ConfigurationPanel {
card.mount(host);
this._cards.set('gps', card);
}
} else if (section === 'advanced' && window.AdvancedConfigCard) {
} else if (section === 'advanced') {
const host = document.getElementById('cfg-advanced-panel');
if (host) {
host.innerHTML = '';
const card = new window.AdvancedConfigCard(api);
card.mount(host);
this._cards.set('advanced', card);
host.innerHTML = '<div class="cfg-section" data-adv-mount></div>';
const mount = host.querySelector('[data-adv-mount]');
if (window.AdvancedConfigCard) {
const card = new window.AdvancedConfigCard(api);
card.mount(mount);
this._cards.set('advanced', card);
}
if (window.RelayThrottleCard) {
const throttle = new window.RelayThrottleCard(api);
throttle.mount(mount);
this._cards.set('relay-throttle', throttle);
}
}
}
this._mounted.add(section);
Expand Down
105 changes: 105 additions & 0 deletions frontend/js/configuration/relay_throttle_card.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Configuration → Advanced — per-channel relay duty throttle (est.).
*/

class RelayThrottleCard {
constructor(api) {
this._api = api;
this._root = null;
this._throttle = {};
}

mount(root) {
this._root = root;
this._root.innerHTML = `
<article class="cfg-card">
<header class="cfg-card__head">
<h3 class="cfg-card__title">Relay channel throttle (est.)</h3>
<p class="cfg-card__hint">
Rolling 1 h ToA budget per channel. Relay TX only — does not limit native messaging.
EU868 capped at 1% regulatory ceiling.
</p>
</header>
<form class="cfg-form" data-relay-throttle-form>
<div class="cfg-throttle-grid" data-throttle-grid></div>
<div class="cfg-card__actions">
<button class="terminal-button terminal-button--primary"
type="submit">Save relay throttle</button>
</div>
<p class="cfg-status" data-relay-throttle-status aria-live="polite"></p>
</form>
</article>
`;
this._throttleGrid = this._root.querySelector('[data-throttle-grid]');
this._statusEl = this._root.querySelector('[data-relay-throttle-status]');
this._root.querySelector('[data-relay-throttle-form]')
.addEventListener('submit', (e) => this._onSubmit(e));
this._paintThrottleGrid();
}

render(config) {
const relay = config.relay || {};
this._throttle = { ...(relay.channel_throttle_percent || {}) };
this._paintThrottleGrid();
}

_paintThrottleGrid() {
if (!this._throttleGrid) return;
const rows = [];
for (let ch = 0; ch <= 7; ch += 1) {
const key = String(ch);
const value = this._throttle[key] != null ? this._throttle[key] : 100;
rows.push(`
<label class="cfg-throttle-row">
<span class="cfg-throttle-label">Ch ${ch}</span>
<input class="cfg-throttle-range" type="range" min="1" max="100" step="1"
data-throttle-ch="${ch}" value="${value}">
<span class="cfg-throttle-value" data-throttle-val="${ch}">${value}%</span>
</label>
`);
}
this._throttleGrid.innerHTML = rows.join('');
this._throttleGrid.querySelectorAll('[data-throttle-ch]').forEach((input) => {
input.addEventListener('input', () => {
const ch = input.dataset.throttleCh;
const pct = Number(input.value);
this._throttle[ch] = pct;
const valEl = this._throttleGrid.querySelector(`[data-throttle-val="${ch}"]`);
if (valEl) valEl.textContent = `${pct}%`;
});
});
}

_throttlePayload() {
const payload = {};
for (let ch = 0; ch <= 7; ch += 1) {
const key = String(ch);
const pct = Number(this._throttle[key] != null ? this._throttle[key] : 100);
if (pct !== 100) payload[key] = pct;
}
return payload;
}

async _onSubmit(event) {
event.preventDefault();
this._setStatus('pending', 'Saving…');
const result = await this._api.put('/api/config/relay', {
channel_throttle_percent: this._throttlePayload(),
});
if (!result) {
this._setStatus('error', 'Save failed.');
return;
}
this._setStatus('success', 'Saved.');
this._api.toast('Relay throttle applied (no restart required).');
this._api.refresh();
}

_setStatus(kind, message) {
if (!this._statusEl) return;
this._statusEl.dataset.kind = kind;
this._statusEl.textContent = message;
}
}

window.RelayThrottleCard = RelayThrottleCard;
Loading
Loading