# ARCH 热点侧 · 全部源码（单文件版）

> 项目：ARCH 业余无线电融合中枢 · **热点侧**（树莓派热点盒子）
> 对应固件版本：**1.0.25**　生成日期：2026-09-23
> 文件数：**107**

本文档把整个热点侧代码库内联在一起，方便直接交给 AI 通读。
所有个人呼号 / DMR ID / 密码 / 内网地址**已脱敏**。

**先读什么**：`README-给审查AI.md` → `ARCH热点侧-完整技术说明与代码架构.md` → 然后按需看下面的源码。

---

## 文件清单

| 文件 | 行数 | 大小 |
| --- | ---: | ---: |
| `etc/DMRGateway.ini` | 72 | 914 B |
| `etc/ircddbgateway` | 184 | 3218 B |
| `etc/mmdvm/active-mode.conf` | 6 | 101 B |
| `etc/mmdvm/bm-masters.json` | 2 | 3 B |
| `etc/mmdvm/channels.json` | 78 | 1215 B |
| `etc/mmdvm/fm-tailtone.json` | 7 | 83 B |
| `etc/mmdvm/frequency.json` | 10 | 159 B |
| `etc/mmdvm/fw-url` | 2 | 59 B |
| `etc/mmdvm/install-state.conf` | 18 | 598 B |
| `etc/MMDVM-Host-1.ini` | 193 | 2482 B |
| `etc/MMDVM-Host-10.ini` | 193 | 2484 B |
| `etc/MMDVM-Host-11.ini` | 193 | 2484 B |
| `etc/MMDVM-Host-2.ini` | 194 | 2505 B |
| `etc/MMDVM-Host-3.ini` | 194 | 2505 B |
| `etc/MMDVM-Host-4.ini` | 194 | 2505 B |
| `etc/MMDVM-Host-5.ini` | 193 | 2482 B |
| `etc/MMDVM-Host-6.ini` | 194 | 2496 B |
| `etc/MMDVM-Host-7.ini` | 193 | 2482 B |
| `etc/MMDVM-Host-8.ini` | 193 | 2482 B |
| `etc/MMDVM-Host-9.ini` | 193 | 2482 B |
| `etc/MMDVM-Host.ini` | 193 | 2478 B |
| `etc/MMDVM-IQ-1.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-10.ini` | 49 | 573 B |
| `etc/MMDVM-IQ-11.ini` | 49 | 573 B |
| `etc/MMDVM-IQ-2.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-3.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-4.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-5.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-6.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-7.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-8.ini` | 49 | 572 B |
| `etc/MMDVM-IQ-9.ini` | 49 | 572 B |
| `etc/MMDVM-IQ.ini` | 43 | 483 B |
| `etc/MMDVM-Multi.ini` | 39 | 463 B |
| `etc/nginx/sites-available/mmdvm-web` | 19 | 495 B |
| `etc/NXDNGateway.ini` | 83 | 1237 B |
| `etc/P25Gateway.ini` | 48 | 666 B |
| `etc/systemd/system/arch-callsign-cache-update.service` | 7 | 160 B |
| `etc/systemd/system/arch-callsign-cache-update.timer` | 9 | 149 B |
| `etc/systemd/system/arch-data-bootstrap.service` | 17 | 315 B |
| `etc/systemd/system/arch-data-bootstrap.timer` | 12 | 186 B |
| `etc/systemd/system/arch-tetra-mode-sync.service` | 7 | 185 B |
| `etc/systemd/system/arch-tetra-mode-sync.timer` | 9 | 145 B |
| `etc/systemd/system/dmrgateway.service` | 19 | 382 B |
| `etc/systemd/system/fmo-bridge.service` | 19 | 438 B |
| `etc/systemd/system/ircddbgatewayd.service` | 15 | 318 B |
| `etc/systemd/system/mmdvm-iq@.service` | 23 | 447 B |
| `etc/systemd/system/mmdvm-multi.service` | 24 | 518 B |
| `etc/systemd/system/mmdvm-web.service` | 17 | 407 B |
| `etc/systemd/system/mmdvm-wifi-boot.service` | 10 | 240 B |
| `etc/systemd/system/mmdvmhost@.service` | 21 | 398 B |
| `etc/systemd/system/nxdngateway.service` | 15 | 308 B |
| `etc/systemd/system/p25gateway.service` | 15 | 305 B |
| `etc/systemd/system/tetra-bs.service` | 19 | 423 B |
| `etc/systemd/system/ysfgateway.service` | 15 | 305 B |
| `etc/YSFGateway.ini` | 81 | 1100 B |
| `opt/fmo-bridge/config.template.json` | 65 | 1299 B |
| `opt/fmo-bridge/fmo_app_uplink_relay.py` | 220 | 6591 B |
| `opt/fmo-bridge/fmo_bridge.py` | 1036 | 48751 B |
| `opt/fmo-bridge/fmo_dsp.py` | 206 | 8223 B |
| `opt/fmo-bridge/fmo_fm.py` | 150 | 4497 B |
| `opt/fmo-bridge/fmo_packet.py` | 118 | 4763 B |
| `opt/fmo-bridge/fmo_presence.py` | 62 | 2069 B |
| `opt/fmo-bridge/fmo_proto.py` | 186 | 6589 B |
| `opt/fmo-bridge/fmo_reg.py` | 107 | 4215 B |
| `opt/fmo-bridge/fmo_voice.py` | 188 | 7930 B |
| `opt/mmdvm-web/app.py` | 9962 | 468873 B |
| `opt/mmdvm-web/console_new.py` | 412 | 23617 B |
| `opt/mmdvm-web/fmo_page.py` | 414 | 24663 B |
| `opt/mmdvm-web/multi2_js.py` | 161 | 8165 B |
| `opt/mmdvm-web/multi2_section.py` | 107 | 5095 B |
| `opt/mmdvm-web/rfmode2_js.py` | 455 | 25332 B |
| `opt/mmdvm-web/rfmode2_section.py` | 222 | 12385 B |
| `opt/mmdvm-web/rfmode_js.py` | 138 | 7725 B |
| `opt/mmdvm-web/rfmode_page.py` | 203 | 11099 B |
| `opt/mmdvm-web/rfmode_section.py` | 77 | 3646 B |
| `opt/mmdvm-web/rfmode_standalone.py` | 66 | 3327 B |
| `opt/mmdvm-web/tetra_adv_backend.py` | 147 | 4939 B |
| `opt/mmdvm-web/tetra_adv_spec.py` | 192 | 9219 B |
| `opt/mmdvm-web/tetra_page.py` | 787 | 47903 B |
| `usr/local/bin/arch-data-bootstrap.sh` | 43 | 1722 B |
| `usr/local/bin/arch-fw-online.sh` | 137 | 4406 B |
| `usr/local/bin/arch-sync-fm-qso.py` | 114 | 3323 B |
| `usr/local/bin/backup.sh` | 5 | 251 B |
| `usr/local/bin/channel-mode.sh` | 3 | 84 B |
| `usr/local/bin/diagnose.sh` | 7 | 520 B |
| `usr/local/bin/dmrid-update-wrapper.sh` | 3 | 57 B |
| `usr/local/bin/sys-manage.sh` | 865 | 40605 B |
| `usr/local/bin/update-database.sh` | 103 | 4613 B |
| `usr/local/bin/update-dstar.sh` | 21 | 1167 B |
| `usr/local/bin/update-hosts.sh` | 74 | 3091 B |
| `usr/local/bin/web-fmo.sh` | 5 | 158 B |
| `usr/local/bin/web-manage.sh` | 366 | 14000 B |
| `usr/local/bin/web-multi.sh` | 94 | 5509 B |
| `usr/local/bin/web-tetra.sh` | 4 | 81 B |
| `usr/local/libexec/arch-channel-limit.py` | 76 | 2651 B |
| `usr/local/libexec/channel-setup.py` | 216 | 9876 B |
| `usr/local/libexec/fmo-api.py` | 520 | 18807 B |
| `usr/local/libexec/hosts-build.py` | 236 | 10292 B |
| `usr/local/libexec/ini-edit.py` | 13 | 466 B |
| `usr/local/libexec/mmdvm-save.py` | 510 | 16754 B |
| `usr/local/libexec/mmdvm-status.py` | 302 | 10025 B |
| `usr/local/libexec/tetra-api.py` | 1071 | 40571 B |
| `_build/installer_template.sh` | 98 | 5714 B |
| `_build/updater_template.sh` | 20 | 1140 B |
| `_build/build_upstream_template.sh` | 126 | 6065 B |
| `_build/make_release.py` | 222 | 10744 B |

---

## 源码全文

### `etc/DMRGateway.ini`

```ini
[General]
Id=000000000
Timeout=10
RptAddress=127.0.0.1
RptPort=62032
LocalAddress=127.0.0.1
LocalPort=62031
RuleTrace=0
Daemon=0
TrunkingEnabled=0
Debug=0
RFTimeout=60
NetTimeout=60

Callsign=NOCALL
[Log]
DisplayLevel=1
MQTTLevel=1

[Voice]
Enabled=1
Language=en_GB
Directory=/var/lib/mmdvm/dmr/Audio

[Info]
Callsign=NOCALL
TXFrequency=439525000
RXFrequency=434525000
Power=1
ColorCode=7
Duplex=1
Slot1=1
Slot2=1
Latitude=0.0
Longitude=0.0
Height=0
Location=Repeater
Description=MMDVM-Multi Duplex
URL=www.qrz.com/db/NOCALL

[DMR Network 1]
Enabled=1
Name=BM
Id=000000000
Address=4602.master.brandmeister.network
Port=62031
Password=CHANGE_ME
Location=1
Debug=0
PassAllTG=1
PassAllTG=2
PassAllPC=1
PassAllPC=2
TGRewrite=1,9,1,9,1
TGRewrite=2,9,2,9,1

[MQTT]
Address=127.0.0.1
Port=1883
Keepalive=60
Auth=0
Username=mmdvm
Password=mmdvm
Name=dmr-gateway

[Dynamic TG Control]
Enable=1

[Remote Commands]
Enable=0
```

### `etc/ircddbgateway`

```
gatewayType=0
gatewayCallsign=
gatewayAddress=0.0.0.0
icomAddress=172.16.0.20
icomPort=20000
hbAddress=127.0.0.1
hbPort=20010
latitude=
longitude=
description1=
description2=
url=
repeaterCall1=
repeaterBand1=B
repeaterType1=0
repeaterAddress1=127.0.0.1
repeaterPort1=20011
reflector1=
atStartup1=0
reconnect1=0
frequency1=434.00000
offset1=0.0000
rangeKms1=0.000
latitude1=0.000000
longitude1=0.000000
agl1=0.000
description1_1=
description1_2=
url1=
band1_1=0
band1_2=0
band1_3=0
repeaterCall2=
repeaterBand2= 
repeaterType2=0
repeaterAddress2=127.0.0.1
repeaterPort2=20012
reflector2=
atStartup2=0
reconnect2=0
frequency2=0.00000
offset2=0.0000
rangeKms2=0.000
latitude2=0.000000
longitude2=0.000000
agl2=0.000
description2_1=
description2_2=
url2=
band2_1=0
band2_2=0
band2_3=0
repeaterCall3=
repeaterBand3= 
repeaterType3=0
repeaterAddress3=127.0.0.1
repeaterPort3=20013
reflector3=
atStartup3=0
reconnect3=0
frequency3=0.00000
offset3=0.0000
rangeKms3=0.000
latitude3=0.000000
longitude3=0.000000
agl3=0.000
description3_1=
description3_2=
url3=
band3_1=0
band3_2=0
band3_3=0
repeaterCall4=
repeaterBand4= 
repeaterType4=0
repeaterAddress4=127.0.0.1
repeaterPort4=20014
reflector4=
atStartup4=0
reconnect4=0
frequency4=0.00000
offset4=0.0000
rangeKms4=0.000
latitude4=0.000000
longitude4=0.000000
agl4=0.000
description4_1=
description4_2=
url4=
band4_1=0
band4_2=0
band4_3=0
ircddbEnabled=1
ircddbHostname=rr.openquad.net
ircddbUsername=
ircddbPassword=
ircddbEnabled2=0
ircddbHostname2=rr.openquad.net
ircddbUsername2=
ircddbPassword2=
ircddbEnabled3=0
ircddbHostname3=
ircddbUsername3=
ircddbPassword3=
ircddbEnabled4=0
ircddbHostname4=
ircddbUsername4=
ircddbPassword4=
aprsEnabled=1
aprsHostname=rotate.aprs2.net
aprsPassword=
aprsPort=14580
dextraEnabled=1
dextraMaxDongles=5
dplusEnabled=0
dplusMaxDongles=5
dplusLogin=        
dcsEnabled=1
ccsEnabled=1
ccsHost=CCS704  
xlxEnabled=1
xlxHostsFileUrl=http://xlxapi.rlx.lu/api.php?do=GetXLXDMRMaster
starNetBand1=A
starNetCallsign1=        
starNetLogoff1=        
starNetInfo1=
starNetPermanent1=        
starNetUserTimeout1=300
starNetGroupTimeout1=300
starNetCallsignSwitch1=0
starNetTXMsgSwitch1=1
starNetReflector1=
starNetBand2=A
starNetCallsign2=        
starNetLogoff2=        
starNetInfo2=
starNetPermanent2=        
starNetUserTimeout2=300
starNetGroupTimeout2=300
starNetCallsignSwitch2=0
starNetTXMsgSwitch2=1
starNetReflector2=
starNetBand3=A
starNetCallsign3=        
starNetLogoff3=        
starNetInfo3=
starNetPermanent3=        
starNetUserTimeout3=300
starNetGroupTimeout3=300
starNetCallsignSwitch3=0
starNetTXMsgSwitch3=1
starNetReflector3=
starNetBand4=A
starNetCallsign4=        
starNetLogoff4=        
starNetInfo4=
starNetPermanent4=        
starNetUserTimeout4=300
starNetGroupTimeout4=300
starNetCallsignSwitch4=0
starNetTXMsgSwitch4=1
starNetReflector4=
starNetBand5=A
starNetCallsign5=        
starNetLogoff5=        
starNetInfo5=
starNetPermanent5=        
starNetUserTimeout5=300
starNetGroupTimeout5=300
starNetCallsignSwitch5=0
starNetTXMsgSwitch5=1
starNetReflector5=
remoteEnabled=1
remotePassword=
remotePort=54321
language=0
infoEnabled=1
echoEnabled=1
logEnabled=0
dratsEnabled=0
dtmfEnabled=1
windowX=-1
windowY=-1
```

### `etc/mmdvm/active-mode.conf`

```ini
VERSION=21.39
MODE=DMR+D-STAR+YSF+P25+NXDN+FM
CONFIGURED=1
CHANNELS=6
CHANGED_AT=2026-09-20 16:39:35
```

### `etc/mmdvm/bm-masters.json`

```json
[]
```

### `etc/mmdvm/channels.json`

```json
{
  "channels": 1,
  "center_rx": 434500000,
  "center_tx": 439500000,
  "callsign": "NOCALL",
  "dmr_id": "000000000",
  "spacing": 25000,
  "sample_rate": 150000,
  "list": [
    {
      "n": 1,
      "mode": "DMR",
      "rx": 434500000,
      "tx": 439500000
    },
    {
      "n": 2,
      "mode": "D-STAR",
      "rx": 434525000,
      "tx": 439525000
    },
    {
      "n": 3,
      "mode": "YSF",
      "rx": 434550000,
      "tx": 439550000
    },
    {
      "n": 4,
      "mode": "P25",
      "rx": 434575000,
      "tx": 439575000
    },
    {
      "n": 5,
      "mode": "NXDN",
      "rx": 434475000,
      "tx": 439475000
    },
    {
      "n": 6,
      "mode": "FM",
      "rx": 434450000,
      "tx": 439450000
    },
    {
      "n": 7,
      "mode": "NONE",
      "rx": 434425000,
      "tx": 439425000
    },
    {
      "n": 8,
      "mode": "NONE",
      "rx": 434400000,
      "tx": 439400000
    },
    {
      "n": 9,
      "mode": "NONE",
      "rx": 434375000,
      "tx": 439375000
    },
    {
      "n": 10,
      "mode": "NONE",
      "rx": 434650000,
      "tx": 439650000
    },
    {
      "n": 11,
      "mode": "NONE",
      "rx": 434625000,
      "tx": 439625000
    }
  ]
}
```

### `etc/mmdvm/fm-tailtone.json`

```json
{
  "enable": true,
  "freq": 1000,
  "ms": 200,
  "delay_ms": 120,
  "gain": 0.7
}
```

### `etc/mmdvm/frequency.json`

```json
{
  "rx": 434450000,
  "tx": 439450000,
  "split_mhz": 5.0,
  "spacing": 25000,
  "sample_rate": 150000,
  "channels": 1,
  "updated": "2026-09-12 19:56:54"
}
```

### `etc/mmdvm/fw-url`

```
https://arch.hljrsa.cn/downloads/firmware/mmdvm-multi.json
```

### `etc/mmdvm/install-state.conf`

```ini
VERSION=21.40
ACTIVE_MODE=NONE
INSTALL_DMR=1
INSTALL_DSTAR=1
INSTALL_YSF=1
INSTALL_P25=1
INSTALL_NXDN=1
INSTALL_FM=1
INSTALL_POCSAG=1
SOAPYSX_COMMIT=9705147dd8c189625071f3f163ea56119bda4a05
MMDVM_IQ_COMMIT=dd4a0fc785851e73d6a6347f355e749aacc5cd72
MMDVM_HOST_COMMIT=dea6e9b2c35857fe6f904c5092bebadb86cbf079
DMRGATEWAY_COMMIT=2a3306de313cf4c094c2031c9ced5a6858bbbfcc
YSFCLIENTS_COMMIT=45e91d0ac250a286330730542ad2f34426728489
P25CLIENTS_COMMIT=ef58905da911063307abcaeedd7fd9aed9b10b1d
NXDNCLIENTS_COMMIT=ae9d88d25491f885b38b12b48b5c416915a7ed48
IRCDDB_COMMIT=9dc023d9479eb77bbf27abcf2ae774348906c391
```

### `etc/MMDVM-Host-1.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434500000
TXFrequency=439500000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-1

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3334
LocalAddress=127.0.0.1
LocalPort=3335
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434500000
TXFrequency=439500000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=1
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-10.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434650000
TXFrequency=439650000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-10

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-10

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3352
LocalAddress=127.0.0.1
LocalPort=3353
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434650000
TXFrequency=439650000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-11.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434625000
TXFrequency=439625000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-11

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-11

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3354
LocalAddress=127.0.0.1
LocalPort=3355
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434625000
TXFrequency=439625000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-2.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434525000
TXFrequency=439525000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-2

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-2

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3336
LocalAddress=127.0.0.1
LocalPort=3337
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434525000
TXFrequency=439525000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=1
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

LocalAddress=127.0.0.1
[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-3.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434550000
TXFrequency=439550000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-3

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-3

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3338
LocalAddress=127.0.0.1
LocalPort=3339
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434550000
TXFrequency=439550000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=1
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

LocalAddress=127.0.0.1
[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-4.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434575000
TXFrequency=439575000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-4

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-4

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3340
LocalAddress=127.0.0.1
LocalPort=3341
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434575000
TXFrequency=439575000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=1
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

LocalAddress=127.0.0.1
[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-5.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434475000
TXFrequency=439475000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-5

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-5

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3342
LocalAddress=127.0.0.1
LocalPort=3343
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434475000
TXFrequency=439475000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=1
SelfOnly=0
RAN=1

[NXDN Network]
Enable=1
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-6.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434450000
TXFrequency=439450000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-6

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-6

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3344
LocalAddress=127.0.0.1
LocalPort=3345
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434450000
TXFrequency=439450000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=1
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1000
AckMinTime=0
AckDelay=120
AckLevel=70.0
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=1
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=1
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=127.0.0.1
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
Callsign=NOCALL
```

### `etc/MMDVM-Host-7.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434425000
TXFrequency=439425000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-7

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-7

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3346
LocalAddress=127.0.0.1
LocalPort=3347
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434425000
TXFrequency=439425000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-8.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434400000
TXFrequency=439400000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-8

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-8

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3348
LocalAddress=127.0.0.1
LocalPort=3349
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434400000
TXFrequency=439400000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host-9.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434375000
TXFrequency=439375000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host-9

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost-9

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3350
LocalAddress=127.0.0.1
LocalPort=3351
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434375000
TXFrequency=439375000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=0
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=0
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=0
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=0
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=0
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=0
SelfOnly=0
RAN=1

[NXDN Network]
Enable=0
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=0
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=0
Frequency=439500000

[POCSAG Network]
Enable=0


[FM Network]
Enable=0
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-Host.ini`

```ini
[General]
Callsign=NOCALL
Id=000000000
Timeout=180
Duplex=1
RFModeHang=10
NetModeHang=3
Daemon=0
RXFrequency=434500000
TXFrequency=439500000

[Info]
RXFrequency=434525000
TXFrequency=439525000
Power=0
Latitude=0.0
Longitude=0.0
Height=0
Description=MMDVM-Multi Duplex Repeater
URL=www.qrz.com/db/NOCALL

[Log]
DisplayLevel=1
FileLevel=1
FilePath=/var/log/mmdvm
FileRoot=MMDVM-Host

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvmhost

[Modem]
Protocol=udp
ModemAddress=127.0.0.1
ModemPort=3334
LocalAddress=127.0.0.1
LocalPort=3335
TXInvert=1
RXInvert=1
PTTInvert=0
TXDelay=100
RXOffset=0
RXFrequency=434525000
TXFrequency=439525000
RXLevel=50
TXLevel=50
RFLevel=100
Trace=0
Debug=0

[DMR Id Lookup]
File=/usr/local/etc/DMRIds.dat
Time=24

[NXDN Id Lookup]
File=/usr/local/etc/NXDN.csv
Time=24

[DMR]
Enable=1
ColorCode=7
SelfOnly=0
DumpTAData=1
CallHang=3
TXHang=4

[DMR Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=62031
LocalAddress=127.0.0.1
LocalPort=62032
Jitter=300
Slot1=1
Slot2=1
Debug=0

[D-Star]
Enable=1
Module=B
SelfOnly=0
AckReply=1
AckTime=750
AckMessage=0
ErrorReply=1

[D-Star Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=20010
LocalPort=20011
Debug=0

[System Fusion]
Enable=1
LoDev=0
SelfOnly=0

[System Fusion Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=4200
LocalPort=3200
Debug=0

[P25]
Enable=1
SelfOnly=0
NAC=293
OverrideUIDCheck=0

[P25 Network]
Enable=1
GatewayAddress=127.0.0.1
GatewayPort=42020
LocalPort=32010
Debug=0

[NXDN]
Enable=1
SelfOnly=0
RAN=1

[NXDN Network]
Enable=1
Protocol=Kenwood
LocalAddress=127.0.0.1
LocalPort=14021
GatewayAddress=127.0.0.1
GatewayPort=14020
Debug=0

[FM]
Enable=1
CallsignSpeed=20
CallsignFrequency=1000
CallsignTime=10
CallsignHoldoff=1
CallsignHighLevel=35
CallsignLowLevel=15
CallsignAtStart=1
CallsignAtEnd=1
CallsignAtLatch=1
RFAck=K
ExtAck=N
AckSpeed=20
AckFrequency=1750
AckMinTime=5
AckDelay=1000
AckLevel=80
Timeout=180
TimeoutLevel=80
CTCSSFrequency=88.5
CTCSSHighThreshold=30
CTCSSLowThreshold=20
CTCSSLevel=2.0
KerchunkTime=0
HangTime=0
AccessMode=5
LinkMode=1
COSInvert=0
NoiseSquelch=0
SquelchHighThreshold=140
SquelchLowThreshold=120
RFAudioBoost=1
MaxDevLevel=90
ExtAudioBoost=1

[POCSAG]
Enable=1
Frequency=439500000

[POCSAG Network]
Enable=1


[FM Network]
Enable=1
LocalAddress=0.0.0.0
LocalPort=49881
GatewayAddress=192.168.1.20
GatewayPort=49880
TXAudioGain=1.0
RXAudioGain=1.0
PreEmphasis=1
DeEmphasis=1
ModeHang=3
```

### `etc/MMDVM-IQ-1.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-1

[Host]
HostAddress=127.0.0.1
HostPort=3335
LocalAddress=127.0.0.1
LocalPort=3334
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48200
LocalAddress=127.0.0.1
LocalPort=48100
```

### `etc/MMDVM-IQ-10.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-10

[Host]
HostAddress=127.0.0.1
HostPort=3353
LocalAddress=127.0.0.1
LocalPort=3352
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48209
LocalAddress=127.0.0.1
LocalPort=48109
```

### `etc/MMDVM-IQ-11.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-11

[Host]
HostAddress=127.0.0.1
HostPort=3355
LocalAddress=127.0.0.1
LocalPort=3354
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48210
LocalAddress=127.0.0.1
LocalPort=48110
```

### `etc/MMDVM-IQ-2.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-2

[Host]
HostAddress=127.0.0.1
HostPort=3337
LocalAddress=127.0.0.1
LocalPort=3336
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48201
LocalAddress=127.0.0.1
LocalPort=48101
```

### `etc/MMDVM-IQ-3.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-3

[Host]
HostAddress=127.0.0.1
HostPort=3339
LocalAddress=127.0.0.1
LocalPort=3338
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48202
LocalAddress=127.0.0.1
LocalPort=48102
```

### `etc/MMDVM-IQ-4.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-4

[Host]
HostAddress=127.0.0.1
HostPort=3341
LocalAddress=127.0.0.1
LocalPort=3340
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48203
LocalAddress=127.0.0.1
LocalPort=48103
```

### `etc/MMDVM-IQ-5.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-5

[Host]
HostAddress=127.0.0.1
HostPort=3343
LocalAddress=127.0.0.1
LocalPort=3342
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48204
LocalAddress=127.0.0.1
LocalPort=48104
```

### `etc/MMDVM-IQ-6.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-6

[Host]
HostAddress=127.0.0.1
HostPort=3345
LocalAddress=127.0.0.1
LocalPort=3344
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48205
LocalAddress=127.0.0.1
LocalPort=48105
```

### `etc/MMDVM-IQ-7.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-7

[Host]
HostAddress=127.0.0.1
HostPort=3347
LocalAddress=127.0.0.1
LocalPort=3346
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48206
LocalAddress=127.0.0.1
LocalPort=48106
```

### `etc/MMDVM-IQ-8.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-8

[Host]
HostAddress=127.0.0.1
HostPort=3349
LocalAddress=127.0.0.1
LocalPort=3348
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48207
LocalAddress=127.0.0.1
LocalPort=48107
```

### `etc/MMDVM-IQ-9.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq-9

[Host]
HostAddress=127.0.0.1
HostPort=3351
LocalAddress=127.0.0.1
LocalPort=3350
Trace=0

[Modem]
Driver=Multi
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0

[Multi]
ModemAddress=127.0.0.1
ModemPort=48208
LocalAddress=127.0.0.1
LocalPort=48108
```

### `etc/MMDVM-IQ.ini`

```ini

[General]
Daemon=0

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-iq

[Host]
HostAddress=127.0.0.1
HostPort=3335
LocalAddress=127.0.0.1
LocalPort=3334
Trace=0

[Modem]
Driver=Soapy
Trace=0

[Soapy]
Type=sx
RxGain=55
TxGain=30
SampleRate=250000
Bandwidth=200000
RxAmplitudeBalance=1.0
RxPhaseBalance=0.0
TxAmplitudeBalance=1.0
TxPhaseBalance=0.0
RxIOffset=0.0
RxQOffset=0.0
TxIOffset=0.0
TxQOffset=0.0
```

### `etc/MMDVM-Multi.ini`

```ini
[General]
Daemon=0
Channels=6
ChannelSpacing=25000
DMRDelay=10
RFDelay=10
RSSICalibration=70

[Log]
DisplayLevel=1
MQTTLevel=1

[MQTT]
Host=127.0.0.1
Port=1883
Auth=0
Username=mmdvm
Password=mmdvm
Keepalive=60
Name=mmdvm-multi

[Modem]
Type=sx
SampleRate=300000
RxGain=55
TxGain=30
RxFrequency=434500000
TxFrequency=439500000
DigitalGain=35
SymbolDeviation=10
Trace=0

[MMDVM]
ModemAddress=127.0.0.1
ModemPort=48100
LocalAddress=127.0.0.1
LocalPort=48200
Trace=0
```

### `etc/nginx/sites-available/mmdvm-web`

```
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    server_name _;

    client_max_body_size 1m;

    location / {
        proxy_pass http://127.0.0.1:18080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 300;
        proxy_send_timeout 300;
    }
}
```

### `etc/NXDNGateway.ini`

```ini
[General]
Callsign=NOCALL
Suffix=NXDN
# The next four lines are for a Kenwood repeater
# RptProtocol=Kenwood
# RptAddress=1.2.3.4
# RptPort=64000
# LocalPort=64000
# The next four lines are for an Icom repeater
# RptProtocol=Icom
# RptAddress=1.2.3.4
# RptPort=41300
# LocalPort=41300
# The next four lines are for an MMDVM
RptProtocol=Icom
RptAddress=127.0.0.1
RptPort=14021
LocalPort=14020
Debug=0
Daemon=0

[Info]
RXFrequency=430475000
TXFrequency=439475000
Power=1
Latitude=0.0
Longitude=0.0
Height=0
Name=Nowhere
Description=Multi-Mode Repeater

[Voice]
Enabled=1
Language=en_GB
Directory=/usr/local/etc/Audio

[APRS]
Enable=0
Suffix=N
Description=APRS Description
# Symbol="/r"

[Id Lookup]
Name=/usr/local/etc/NXDN.csv
Time=24

[Log]
# Logging levels, 0=No logging
DisplayLevel=1
MQTTLevel=1

[MQTT]
Address=127.0.0.1
Port=1883
Keepalive=60
Auth=0
Username=mmdvm
Password=mmdvm
Name=nxdn-gateway

[Network]
Port=14050
HostsFile1=/usr/local/etc/NXDNHosts.json
HostsFile2=/usr/local/etc/NXDNHosts.txt
ReloadTime=60
ParrotAddress=127.0.0.1
ParrotPort=42021
NXDN2DMRAddress=127.0.0.1
NXDN2DMRPort=42022
Static=10200,65000
RFHangTime=120
NetHangTime=60
Debug=0

[GPSD]
Enable=0
Address=127.0.0.1
Port=2947

[Remote Commands]
Enable=0
```

### `etc/P25Gateway.ini`

```ini
[General]
Callsign=NOCALL
RptAddress=127.0.0.1
RptPort=32010
LocalPort=42020
Debug=0
Daemon=0

[Id Lookup]
Name=/usr/local/etc/DMRIds.dat
Time=24

[Voice]
Enabled=1
Language=en_GB
Directory=/usr/local/etc/Audio

[Log]
# Logging levels, 0=No logging
DisplayLevel=1
MQTTLevel=1

[MQTT]
Address=127.0.0.1
Port=1883
Keepalive=60
Auth=0
Username=mmdvm
Password=mmdvm
Name=p25-gateway

[Network]
Port=42010
HostsFile1=/usr/local/etc/P25Hosts.json
HostsFile2=/usr/local/etc/private/P25Hosts.json
ReloadTime=60
ParrotAddress=127.0.0.1
ParrotPort=42011
P252DMRAddress=127.0.0.1
P252DMRPort=42012
Static=46020
RFHangTime=120
NetHangTime=60
Debug=0

[Remote Commands]
Enable=0
```

### `etc/systemd/system/arch-callsign-cache-update.service`

```ini
[Unit]
Description=Refresh ARCH TETRA callsign cache
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/arch-callsign-cache-update.sh
```

### `etc/systemd/system/arch-callsign-cache-update.timer`

```ini
[Unit]
Description=Daily ARCH TETRA callsign cache refresh
[Timer]
OnBootSec=90s
OnUnitActiveSec=24h
AccuracySec=5m
[Install]
WantedBy=timers.target
```

### `etc/systemd/system/arch-data-bootstrap.service`

```ini
[Unit]
Description=ARCH 数据库首次下载与更新
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/arch-data-bootstrap.sh
TimeoutStartSec=0
Nice=10
IOSchedulingClass=idle
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/arch-data-bootstrap.timer`

```ini
[Unit]
Description=ARCH 数据库首次下载与每日更新

[Timer]
OnBootSec=45s
OnUnitActiveSec=1d
Persistent=true
Unit=arch-data-bootstrap.service

[Install]
WantedBy=timers.target
```

### `etc/systemd/system/arch-tetra-mode-sync.service`

```ini
[Unit]
Description=Keep legacy gateways off while ARCH TETRA cluster mode is active
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/arch-tetra-mode-sync.sh
```

### `etc/systemd/system/arch-tetra-mode-sync.timer`

```ini
[Unit]
Description=ARCH TETRA gateway mode synchronizer
[Timer]
OnBootSec=20s
OnUnitActiveSec=3s
AccuracySec=1s
[Install]
WantedBy=timers.target
```

### `etc/systemd/system/dmrgateway.service`

```ini
[Unit]
Description=DMRGateway Service
After=network.target mmdvm-multi.service
ConditionPathExists=/etc/mmdvm/dmr-ready
Wants=mmdvm-multi.service

[Service]
Type=simple
ExecStart=/usr/local/bin/DMRGateway /etc/DMRGateway.ini
Restart=on-failure
RestartSec=5
User=root
WorkingDirectory=/var/log/mmdvm
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/fmo-bridge.service`

```ini
[Unit]
Description=ARCH FMO 本地桥接
After=network-online.target
ConditionPathExists=/opt/fmo-bridge/pki/user_fm.json
Wants=network-online.target

[Service]
Type=simple
User=root
WorkingDirectory=/opt/fmo-bridge
Environment=TZ=Asia/Shanghai
Environment=PYTHONPATH=/opt/fmo-bridge
ExecStart=/usr/bin/python3 -u /opt/fmo-bridge/fmo_bridge.py /opt/fmo-bridge/config.json
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/ircddbgatewayd.service`

```ini
[Unit]
Description=ircDDBGateway D-STAR Service
After=network-online.target network.target mmdvm-multi.service
Wants=network-online.target mmdvm-multi.service

[Service]
Type=simple
ExecStart=/usr/local/bin/ircddbgatewayd /etc/ircddbgateway
Restart=always
RestartSec=10
User=root

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/mmdvm-iq@.service`

```ini
[Unit]
StartLimitIntervalSec=0
Description=MMDVM-IQ channel %i (SDR modem, UDP to MMDVM-Multi)
After=network.target mmdvm-multi.service
BindsTo=mmdvm-multi.service
PartOf=mmdvm-multi.service

[Service]
Type=simple
User=root
Group=mmdvm-web
ExecStart=/usr/local/bin/MMDVM-IQ /etc/MMDVM-IQ-%i.ini
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
Nice=-5
LimitRTPRIO=99
LimitNOFILE=16384

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/mmdvm-multi.service`

```ini
[Unit]
StartLimitIntervalSec=0
Description=MMDVM-Multi (SX1255 multi-channel I/Q front end)
After=network.target mosquitto.service
Before=mmdvm-iq@1.service mmdvm-iq@2.service
Wants=network.target mosquitto.service

[Service]
Type=simple
User=root
Group=mmdvm-web
WorkingDirectory=/opt/src-mmdvm/MMDVM-Multi
ExecStart=/usr/local/bin/MMDVM-Multi /etc/MMDVM-Multi.ini
Restart=always
RestartSec=5
StandardOutput=journal
StandardError=journal
Nice=-5
LimitRTPRIO=99
LimitNOFILE=16384

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/mmdvm-web.service`

```ini
[Unit]
Description=MMDVM-IQ Web Console (Gunicorn)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=mmdvm-web
Group=mmdvm-web
WorkingDirectory=/opt/mmdvm-web
ExecStart=/usr/bin/gunicorn --workers 1 --threads 6 --timeout 120 --chdir /opt/mmdvm-web --pythonpath /opt/mmdvm-web --bind 127.0.0.1:18080 app:app
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/mmdvm-wifi-boot.service`

```ini
[Unit]
Description=MMDVM WiFi auto-connect at boot
After=NetworkManager.service network-online.target
Wants=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/sys-manage.sh wifi-boot
[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/mmdvmhost@.service`

```ini
[Unit]
StartLimitIntervalSec=0
Description=MMDVM-Host channel %i
After=network.target mmdvm-iq@%i.service
BindsTo=mmdvm-iq@%i.service
PartOf=mmdvm-iq@%i.service

[Service]
Type=simple
User=root
Group=mmdvm-web
ExecStart=/usr/local/bin/MMDVM-Host /etc/MMDVM-Host-%i.ini
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal
LimitNOFILE=16384

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/nxdngateway.service`

```ini
[Unit]
Description=NXDNGateway Service
After=network-online.target network.target mmdvm-multi.service
Wants=network-online.target mmdvm-multi.service

[Service]
Type=simple
ExecStart=/usr/local/bin/NXDNGateway /etc/NXDNGateway.ini
Restart=always
RestartSec=10
User=root

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/p25gateway.service`

```ini
[Unit]
Description=P25Gateway Service
After=network-online.target network.target mmdvm-multi.service
Wants=network-online.target mmdvm-multi.service

[Service]
Type=simple
ExecStart=/usr/local/bin/P25Gateway /etc/P25Gateway.ini
Restart=always
RestartSec=10
User=root

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/tetra-bs.service`

```ini
[Unit]
Description=TETRA Base Station (FlowStation / tetra-bluestation)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
WorkingDirectory=/opt/tetra
Environment=RUST_BACKTRACE=1
ExecStartPre=/opt/tetra/reset_usb.sh
ExecStart=/opt/tetra-src/flowstation/target/release/bluestation-bs /opt/tetra/config.toml
Restart=on-failure
RestartSec=5
User=root
Nice=-5

[Install]
WantedBy=multi-user.target
```

### `etc/systemd/system/ysfgateway.service`

```ini
[Unit]
Description=YSFGateway Service
After=network-online.target network.target mmdvm-multi.service
Wants=network-online.target mmdvm-multi.service

[Service]
Type=simple
ExecStart=/usr/local/bin/YSFGateway /etc/YSFGateway.ini
Restart=always
RestartSec=10
User=root

[Install]
WantedBy=multi-user.target
```

### `etc/YSFGateway.ini`

```ini
[General]
Callsign=NOCALL
Suffix=RPT
# Suffix=ND
Id=0000000
RptAddress=127.0.0.1
RptPort=3200
LocalAddress=127.0.0.1
LocalPort=4200
WiresXCommandPassthrough=0
Debug=0
Daemon=0

[Info]
RXFrequency=430475000
TXFrequency=439475000
Power=1
Latitude=0.0
Longitude=0.0
Height=0
Name=Nowhere
Description=Multi-Mode Repeater

[Log]
# Logging levels, 0=No logging
DisplayLevel=1
MQTTLevel=1

[APRS]
Enable=0
Description=APRS Description
Suffix=Y
# Symbol="/r"

[MQTT]
Address=127.0.0.1
Port=1883
Keepalive=60
Auth=0
Username=mmdvm
Password=mmdvm
Name=ysf-gateway

[Network]
# Startup=FCS00120
# Startup=Alabama-Link
# book DG-ID for Reflector
# Options=20;21;
InactivityTimeout=10
Reconnect=0
Revert=0
Debug=0

[YSF Network]
Enable=1
Port=42000
Hosts=/usr/local/etc/YSFHosts.json
ReloadTime=60
ParrotAddress=127.0.0.1
ParrotPort=42012
YSF2DMRAddress=127.0.0.1
YSF2DMRPort=42013
YSF2NXDNAddress=127.0.0.1
YSF2NXDNPort=42014
YSF2P25Address=127.0.0.1
YSF2P25Port=42015

[FCS Network]
Enable=1
Rooms=/usr/local/etc/FCSRooms.txt
Port=42001

[GPSD]
Enable=0
Address=127.0.0.1
Port=2947

[Remote Commands]
Enable=0
```

### `opt/fmo-bridge/config.template.json`

```json
{
  "server": {
    "callsign": "",
    "uid": 0,
    "mqtt_host": "fmo.hljrsa.cn",
    "mqtt_port": 1883,
    "reg_port": 8081,
    "certFingerprint": ""
  },
  "client": {
    "callsign": "",
    "uid": 0,
    "role": "user",
    "user_cert": "./pki/user_fm.json",
    "user_key": "./pki/user_fm.key",
    "intermediate_cert": "./pki/intermediate.json"
  },
  "fm": {
    "bind_addr": "0.0.0.0",
    "bind_port": 49880,
    "rxgain": 1.0,
    "txgain": 0.45,
    "vendor": 0,
    "debug": false,
    "dsp": {
      "enable": true,
      "hpf_hz": 180.0,
      "shelf_db": 2.5,
      "shelf_hz": 1800.0,
      "limit": 0.95,
      "expander": false,
      "expander_thr": 0.01,
      "expander_ratio": 3.0,
      "agc": true,
      "agc_target": 0.08,
      "agc_max_gain_db": 24.0,
      "agc_min_gain_db": -26.0,
      "agc_rms_ms": 120.0,
      "agc_down_ms": 12.0,
      "agc_up_ms": 600.0,
      "agc_gate": 0.004
    },
    "tone": {
      "enable": false,
      "freq": 1000.0,
      "ms": 200,
      "delay_ms": 120,
      "gain": 0.5
    },
    "ctcss": {
      "enable": true,
      "rx": true,
      "tx": true,
      "freq": 88.5,
      "rx_tone": "88.5",
      "tx_tone": "88.5"
    }
  },
  "voice": {
    "codec": "radpcm",
    "topic": "FMO/RAW",
    "relay_to_public": false
  }
}
```

### `opt/fmo-bridge/fmo_app_uplink_relay.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FMO APP uplink relay.

The phone APP sends one 80 ms PCM frame per MQTT packet under its own UID.
Real FMO devices normally receive a server stream: NOCALL/uid=487, vendor 0,
RADPCM, up to three 80 ms frames per message packet. This service converts
and aggregates APP uplink packets into that exact shape. Server-uid packets
are ignored, so the relay cannot loop into itself.
"""
import hashlib
import json
import sys
import threading
import time

sys.path.insert(0, '/opt/dmr-fmo-bridge')
import fmo_proto as FP          # noqa: E402
import fmo_packet as FPK        # noqa: E402
import fmo_voice as FV          # noqa: E402
from paho.mqtt.client import Client  # noqa: E402

BASE = '/opt/fmo-announcer'
CFG = json.load(open(BASE + '/config.json', encoding='utf-8'))
CERT = json.load(open('/opt/fmo-relay/pki/user_NOCALLRLY.json', encoding='utf-8'))
INTER = json.load(open('/opt/fmo-relay/pki/intermediate.json', encoding='utf-8'))
SEED = FP.b64u_decode(open('/opt/fmo-relay/pki/user_NOCALLRLY.key', encoding='ascii').read().strip())
SRV = CFG['server']
TOPIC = 'FMO/RAW'
SERVER_UID = int(SRV['uid'])
SERVER_CALL = str(SRV['callsign']).upper()
CLIENT_ID = 'fmo-app-uplink-relay'
MAX_FRAMES = 1
PARTIAL_MS = 180
STREAM_IDLE_S = 2.0

lock = threading.Lock()
streams = {}
seen = {}
codecs = {}
last_log = 0.0
out_count = 0
out_frames = 0
skip = 0
cli = None


def log(msg):
    print('[%s] %s' % (time.strftime('%Y-%m-%d %H:%M:%S'), msg), flush=True)


def prune_seen(now):
    for k, t in list(seen.items()):
        if now - t > 30.0:
            seen.pop(k, None)
    if len(codecs) > 128:
        codecs.clear()


def get_codec(key):
    c = codecs.get(key)
    if c is None:
        c = FV.RadPcmCodec()
        codecs[key] = c
    return c


def publish_frames(key, st, frames):
    global out_count, out_frames, last_log
    if not frames:
        return 0
    sid, uid, call = key[1], key[0], st.get('call') or ''
    out = FPK.build_voice_packet(
        uid, call, frames,
        stream_begin_utc=sid,
        ts_ms=int(st.get('last_ts') or int(time.time() * 1000)),
        srv_uid=SERVER_UID,
        vendor=0,
        smeter=0,
        seq_index0=1,
    )
    seen[hashlib.sha1(out).digest()] = time.time()
    cli.publish(TOPIC, out, qos=0)
    out_count += 1
    out_frames += len(frames)
    now = time.time()
    if out_count == 1 or now - last_log >= 5.0:
        last_log = now
        log('relayed_packets=%d frames=%d source=%s/%s sid=%s packet_frames=%d' % (
            out_count, out_frames, call, uid, sid, len(frames)))
    return len(frames)


def flush_key(key, force=False):
    st = streams.get(key)
    if not st:
        return
    frames = st['frames']
    while len(frames) >= MAX_FRAMES:
        chunk = frames[:MAX_FRAMES]
        del frames[:MAX_FRAMES]
        publish_frames(key, st, chunk)
    if force and frames:
        # Flush the final partial packet after the input stream has paused.
        chunk = frames[:]
        frames.clear()
        publish_frames(key, st, chunk)
    if not frames and force:
        streams.pop(key, None)


def janitor():
    while True:
        time.sleep(0.04)
        now = time.time()
        with lock:
            for key, st in list(streams.items()):
                if st['frames'] and now - st['last_rx'] >= PARTIAL_MS / 1000.0:
                    flush_key(key, force=True)
                elif not st['frames'] and now - st['last_rx'] > STREAM_IDLE_S:
                    streams.pop(key, None)


def on_connect(client, userdata, flags, rc):
    log('MQTT connect rc=%s' % rc)
    if rc == 0:
        client.subscribe(TOPIC, qos=0)


def on_disconnect(client, userdata, rc):
    if rc:
        log('MQTT disconnected rc=%s' % rc)


def on_message(client, userdata, msg):
    global skip
    if msg.topic != TOPIC or len(msg.payload) < 64:
        return
    try:
        pkt = FPK.parse_voice_packet(bytes(msg.payload))
    except Exception:
        return
    uid = int(pkt.get('uid') or 0)
    if uid == SERVER_UID:
        skip += 1
        return
    if not pkt.get('crc_ok'):
        return
    in_frames = pkt.get('frames') or []
    if not in_frames:
        return
    vendor = int(pkt.get('vendor') or 0)
    if vendor == 0 and not any(cm == FPK.CM_PCM for cm, _ in in_frames):
        return
    now = time.time()
    digest = hashlib.sha1(bytes(msg.payload)).digest()
    if now - seen.get(digest, 0.0) < 30.0:
        return
    seen[digest] = now
    if len(seen) > 2048:
        prune_seen(now)

    sid = int(pkt.get('streamBeginUTC') or pkt.get('timestamp') or 0)
    key = (uid, sid)
    codec = get_codec(key)
    converted = []
    for cm, payload in in_frames:
        if cm == FPK.CM_PCM:
            try:
                enc = codec.encode(payload)
            except Exception as e:
                log('RADPCM encode failed uid=%s sid=%s: %s' % (uid, sid, e))
                return
            if not enc:
                return
            converted.append((FPK.CM_RADPCM, enc))
        elif cm in (FPK.CM_RADPCM, FPK.CM_OPUS):
            converted.append((cm, payload))
        else:
            return
    with lock:
        st = streams.get(key)
        if st is None:
            st = {'frames': [], 'started': False, 'last_rx': now, 'last_ts': int(pkt.get('timestamp') or 0), 'call': str(pkt.get('callsign') or '')}
            streams[key] = st
        st['frames'].extend(converted)
        st['last_rx'] = now
        st['last_ts'] = int(pkt.get('timestamp') or int(now * 1000))
        if not st.get('started') and st['frames']:
            first = st['frames'][:1]
            del st['frames'][:1]
            publish_frames(key, st, first)
            st['started'] = True
        flush_key(key, force=False)


def main():
    global cli
    login = FP.build_mqtt_login(
        CERT, INTER, SEED, SRV['callsign'], int(SRV['uid']),
        SRV['mqtt_host'], int(SRV['mqtt_port']), SRV['certFingerprint'], role='user')
    cli = Client(client_id=CLIENT_ID, protocol=3)
    cli.username_pw_set(login['username'], login['password'])
    cli.on_connect = on_connect
    cli.on_disconnect = on_disconnect
    cli.on_message = on_message
    cli.reconnect_delay_set(min_delay=1, max_delay=10)
    threading.Thread(target=janitor, daemon=True).start()
    while True:
        try:
            cli.connect(SRV['mqtt_host'], int(SRV['mqtt_port']), 30)
            cli.loop_forever()
        except Exception as e:
            log('error: %s; reconnecting' % e)
            time.sleep(5)


if __name__ == '__main__':
    main()
```

### `opt/fmo-bridge/fmo_bridge.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""FMO 桥接网关：MMDVM-Host「FM 模式」↔ FMO 4.0 网络（MQTT/EMQX + SAS 证书认证）。

数据流：
  RF 侧 : MMDVM-Host --FM Network(UDP, 8kHz S16LE)--> 本程序 --OPUS/RADPCM--> MQTT FMO/RAW
  网络侧: MQTT FMO/RAW --解包/解码--> 本程序 --FM Network(UDP)--> MMDVM-Host

用法：python3 fmo_bridge.py config.json
"""
import hashlib
import array
import json
import os
import os
import queue
import signal
import struct
import sys
import threading
import time
import wave
from collections import deque

import paho.mqtt.client as mqtt

import fmo_proto as P
import fmo_packet as PK
import fmo_presence as PRES
from fmo_fm import FmNetwork, pcm_gain
from fmo_voice import OpusCodec, RadPcmCodec, OPUS_FRAME, RADPCM_FRAME
from fmo_dsp import DownlinkShaper, make_tone_pcm

RAW_TOPIC = "ARCH/HRC"          # 默认直连全网语音主题；若配置了 voice.topic 则用它
# 上行流的分段判据：RF 音频静默超过这个时间就结束本段流。
# 必须大于正常语音的停顿间隙，否则一句话中间就会重开流 → 对端听感断续；
# PTT 松开由 FME 显式收尾，不依赖这里。
STREAM_IDLE_S = 1.2

def log(*a):
    print(time.strftime("[%H:%M:%S]"), *a, flush=True)


class AppPcmRelay:
    TOPIC='FMO/QSO/ARCH-FMO8P'
    OUT_N=320                 # 40 ms @ 8 kHz
    PREBUFFER=3200            # 400 ms；比原来的 800 ms 少一半，端到端留出余量
    MAX_QUEUE=24000           # 3 s 硬上限，防止延迟越积越大
    RATE_MIN=296              # 每 40 ms 最少消费 296 个输入样本（约 -7.5%）
    RATE_MAX=340              # 每 40 ms 最多消费 340 个输入样本（约 +6.25%）
    RATE_SMOOTH=0.08          # 连续调节，禁止逐帧大幅变速造成音质抖动
    def __init__(self,publish_fn):
        self.publish_fn=publish_fn; self.q=deque(); self.q_n=0
        self.cv=threading.Condition(); self.stream=None
        self.uid=0; self.callsign=''; self.sid=0; self.seq=0
        self.last_rx=0.0; self.stop_flag=False
        self.last_frame=array.array('h',[0]*self.OUT_N); self.plc_gain=1.0
        self.rate=float(self.OUT_N); self.underruns=0
        self.stream_frames=0; self.stream_start=0.0
        self.rate_samples=0; self.rate_start=time.monotonic(); self.fixed_rate=False
        self.pending_key=None; self.pending_q=deque(); self.pending_n=0
        self.pending_last=0.0; self.pending_uid=0; self.pending_callsign=''
        self.pending_sid=0; self.pending_frames=0; self.pending_first_len=0
    def start(self): threading.Thread(target=self.run_player,daemon=True,name='app-pcm-relay').start()
    def stop(self):
        with self.cv:
            self.stop_flag=True; self.cv.notify_all()
    def _start_current_locked(self,key,uid,callsign,sid,first_len):
        self.q.clear(); self.q_n=0
        self.stream=key; self.uid=int(uid); self.callsign=str(callsign or 'FMO'); self.sid=int(sid)
        self.seq=0; self.plc_gain=1.0
        self.rate=float(self.RATE_MIN if first_len>=1280 else self.OUT_N)
        self.fixed_rate=(first_len<1280)
        self.underruns=0; self.stream_frames=0; self.stream_start=time.monotonic()
        self.rate_samples=0; self.rate_start=time.monotonic(); self.last_rx=time.monotonic()
    def _drop_pending_locked(self):
        self.pending_key=None; self.pending_q=deque(); self.pending_n=0
        self.pending_last=0.0; self.pending_frames=0; self.pending_first_len=0
    def _adopt_pending_locked(self):
        if self.pending_key is None or self.pending_n<=0: return False
        self.q=self.pending_q; self.q_n=self.pending_n
        self.stream=self.pending_key; self.uid=self.pending_uid; self.callsign=self.pending_callsign or 'FMO'
        self.sid=self.pending_sid; self.seq=0; self.plc_gain=1.0
        self.rate=float(self.RATE_MIN if self.pending_first_len>=1280 else self.OUT_N)
        self.fixed_rate=(self.pending_first_len<1280)
        self.underruns=0; self.stream_frames=self.pending_frames; self.stream_start=time.monotonic()
        self.rate_samples=self.pending_n; self.rate_start=time.monotonic(); self.last_rx=self.pending_last
        self.pending_key=None; self.pending_q=deque(); self.pending_n=0
        self.pending_last=0.0; self.pending_frames=0; self.pending_first_len=0
        self.cv.notify_all(); return True
    def feed(self,pcm,uid,callsign,sid):
        if not pcm: return
        key=(int(sid),int(uid)); a=array.array('h'); a.frombytes(pcm[:len(pcm)//2*2])
        if not a: return
        now=time.monotonic(); activated=False
        with self.cv:
            current_live=(self.stream is not None and self.last_rx>0 and now-self.last_rx<1.2 and self.q_n>0)
            if self.stream is None or self.q_n==0 or not current_live:
                if self.stream!=key: self._start_current_locked(key,uid,callsign,sid,len(a)); activated=True
                if self.stream_frames==0:
                    self.rate=float(self.RATE_MIN if len(a)>=1280 else self.OUT_N); self.fixed_rate=(len(a)<1280)
                self.q.append(a); self.q_n+=len(a); self.rate_samples+=len(a); self.stream_frames+=1; self.last_rx=now
            elif key==self.stream:
                if self.stream_frames==0:
                    self.rate=float(self.RATE_MIN if len(a)>=1280 else self.OUT_N); self.fixed_rate=(len(a)<1280)
                self.q.append(a); self.q_n+=len(a); self.rate_samples+=len(a); self.stream_frames+=1; self.last_rx=now
            else:
                if key!=self.pending_key or (self.pending_last>0 and now-self.pending_last>0.35):
                    self._drop_pending_locked(); self.pending_key=key; self.pending_uid=int(uid)
                    self.pending_callsign=str(callsign or 'FMO'); self.pending_sid=int(sid); self.pending_first_len=len(a)
                self.pending_q.append(a); self.pending_n+=len(a); self.pending_frames+=1; self.pending_last=now
                if self.pending_n>=self.PREBUFFER: self._adopt_pending_locked(); activated=True
            while self.q_n>self.MAX_QUEUE and self.q:
                old=self.q.popleft(); self.q_n-=len(old)
            self.cv.notify_all()
        if activated: log('APP relay stream uid=%s call=%s sid=%s'%(uid,callsign,sid))
    def _take(self,want):
        out=array.array('h')
        with self.cv:
            while len(out)<want and self.q:
                a=self.q.popleft(); self.q_n-=len(a); need=want-len(out)
                if len(a)<=need: out.extend(a)
                else:
                    out.extend(a[:need]); self.q.appendleft(a[need:]); self.q_n+=len(a)-need
        return out
    def _publish(self,frame):
        cs=(self.callsign or 'FMO').upper().encode('ascii','ignore')[:12].ljust(12,b'\x00')
        # A8P2 增加 streamBeginUTC，APP 可在快速 PTT 时准确清掉上一段尾音。
        p=bytearray(b'A8P2'); p.append(1)
        p+=(self.seq&0xffff).to_bytes(2,'big'); self.seq=(self.seq+1)&0xffff
        p+=(self.uid&0xffffffff).to_bytes(4,'big')
        p+=(self.sid&0xffffffff).to_bytes(4,'big')
        p+=cs; p+=frame.tobytes()
        try:self.publish_fn(self.TOPIC,bytes(p))
        except Exception as e:log('APP relay publish failed %r'%e)
    def run_player(self):
        nt=time.monotonic(); primed=False
        while not self.stop_flag:
            if not primed:
                with self.cv:
                    while self.q_n==0 and self.pending_n==0 and not self.stop_flag:self.cv.wait(0.5)
                    if self.pending_n>=self.PREBUFFER: self._adopt_pending_locked()
                    # 短流（尤其是 PTT 结束后的 1 帧/80ms 尾包）绝不播放：
                    # 给正常包间隔留 0.8s，凑不满 400ms 的流直接丢弃。
                    while self.q_n<self.PREBUFFER and not self.stop_flag and self.last_rx>0 and time.monotonic()-self.last_rx<0.8:self.cv.wait(0.1)
                    if self.stop_flag:return
                    if self.q_n<self.PREBUFFER:
                        self.q.clear(); self.q_n=0; self.stream=None
                        self._drop_pending_locked()
                        continue
                primed=True; nt=time.monotonic()
                log('APP relay prebuffer ready %d ms'%(self.q_n*1000//8000))
            now=time.monotonic(); active=self.last_rx>0 and now-self.last_rx<2.5
            if not active and self.q_n<self.OUT_N:
                with self.cv:
                    if self.pending_n>=self.PREBUFFER: self._adopt_pending_locked()
                    else: self.q.clear();self.q_n=0;self.stream=None
                primed=False;nt=time.monotonic()+0.04;time.sleep(0.04);continue
            buffered=self.q_n
            now_rate=time.monotonic(); rate_elapsed=now_rate-self.rate_start
            if (not self.fixed_rate) and rate_elapsed>=0.5 and self.rate_samples>0:
                measured=self.rate_samples/rate_elapsed*0.040
                self.rate_samples=0; self.rate_start=now_rate
                target=max(self.RATE_MIN,min(self.RATE_MAX,measured))
                err=buffered-self.PREBUFFER
                target=max(self.RATE_MIN,min(self.RATE_MAX,target+max(-4.0,min(4.0,err*0.0015))))
                self.rate += (target-self.rate)*0.15
            want=max(self.RATE_MIN,min(self.RATE_MAX,int(round(self.rate))))
            src=self._take(want);out=array.array('h',[0]*self.OUT_N)
            if len(src)==0:
                # 语音结束：绝不补 PLC。旧逻辑连续重复上一帧 3 次，听感就是“哒哒哒”。
                self.underruns+=1
                with self.cv:
                    self.q.clear(); self.q_n=0; self.stream=None
                    self._drop_pending_locked()
                primed=False; nt=time.monotonic()+0.04; time.sleep(0.04)
                continue
            else:
                self.plc_gain=1.0
                if len(src)==self.OUT_N:out=src
                else:
                    last=len(src)-1
                    for i in range(self.OUT_N):
                        pos=i*last/(self.OUT_N-1.0);j=int(pos);k=min(last,j+1);f=pos-j
                        out[i]=max(-32768,min(32767,int(round(src[j]*(1.0-f)+src[k]*f))))
                self.last_frame=array.array('h',out)
            self._publish(out);nt+=0.040;d=nt-time.monotonic()
            if d>0:time.sleep(d)
            elif d<-0.4:nt=time.monotonic()

class TxPacer(threading.Thread):
    """下行音频实时节拍器。

    网络语音包是 ~240ms 的突发；若整包立刻灌给 MMDVM-Host，Host→modem
    的缓冲会在两次突发之间欠载，表现为发射断续。这里按 20ms 块、固定节拍
    平滑输出，并负责 FMS/FME 的起停。
    """

    BLOCK_MS = 20
    BLOCK_BYTES = 320          # 160 样本 @8kHz S16LE
    # 严格按实时节拍（20ms 一块）。MMDVM-Host 的 FM 网络缓冲只有
    # 2000 字节 = 1000 样本 = 125ms（FMNetwork.cpp:42），快了会溢出丢音、
    # 慢了会饿死（IQ 只容忍 60ms 断流）——只能靠开头的预灌建立余量。
    PACE_MS = 20
    # 流开始时立即灌入的块数。MMDVM-Host 的 FM 网络环形缓冲只有 2000B=125ms，
    # 必须先把缓冲垫高，否则馈送节拍的微小抖动会让缓冲见底（表现为音频断续）。
    PREBUFFER_BLOCKS = 6       # 6×20ms = 120ms，贴近 Host 缓冲上限
    # 网络语音包间隔实测约 250~260ms（NOCALL 交替发 2/3 帧），
    # 静默判定必须显著大于包间隔，否则每个包之间都会 FME/FMS 反复起停 → 发射断续
    IDLE_MS = 800

    def __init__(self, send_fn, start_fn, end_fn):
        super().__init__(daemon=True)
        self._send = send_fn
        self._start = start_fn
        self._end = end_fn
        self._q = queue.Queue()
        self._stop = False
        self._active = False
        self._last = 0.0
        self._next = 0.0
        self._prefill = 0
        self.blocks = 0
        self.underruns = 0
        self.max_empty = 0.0       # 播放中缓冲最长空置时间（判饥饿）
        self._empty_since = 0.0

    def feed(self, pcm):
        if pcm:
            self._q.put(pcm)

    def active(self):
        return self._active

    def stop(self):
        self._stop = True

    def run(self):
        buf = bytearray()
        while not self._stop:
            try:
                while True:
                    buf += self._q.get_nowait()
            except queue.Empty:
                pass

            now = time.monotonic()
            if (len(buf) >= self.BLOCK_BYTES
                    and (not self._active or now >= self._next or self._prefill > 0)):
                if not self._active:
                    self._active = True
                    self._start()
                    self._next = now
                    self._prefill = self.PREBUFFER_BLOCKS
                    self._empty_since = 0.0
                if self._prefill > 0:
                    self._prefill -= 1          # 预灌期内立即补发，不按节拍
                else:
                    now = time.monotonic()
                # 每轮最多发一块，绝不补发突发
                self._send(bytes(buf[:self.BLOCK_BYTES]))
                del buf[:self.BLOCK_BYTES]
                self.blocks += 1
                self._last = now
                self._next = max(self._next, now) + self.PACE_MS / 1000.0

            now = time.monotonic()
            if self._active and not buf:
                if self._empty_since == 0.0:
                    self._empty_since = now
            elif buf:
                # ARCH 2026-09-19：只在「断流后又重新收到数据」时统计饥饿时长。
                # 原来的写法把流末尾的收尾空档也算进去，使得每次统计恒为 800ms，
                # 无法判断流中间到底有没有断音。
                if self._empty_since:
                    gap = now - self._empty_since
                    if gap > self.max_empty:
                        self.max_empty = gap
                    if gap > 0.060:
                        self.underruns += 1
                    self._empty_since = 0.0

            if self._active and (now - self._last) > self.IDLE_MS / 1000.0:
                self._end()
                self._active = False
                buf.clear()
                self._empty_since = 0.0

            # 精确睡到下一块到期（上限 20ms）；空闲时短睡以便及时唤醒
            if self._active:
                dt = self._next - time.monotonic()
                time.sleep(0.0005 if dt <= 0 else min(dt, 0.02))
            else:
                time.sleep(0.003)

class Bridge:
    def __init__(self, cfg):
        self.cfg = cfg
        c = cfg["client"]
        self.uid = int(c["uid"])
        self.callsign = str(c["callsign"]).upper()
        self.vendor = int(cfg.get("fm", {}).get("vendor", PK.DEFAULT_VENDOR))
        self.codec_mode = cfg.get("voice", {}).get("codec", "opus").lower()
        # 语音主题：默认直连 FMO/RAW；设为 FMO-BRIDGE/RAW 时由服务器侧中继
        # 改写身份后转出（真实设备只信任官方根，故服务器侧改写是必要的一步）
        self.topic = cfg.get("voice", {}).get("topic") or RAW_TOPIC
        # 是否同时以「服务器本台身份」把上行音频转发到全网 FMO/RAW
        self.relay_out = bool(cfg.get("voice", {}).get("relay_to_public", True))
        self.relay_pkts = 0
        self.relayed_sb = {}          # 本机转出过的 streamBeginUTC → 时间（下行去重用）
        # 下行收包统计（诊断丢包）
        self.rx_msgs = 0
        self.rx_drop_own = 0
        self.rx_drop_self = 0
        self.rx_drop_bad = 0
        self.rx_drop_busy = 0
        self.rx_drop_dup = 0
        self._dedup = {}
        self.rx_frames = 0
        self.cap_n = 0
        self._stat_t = 0.0

        # 无本地证书时自动向服务器注册（方案 C）
        reg = c.get("register_url") or cfg.get("server", {}).get("register_url")
        if reg:
            try:
                import fmo_reg
                srv_info, _, _ = fmo_reg.ensure_cert(
                    reg, self.callsign, self.uid,
                    c["user_cert"], c["user_key"], c["intermediate_cert"],
                    token=c.get("register_token"), log=log)
                if srv_info:
                    s = cfg["server"]
                    s["callsign"] = srv_info.get("callsign", s.get("callsign"))
                    s["uid"] = srv_info.get("uid", s.get("uid"))
                    s["certFingerprint"] = srv_info.get("certFingerprint",
                                                        s.get("certFingerprint"))
                    if not s.get("mqtt_host"):
                        s["mqtt_host"] = srv_info.get("mqttHost")
                    if not s.get("mqtt_port"):
                        s["mqtt_port"] = srv_info.get("mqttPort", 1883)
                    log("已从服务器取得身份：%s uid=%s" % (s["callsign"], s["uid"]))
            except Exception as e:
                log("自动注册未完成（改用本地已有证书）：%s" % e)

        with open(c["user_cert"], encoding="utf-8") as f:
            self.user_cert = json.load(f)
        with open(c["intermediate_cert"], encoding="utf-8") as f:
            self.inter_cert = json.load(f)
        with open(c["user_key"], encoding="ascii") as f:
            self.user_seed = P.b64u_decode(f.read().strip())
        # uid 一律以证书为准：FAS 审计服务会封禁「同一 uid 多处登录」，
        # 配置与证书不一致会导致封号
        self.uid = int(self.user_cert["subject"]["uid"])

        self.codec = OpusCodec() if self.codec_mode == "opus" else RadPcmCodec()
        self.frame_samples = OPUS_FRAME if self.codec_mode == "opus" else RADPCM_FRAME
        self.frame_ms = self.frame_samples * 1000 // 8000     # OPUS 40ms / RADPCM 80ms
        self.cm = PK.CM_OPUS if self.codec_mode == "opus" else PK.CM_RADPCM
        # 下行必须按对端实际编码解码，故两种解码器都常驻
        self.opus = OpusCodec()
        self.radpcm = RadPcmCodec()

        # 在线宣告（真实设备靠 FMO/TELE 感知在线用户）
        # 注意：午夜实测可用时**没有**发 TELE；若开启后干扰对端播放，可通过配置关闭
        self.presence_on = bool(cfg.get("voice", {}).get("presence", False))
        # §8.4 路由仲裁：午夜可用时没有实现；实现不当会误拒真实设备的包（表现为断续）
        self.arbitration_on = bool(cfg.get("voice", {}).get("arbitration", False))
        self.session = int(time.time() * 1000) & 0xFFFFFFFF
        self.late_seq = 0
        self.stop_flag = False
        # §8.4 网络路由仲裁状态
        self.route_uid = None
        self.route_sb = 0
        self.route_at = 0.0
        # 上行（RF -> 网络）
        self.rx_buf = bytearray()
        # 上行速率计量（诊断 FM -> 网络 是否按实时速率送音频）
        self.rf_pkts = 0
        self.rf_samples = 0
        self.rf_rate_t0 = time.time()
        self.rf_gap_max = 0.0
        self.rf_last_t = 0.0
        self.frames = []           # [(cm, payload), ...]
        self.stream_begin = 0
        self.seq = 0
        self.tx_stream_on = False

        self.last_voice = 0.0
        self.last_net_voice = 0.0
        # 下行（网络 -> RF）
        self.rf = None
        self.pacer = None          # 下行实时节拍器（run() 里创建）
        # 下行音频整形（见 fmo_dsp.py；参数在 config.json 的 fm.dsp）
        self.shaper = DownlinkShaper(8000, cfg.get("fm", {}).get("dsp") or {})
        self.nw_playing = False

        self.nw_frames = 0
        self.rf_busy_until = 0.0   # 半双工：本地正在发就不收网络
        self.net_guard_until = 0.0 # 网络正在放音/刚放完时抑制 SDR 自回声
        self.agg_ms = 0            # 上行聚合计时（毫秒）
        # 断续诊断
        self.rx_last = 0.0         # 上行：上一个 FMD 到达时刻
        self.rx_max_gap = 0.0
        self.tx_pkts = 0           # 上行：本流已发布的消息包/帧
        self.tx_frames = 0
        self.nw_last = 0.0         # 下行：上一个网络包到达时刻
        self.nw_max_gap = 0.0
        # 上行音频抓取（诊断用，fm.capture 指定路径时启用）
        self.cap_path = self.cfg.get("fm", {}).get("capture") or ""
        self.cap_w = None
        self.cap_bytes = 0
        if self.cap_path:
            try:
                self.cap_w = wave.open(self.cap_path, "wb")
                self.cap_w.setnchannels(1)
                self.cap_w.setsampwidth(2)
                self.cap_w.setframerate(8000)
                log("上行音频抓取 ->", self.cap_path)
            except Exception as e:
                log("上行音频抓取开启失败：%s" % e)
                self.cap_w = None
        # 下行音频抓取（诊断用）：记录实际送给 Host 的 PCM（裸 PCM，避免 wave 模块问题）
        self.cap_tx_path = self.cfg.get("fm", {}).get("capture_tx") or ""
        self.cap_tx = None
        # ---- 通联日志（供 Web「Live QSO」看板显示呼号）----
        self.qso_ch = 0       # 显示/QSO 日志按当前规划动态取通道，不保存绑定。
        self.nw_start = 0.0       # 网络流开始时刻
        self.nw_call = ""        # 网络流说话人呼号
        self.nw_uid = 0
        self.tx_start = 0.0       # RF 流开始时刻

        if self.cap_tx_path:
            try:
                self.cap_tx = open(self.cap_tx_path, "wb")
                log("下行音频抓取（裸 PCM @8kHz s16le）->", self.cap_tx_path)
            except Exception as e:
                log("下行音频抓取开启失败：%s" % e)
                self.cap_tx = None

        s = cfg["server"]
        self.mqtt = mqtt.Client(client_id="fmo-bridge-" + self.callsign,
                                clean_session=True, protocol=mqtt.MQTTv311)
        self._refresh_login()
        self.mqtt.on_connect = self._on_connect
        self.mqtt.on_message = self._on_message
        self.mqtt.on_disconnect = self._on_disconnect
        self.app_relay = AppPcmRelay(lambda topic,payload: self.mqtt.publish(topic,payload,qos=0))

    # --------------------------------------------- §8.4 网络路由仲裁（协议强制）
    ROUTE_WINDOW_MS = 1500

    def _route_accept(self, uid, stream_begin):
        """按 FMO 协议 §8.4 判定该语音包是否有权占用网络路由。

        规则：同 UID 续占；路由超时(1500ms)新包占用；
             新包 streamBeginUTC 更早(差值≤2s) 抢占；相同且 UID 更小 抢占；否则拒绝。
        """
        now = time.time() * 1000.0
        if (self.route_uid is not None
                and (now - self.route_at) <= self.ROUTE_WINDOW_MS):
            if uid == self.route_uid:
                self.route_at = now
                return True
            diff = (self.route_sb - stream_begin) & 0xFFFFFFFF
            if stream_begin != self.route_sb and 0 < diff <= 2000:
                log("路由被抢占：uid=%d 取代 uid=%d（起始更早 %dms）"
                    % (uid, self.route_uid, diff))
                self.route_uid, self.route_sb, self.route_at = uid, stream_begin, now
                return True
            if stream_begin == self.route_sb and uid < self.route_uid:
                log("路由被抢占：uid=%d 取代 uid=%d（起始相同，UID 更小）"
                    % (uid, self.route_uid))
                self.route_uid, self.route_sb, self.route_at = uid, stream_begin, now
                return True
            return False
        self.route_uid, self.route_sb, self.route_at = uid, stream_begin, now
        return True

    def _route_free(self):
        """本机上行结束：让出路由，供他人使用。"""
        self.route_uid = None
        self.route_sb = 0
        self.route_at = 0.0

    # ------------------------------------------------------------- MQTT
    def _refresh_login(self):
        """(重新)生成 MQTT 登录凭据。

        登录密码里含有时效性 timestamp，SAS 有 TtlSec 窗口；
        paho 自动重连会复用旧凭据 → 超窗后被 deny(rc=5)，故每次重连前必须刷新。
        """
        s = self.cfg["server"]
        c = self.cfg["client"]
        login = P.build_mqtt_login(
            self.user_cert, self.inter_cert, self.user_seed,
            server_callsign=s["callsign"], server_uid=int(s["uid"]),
            mqtt_host=s["mqtt_host"], mqtt_port=int(s["mqtt_port"]),
            server_fingerprint_b64u=s["certFingerprint"],
            role=c.get("role", "user"))
        self.mqtt.username_pw_set(login["username"], login["password"])
        return login

    def _on_connect(self, cli, userdata, flags, rc):
        if rc == 0:
            # 订阅与真实 FMO 设备一致的主题集合
            for t in PRES.USER_SUBSCRIPTIONS:
                cli.subscribe(t, qos=0)
            if self.topic != "ARCH/HRC":
                cli.subscribe("ARCH/HRC", qos=0)   # 下行：直接收全网语音
                cli.subscribe(self.topic, qos=0)
            log("MQTT 已连接；下行主题=FMO/RAW，中转主题=%s" % self.topic)
            self._send_presence()          # 立即宣告一次"我在线"
        else:
            log("MQTT 连接失败 rc=%s（SAS 可能 deny：检查证书/指纹/时间）" % rc)

    def _active_fm_slot(self):
        """实时返回当前已启用的 FM 通道号和频率，不保存固定通道。"""
        try:
            with open("/etc/mmdvm/channels.json", encoding="utf-8") as f:
                plan = json.load(f)
            limit = max(1, min(16, int(plan.get("channels") or 1)))
            for item in plan.get("list", [])[:limit]:
                if str(item.get("mode", "")).upper() == "FM":
                    return (int(item.get("n") or 0),
                            float(item.get("rx") or 0) / 1e6,
                            float(item.get("tx") or 0) / 1e6)
        except Exception:
            pass
        fmc = self.cfg.get("fm", {})
        return (int(fmc.get("channel") or 0),
                float(fmc.get("rx_mhz", 0.0)),
                float(fmc.get("tx_mhz", 0.0)))

    def _active_fm_freqs(self):
        _n, rx_mhz, tx_mhz = self._active_fm_slot()
        return rx_mhz, tx_mhz

    # ----------------------------------------------------- 在线宣告（关键）
    def _send_presence(self):
        """发 FMO/TELE 遥测/在线宣告（默认关闭，见 voice.presence）。"""
        if not self.presence_on:
            return
        rx_mhz, tx_mhz = self._active_fm_freqs()
        tele = PRES.build_tele(self.uid, self.callsign,
                               tx_mhz=tx_mhz,
                               rx_mhz=rx_mhz,
                               session=self.session)
        try:
            self.mqtt.publish("FMO/TELE", tele, qos=0)
            self.mqtt.publish("FMO/LATE/UID_V1/%d" % self.uid,
                              PRES.build_late(0, self.late_seq, 0), qos=0)
            self.late_seq = (self.late_seq + 1) & 0xFFFFFFFF
        except Exception as e:
            log("宣告发送失败：%s" % e)

    def _presence_loop(self):
        while not self.stop_flag:
            time.sleep(30.0)
            if self.mqtt is not None:
                self._send_presence()

    def _cred_loop(self):
        """高频刷新登录凭据。

        SAS 的凭据有效期只有约 60 秒（其启动日志明确写着
        「Token 过期时间随机抖动(±60s, 避免惊群)」）。若刷新间隔与有效期相当，
        重连时凭据往往刚好过期 → 永久 rc=5。故这里每 15 秒重建一次。
        """
        while not self.stop_flag:
            time.sleep(15.0)
            try:
                self._refresh_login()
            except Exception as e:
                log("刷新登录凭据失败：%s" % e)

    def _on_disconnect(self, cli, userdata, rc):
        log("MQTT 断开 rc=%s；刷新登录凭据供重连" % rc)
        try:
            self._refresh_login()
        except Exception as e:
            log("刷新登录凭据失败：%s" % e)

    def _on_message(self, cli, userdata, msg):
        self.rx_msgs += 1
        if msg.topic not in ("ARCH/HRC", self.topic):
            return
        # 丢弃自己转出的包（uid=服务器 且 streamBeginUTC 命中），否则会自激回环
        if self._is_own_relay(msg.payload):
            self.rx_drop_own += 1
            return
        try:
            pkt = PK.parse_voice_packet(msg.payload)
        except Exception as e:
            self.rx_drop_bad += 1
            if self.rx_drop_bad <= 5:
                log("解包失败:", e)
            return
        if pkt["uid"] == self.uid:
            self.rx_drop_self += 1
            return
        # ARCH 2026-09-19：同一条语音流可能同时出现在 ARCH/HRC 与 FMO/RAW
        # （服务器侧镜像或双发），按报文内容去重，避免重复灌入节拍器 → 叠音
        now = time.time()
        key = hashlib.sha1(msg.payload).digest()
        seen = self._dedup.get(key)
        if seen is not None and (now - seen) < 2.0:
            self.rx_drop_dup += 1
            return
        self._dedup[key] = now
        if len(self._dedup) > 4000:
            cut = now - 5.0
            self._dedup = {k: v for k, v in self._dedup.items() if v >= cut}
        self._on_net_voice(pkt)

    # ------------------------------------------------------------- 上行 RF->网络
    def on_rf_audio(self, pcm):
        now = time.time()
        # Half-duplex/self-echo guard: while a network stream is actively being
        # played to RF, the SDR may hear our own transmission and return it as
        # RF audio. Never feed that echo back into the FMO room.
        if (self.nw_playing and (now - self.last_net_voice) < 0.8) or now < self.net_guard_until:
            self.rx_drop_busy += 1
            return
        if self.cap_w is not None:
            try:
                self.cap_w.writeframes(pcm)
                self.cap_bytes += len(pcm)
                if self.cap_bytes > 8000 * 2 * 180:      # 上限 180 秒
                    self.cap_w.close()
                    log("上行音频抓取已达上限，停止写入 %s" % self.cap_path)
                    self.cap_w = None
            except Exception:
                self.cap_w = None
        self.rf_pkts += 1
        self.rf_samples += len(pcm) // 2
        if self.rf_last_t > 0:
            g = now - self.rf_last_t
            if g > self.rf_gap_max:
                self.rf_gap_max = g
        self.rf_last_t = now
        if self.rx_last:
            gap = now - self.rx_last
            if gap > self.rx_max_gap:
                self.rx_max_gap = gap
        self.rx_last = now
        if now - self.last_voice > STREAM_IDLE_S:
            self._begin_stream()
        self.last_voice = now
        self.rf_busy_until = now + 1.0        # §8.3 半双工：当前方向空闲超过 1s 才允许反向
        self.rx_buf += pcm_gain(pcm, float(self.cfg.get("fm", {}).get("rxgain", 1.0)))

        need = self.frame_samples * 2
        while len(self.rx_buf) >= need:
            chunk = bytes(self.rx_buf[:need])
            del self.rx_buf[:need]
            self.frames.append((self.cm, self.codec.encode(chunk)))
            self.agg_ms += self.frame_ms
            # 再凑一帧就超过 250ms 就发（RADPCM 3帧=240ms，OPUS 6帧=240ms）
            # 必须与对端(NOCALL)一致：240ms 一发，否则接收端抖动缓冲会欠载 → 断续
            if self.agg_ms + self.frame_ms > PK.AGG_MS:
                self._flush_frames()
                self.agg_ms = 0

    def _begin_stream(self):
        self.flush_stream()
        self.stream_begin = int(time.time() * 1000) & 0xFFFFFFFF
        self.seq = 0
        self.agg_ms = 0
        self.tx_stream_on = True
        self.tx_start = time.time()
        self._qso_log("rf", self.callsign, "CQCQCQ")   # 本台开始发射

        log("RF 开始 -> 网络流（codec=%s vendor=0x%04X）"
            % (self.codec_mode, self.vendor))

    def _relay_out(self, blob):
        """把上行包以「服务器本台身份」改写后发到全网 FMO/RAW。

        真实 FMO 设备只信任能链到官方根的用户证书，我们自签身份不会被它们播放；
        按官方《语音数据开放协议》「服务器管理员对桥接输入源负责」及使用说明书中
        「部分服务器可能桥接了非 FMO 的音频源或呼号输入」，由服务器台以本台身份
        转出是被认可的用法。改写只动头部，CRC32 仅覆盖帧区，无需重算。

        关键：vendor 也要写成 0x0000（FMO 官方值）。协议规定 vendor 的用途是
        「让大家都遵守同一种语音编码规则」，0x1000~0x2FFF 的试验/软件区只是
        开发调试用；真实设备对软件区厂商的流不予播放（实测：会提示接收但不放音）。
        """
        s = self.cfg["server"]
        b = bytearray(blob)
        struct.pack_into("<I", b, 2, 0)                       # vendor = 0x0000（FMO）
        struct.pack_into("<I", b, 6, int(s["uid"]) & 0xFFFFFFFF)
        b[10:22] = str(s["callsign"]).upper().encode("ascii")[:12].ljust(12, b"\x00")
        struct.pack_into("<I", b, 41, int(s["uid"]) & 0xFFFFFFFF)
        out = bytes(b)
        # 记录本次转出的流标识，供下行去重（防回环）
        sb = struct.unpack_from("<I", out, 22)[0]
        self.relayed_sb[sb] = time.time()
        self.mqtt.publish("ARCH/HRC", out, qos=0)
        self.relay_pkts += 1

    def _is_own_relay(self, payload):
        """判断该下行包是否就是我们自己刚转出的（uid=服务器 且 streamBeginUTC 命中）。"""
        if len(payload) < 26:
            return False
        if struct.unpack_from("<I", payload, 6)[0] != int(self.cfg["server"]["uid"]):
            return False
        sb = struct.unpack_from("<I", payload, 22)[0]
        t = self.relayed_sb.get(sb)
        if t is not None and (time.time() - t) < 30.0:
            return True
        return False

    def _flush_frames(self):
        if not self.frames:
            return
        pkts = PK.chunk_frames(self.frames)
        self.frames = []
        for fr in pkts:
            blob = PK.build_voice_packet(self.uid, self.callsign, fr,
                                         stream_begin_utc=self.stream_begin,
                                         srv_uid=int(self.cfg["server"]["uid"]),
                                         vendor=self.vendor,
                                         seq_index0=1)   # 官方实现：每个消息包内序号从 1 起
            self.seq += len(fr)
            self.mqtt.publish(self.topic, blob, qos=0)
            if self.relay_out:
                try:
                    self._relay_out(blob)      # 同时以服务器本台身份发到全网
                except Exception as e:
                    log("转发到全网失败（不影响本地路径）：%s" % e)
            self.tx_pkts += 1
            self.tx_frames += len(fr)

    def flush_stream(self):
        if self.frames:
            self._flush_frames()
        if self.tx_stream_on:
            log("RF 流收尾：发布 %d 包 / %d 帧，最大到达间隙 %.0fms"
                % (self.tx_pkts, self.tx_frames, self.rx_max_gap * 1000.0))
        self.tx_stream_on = False
        self.tx_pkts = 0
        if self.tx_start > 0:
            self._qso_log("rf", self.callsign, "CQCQCQ",
                          max(0.1, time.time() - self.tx_start))
            self.tx_start = 0.0
        self.tx_frames = 0
        self.rx_max_gap = 0.0
        self.rx_buf.clear()
        self._route_free()                 # §8.4 结束上行即让出路由

    def tick(self):
        if self.tx_stream_on and (time.time() - self.last_voice) > STREAM_IDLE_S:
            self.flush_stream()

    # ------------------------------------------------------------- 下行 网络->RF
    def _decode_any(self, cm, payload):
        """按帧自带的 compressMode 解码（对端可能是 OPUS/RADPCM/PCM 任意一种）。"""
        try:
            if cm == PK.CM_OPUS:
                return self.opus.decode(payload)
            if cm == PK.CM_RADPCM:
                return self.radpcm.decode(payload)
            if cm == PK.CM_PCM:
                return payload
        except Exception as e:
            log("解码失败 cm=%d len=%d: %s" % (cm, len(payload), e))
        return b""

    def _on_net_voice(self, pkt):
        if self.rf is None:
            return
        if time.time() < self.rf_busy_until:      # 半双工：本地正在发就不收网络
            self.nw_last = 0.0                    # 避免把本地发射期间算作网络间隙
            self.rx_drop_busy += 1
            return
        # §8.4 网络路由仲裁（默认关闭，见 voice.arbitration）
        if self.arbitration_on and not self._route_accept(pkt["uid"], pkt["streamBeginUTC"]):
            return
        cs = pkt["callsign"]
        if not self.nw_playing:
            self.nw_playing = True
            self.nw_start = time.time()
            self.nw_last = 0.0          # 新流：重置到达间隙统计
            self.nw_max_gap = 0.0
            self.nw_call = cs
            self._qso_log("network", cs, "CQCQCQ")   # 对方开始说话

            log("网络 -> RF: %s uid=%d cm=%s frames=%d"
                % (cs, pkt["uid"], sorted(set(cm for cm, _ in pkt["frames"])),
                   len(pkt["frames"])))
        self.last_net_voice = time.time()
        self.net_guard_until = self.last_net_voice + 1.5
        now = self.last_net_voice
        if self.nw_last:
            gap = now - self.nw_last
            if gap > self.nw_max_gap:
                self.nw_max_gap = gap
        self.nw_last = now
        gain = float(self.cfg.get("fm", {}).get("txgain", 1.0))
        for cm, payload in pkt["frames"]:
            pcm = self._decode_any(cm, payload)
            if pcm:
                self.app_relay.feed(pcm, pkt['uid'], cs, pkt['streamBeginUTC'])
                # 必须交给节拍器平滑输出：直接整包灌给 Host 会撑爆其 125ms 环形缓冲
                # （FMNetwork.cpp:42 m_buffer(2000U)），导致音频被丢弃 → 断续
                pcm = self.shaper.process(pcm_gain(pcm, gain))   # 下行音频整形
                if self.pacer is not None:
                    self.pacer.feed(pcm)
                else:
                    self.rf.send_audio(pcm)
                self.nw_frames += 1
                self.rx_frames += 1

    def _on_rf_end(self):
        self.flush_stream()
        log("RF END")
        self._courtesy_tone()

    def _courtesy_tone(self):
        """尾音确认音：对方松开 PTT（Host 发 FME）后回一段短促音。
        走同一个 TxPacer，因此 FMS/FME 与节拍都由它统一负责。"""
        return  # ARCH: 1000Hz 尾音已改为热点本地功能，93 不再发送
        t = (self.cfg.get("fm", {}) or {}).get("tone") or {}
        if not t.get("enable") or self.pacer is None:
            return
        def _play():
            d = float(t.get("delay_ms", 120)) / 1000.0
            if d > 0:
                time.sleep(d)
            # 网络侧正在放音就让路，避免抢信道
            if self.nw_playing and (time.time() - self.last_net_voice) < 0.8:
                return
            try:
                self.pacer.feed(make_tone_pcm(float(t.get("freq", 1000.0)),
                                             int(t.get("ms", 200)),
                                             float(t.get("gain", 0.5))))
                log("尾音确认音 %dHz %dms（对方已收到）"
                    % (int(t.get("freq", 1000)), int(t.get("ms", 200))))
            except Exception as e:
                log("尾音发送失败：%s" % e)
        threading.Thread(target=_play, daemon=True).start()

    def _qso_log(self, src, call, target, seconds=None, extra=""):
        """按 MMDVM-Host 的格式打一条通联日志，供 Web「Live QSO」解析。

        格式必须同时满足两处：
          · mmdvm-status.py 的关键字过滤：含 'received network voice header'
            / 'received rf voice header'（连在一起的固定词组）
          · app.py 的 parse_qso_line：含 'fm' → 模式 FM；'from <呼号>'；
            'N seconds'；'\\s*\\[CH(\\d)\\]\\s*' 通道前缀
        且「开始通话」那一行绝对不能再带 “N seconds”，
        否则 parse_qso_line 会把它当成已结束，通联列表不会变红。
        故输出形如：
          开始：[CH1] 2026-09-12 09:30:15 FM received network voice header "
                "from NOCALL to CQCQCQ
          结束：[CH1] 2026-09-12 09:30:20 FM received network voice header "
                "from NOCALL to CQCQCQ, 5.2 seconds
        """
        try:
            ts = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())
            who = "network" if src == "network" else "rf"
            n, _rx, _tx = self._active_fm_slot()
            n = n or self.qso_ch or 1
            if seconds is None:
                # ★ 开始通话：不带时长 = 前端判为“通话中”（红色高亮）
                log("[CH%d] %s FM received %s voice header from %s to %s%s"
                    % (n, ts, who, call or "UNKNOWN",
                       target or "CQCQCQ", extra))
            else:
                log("[CH%d] %s FM received %s voice header from %s to %s, %.1f seconds%s"
                    % (n, ts, who, call or "UNKNOWN",
                       target or "CQCQCQ", max(0.1, seconds), extra))
        except Exception as e:
            log("通联日志写入失败：%s" % e)

    def _check_tone_req(self):
        """Web 页「试听尾音」：读请求文件内容，与上次不同就发一声。

        不用删除文件：桥接以 pi 身份运行，而请求文件由 root 写进 /tmp
        （/tmp 带 sticky 位），pi 无权 unlink，只能靠内容比对。
        """
        return  # ARCH: 1000Hz 尾音已改为热点本地功能，93 不再发送
        if self.pacer is None:
            return
        try:
            with open("/tmp/fmo_tone_req") as f:
                v = f.read().strip()
        except Exception:
            return
        if not v or v == getattr(self, "_tone_seen", None):
            return
        self._tone_seen = v
        t = dict((self.cfg.get("fm", {}) or {}).get("tone") or {})
        try:
            self.pacer.feed(make_tone_pcm(float(t.get("freq", 1000.0)),
                                         int(t.get("ms", 200)),
                                         float(t.get("gain", 0.5))))
            log("试听尾音已发送（%dHz %dms）"
                % (int(t.get("freq", 1000)), int(t.get("ms", 200))))
        except Exception as e:
            log("试听尾音失败：%s" % e)

    def _net_idle(self):
        # FMS/FME 由 TxPacer 负责；这里只做收尾统计
        if (self.nw_playing and (self.pacer is None or not self.pacer.active())
                and (time.time() - self.last_net_voice) > TxPacer.IDLE_MS / 1000.0):
            self.nw_playing = False
            if self.nw_start > 0:
                self._qso_log("network", self.nw_call, "CQCQCQ",
                              max(0.1, time.time() - self.nw_start))
                self.nw_start = 0.0
            log("网络流结束：解码 %d 帧，最大到达间隙 %.0fms，流中断音 %.0fms（%d 次）"
                % (self.nw_frames, self.nw_max_gap * 1000.0,
                   (self.pacer.max_empty * 1000.0) if self.pacer else 0.0, self.pacer.underruns if self.pacer else 0))
            if self.pacer:
                self.pacer.max_empty = 0.0
                self.pacer.underruns = 0
            self.nw_frames = 0
            self.nw_max_gap = 0.0

    # ------------------------------------------------------------- 运行
    def _rf_rate_report(self):
        now = time.time()
        el = now - self.rf_rate_t0
        if el < 5.0:
            return
        if self.rf_pkts > 0:
            audio_ms = self.rf_samples * 1000.0 / 8000.0
            pct = audio_ms / (el * 1000.0) * 100.0
            log("[上行速率] %d 包 / %.2fs 音频 / %.1fs 墙钟 = %.0f%%  最大块间隔 %.0fms"
                % (self.rf_pkts, audio_ms / 1000.0, el, pct, self.rf_gap_max * 1000.0))
        self.rf_pkts = 0
        self.rf_samples = 0
        self.rf_gap_max = 0.0
        self.rf_rate_t0 = now

    def run(self):
        fmc = self.cfg["fm"]
        self.rf = FmNetwork(fmc.get("bind_addr", "127.0.0.1"),
                            int(fmc["bind_port"]),
                            callsign=self.callsign,
                            on_audio=self.on_rf_audio,
                            on_start=lambda cs: log("RF START from", cs),
                            on_end=self._on_rf_end,
                            debug=bool(fmc.get("debug", False)))
        self.rf.open().start_thread()

        def _send_block(pcm):
            if self.cap_tx is not None:
                try:
                    self.cap_tx.write(pcm)
                    self.cap_tx.flush()
                    self.cap_n += 1
                except Exception as e:
                    log("下行抓取写入失败：%s" % e)
                    self.cap_tx = None
            elif self.cap_n == 0:
                self.cap_n = -1
                log("注意：下行抓取未启用（cap_tx 为空）")
            self.rf.send_audio(pcm)

        self.pacer = TxPacer(_send_block, self.rf.send_start, self.rf.send_end)
        self.pacer.start()
        log("FM Network 监听 %s:%s（下行按 20ms 块实时节拍输出）"
            % (fmc.get("bind_addr", "127.0.0.1"), fmc["bind_port"]))

        s = self.cfg["server"]
        log("连接 MQTT %s:%s (server=%s uid=%s)" % (s["mqtt_host"], s["mqtt_port"],
                                                   s["callsign"], s["uid"]))
        self.mqtt.connect(s["mqtt_host"], int(s["mqtt_port"]), keepalive=30)
        self.mqtt.loop_start()
        self.app_relay.start()

        threading.Thread(target=self._presence_loop, daemon=True).start()
        threading.Thread(target=self._cred_loop, daemon=True).start()
        _rx_mhz, _tx_mhz = self._active_fm_freqs()
        log("在线宣告已启动（FMO/TELE 每 30s，频率 TX=%s/RX=%s MHz）"
            % (_tx_mhz, _rx_mhz))

        while True:
            time.sleep(0.05)
            self.tick()
            self._net_idle()
            self._rf_rate_report()
            self._check_tone_req()
            now = time.time()
            if now - self._stat_t >= 30.0:
                self._stat_t = now
                log("[下行统计] 收到 %d 包 → 解码 %d 帧；丢弃 本机中转 %d / 本机自己 %d "
                    "/ 解包失败 %d / 半双工 %d / 重复 %d"
                    % (self.rx_msgs, self.rx_frames, self.rx_drop_own,
                       self.rx_drop_self, self.rx_drop_bad, self.rx_drop_busy, self.rx_drop_dup))
                # AGC / 限幅统计（判断音频电平是否被拉平）
                try:
                    _st = self.shaper.stats()
                    log("[音频AGC] 输入峰值 %.2f → 输出峰值 %.2f；输出 RMS %.1f%%；"
                        "AGC 增益 %.2f（最低 %.2f）；限幅触发 %d 次"
                        % (_st["pk_in"], _st["pk_out"], _st["rms_out"] * 100.0,
                           _st["gain_now"], _st["gain_min"], _st["clip"]))
                    self.shaper.reset_stats()
                except Exception as e:
                    log("AGC 统计失败：%s" % e)

def main():
    cfg_path = sys.argv[1] if len(sys.argv) > 1 else os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "config.json")
    with open(cfg_path, encoding="utf-8") as f:
        cfg = json.load(f)
    b = Bridge(cfg)

    def _stop(*_):
        log("退出中…")
        try:
            b.flush_stream()
            b.stop_flag = True
            if b.pacer:
                b.pacer.stop()
            if b.cap_w:
                b.cap_w.close()
            if b.cap_tx:
                b.cap_tx.close()
            b.mqtt.loop_stop()
            b.rf.close()
        finally:
            sys.exit(0)

    signal.signal(signal.SIGINT, _stop)
    signal.signal(signal.SIGTERM, _stop)
    b.run()

if __name__ == "__main__":
    main()
```

### `opt/fmo-bridge/fmo_dsp.py`

```python
# -*- coding: utf-8 -*-
"""下行音频整形（可开关 / 可调参）。

背景：FMO 网络语音是低码率编码（RADPCM 4bit IMA ADPCM 或 OPUS），解码出来的
PCM 带明显量化噪声和低频隆隆声。直接送 FM 调制时听感偏「沙 / 哑」。
这里做四件温和的事，全部可关：

  1) 高通去隆隆（默认 180Hz）—— 低频对可懂度贡献很小，却白占频偏
  2) 高频临场补偿（默认 +2.5dB @ 1.8kHz 一阶搁架）—— 抵消链路/手台去加重的钝感
  3) 软限幅（默认 -1.5dBFS）—— 保证不撞 Host 里 12bit 的硬上限
  4) 向下扩展「噪声门」（默认关）—— 压制音节之间的编码底噪

所有处理都是流式的（保留滤波器状态），可以逐 20ms 块调用。
"""
import math

S16_MAX = 32767.0


def make_tone_pcm(freq=1000.0, ms=200, gain=0.5, sr=8000, fade_ms=8):
    """生成一段正弦确认音（尾音），带短淡入淡出避免起止爆音。"""
    n = int(sr * ms / 1000.0)
    nf = max(1, int(sr * fade_ms / 1000.0))
    out = bytearray()
    amp = max(0.0, min(1.0, gain)) * S16_MAX
    for i in range(n):
        v = math.sin(2.0 * math.pi * freq * i / sr) * amp
        # 淡入淡出
        if i < nf:
            v *= i / float(nf)
        elif i > n - nf:
            v *= (n - i) / float(nf)
        iv = int(v)
        iv = max(-32768, min(32767, iv))
        out += int(iv).to_bytes(2, "little", signed=True)
    return bytes(out)


class DownlinkShaper:
    """下行 PCM 整形器。参数全部可调，默认都是温和值。"""

    def __init__(self, sr=8000, cfg=None):
        c = cfg or {}
        self.enabled = bool(c.get("enable", True))
        self.sr = sr

        # 1) 一阶高通：y[n] = a*(y[n-1] + x[n] - x[n-1])
        self.hpf_hz = float(c.get("hpf_hz", 180.0))
        if self.hpf_hz > 0:
            self._a_hp = 1.0 / (1.0 + 2.0 * math.pi * self.hpf_hz / sr)
        else:
            self._a_hp = 0.0

        # 2) 一阶高频搁架：y = x + g*(x - lopass(x))
        self.shelf_db = float(c.get("shelf_db", 2.5))
        self.shelf_hz = float(c.get("shelf_hz", 1800.0))
        if self.shelf_hz > 0:
            self._a_lp = 1.0 - math.exp(-2.0 * math.pi * self.shelf_hz / sr)
        else:
            self._a_lp = 1.0
        self._g_shelf = 10.0 ** (self.shelf_db / 20.0) - 1.0

        # 3) 软限幅
        self.limit = float(c.get("limit", 0.965))

        # 2.5) AGC —— 音频自动增益控制
        #   实测：FMO 网络下行整体 RMS 达 56% FS、近 5% 样点贴顶 →
        #   听感"过载"。AGC 把 RMS 拉平到 agc_target，峰值交给限幅器兜底。
        self.agc_on = bool(c.get("agc", True))
        self.agc_target = float(c.get("agc_target", 0.09))          # 目标 RMS（占满幅比例）
        self.agc_gmax = 10.0 ** (float(c.get("agc_max_gain_db", 20.0)) / 20.0)
        self.agc_gmin = 10.0 ** (float(c.get("agc_min_gain_db", -26.0)) / 20.0)
        self.agc_gate = float(c.get("agc_gate", 0.004))             # 低于此视为静音，冻结增益
        rms_ms = float(c.get("agc_rms_ms", 120.0))                  # RMS 观察窗
        dn_ms = float(c.get("agc_down_ms", 12.0))                   # 增益下降（压）速度
        up_ms = float(c.get("agc_up_ms", 600.0))                    # 增益回升（抬）速度

        def _a(ms):
            return 1.0 - math.exp(-1.0 / max(1.0, sr * ms / 1000.0))

        self._a_rms = _a(rms_ms)
        self._a_gdn = _a(dn_ms)
        self._a_gup = _a(up_ms)
        self._rms = 0.0        # 平方域 RMS
        self._g = 1.0          # 当前增益
        # 诊断统计
        self.pk_in = 0.0
        self.pk_out = 0.0
        self.rms_acc = 0.0
        self.n_acc = 0
        self.g_min = 9.9
        self.g_max = 0.0
        self.clip_in = 0

        # 4) 向下扩展（噪声门）：低于 thr 的部分按 ratio 压低
        self.exp_on = bool(c.get("expander", False))
        self.exp_thr = float(c.get("expander_thr", 0.010))   # ≈ -40dBFS
        self.exp_ratio = float(c.get("expander_ratio", 3.0))

        # 状态
        self._x1 = 0.0
        self._y1 = 0.0
        self._lp = 0.0
        self._env = 0.0
        self._g = 1.0
        self._g2 = 1.0          # 向下扩展用的增益（与 AGC 的 _g 分开）
        self.calls = 0

    # ------------------------------------------------------------------
    def stats(self):
        """返回本段统计（供桥接每 30s 打印，便于判断 AGC 是否工作正常）"""
        rms = math.sqrt(self.rms_acc / self.n_acc) if self.n_acc else 0.0
        return {"pk_in": self.pk_in, "pk_out": self.pk_out, "rms_out": rms,
                "gain_now": self._g, "gain_min": self.g_min if self.g_min < 9.0 else 1.0,
                "gain_max": self.g_max, "clip": self.clip_in, "n": self.n_acc}

    def reset_stats(self):
        self.pk_in = self.pk_out = 0.0
        self.rms_acc = 0.0
        self.n_acc = 0
        self.clip_in = 0
        self.g_min = 9.9
        self.g_max = 0.0

    # ------------------------------------------------------------------
    def process(self, pcm):
        """输入/输出都是 S16LE 字节串。"""
        if not self.enabled or not pcm:
            return pcm
        n = len(pcm) // 2
        vals = [int.from_bytes(pcm[i * 2:i * 2 + 2], "little", signed=True)
                for i in range(n)]
        out = bytearray(len(pcm))
        a_hp, a_lp = self._a_hp, self._a_lp
        g_sh, lim = self._g_shelf, self.limit
        agc_on = self.agc_on
        a_rms, a_gdn, a_gup = self._a_rms, self._a_gdn, self._a_gup
        tgt, gmax, gmin = self.agc_target, self.agc_gmax, self.agc_gmin
        gate2 = self.agc_gate * self.agc_gate

        for i in range(n):
            x = vals[i] / S16_MAX
            if x > self.pk_in:
                self.pk_in = x
            elif -x > self.pk_in:
                self.pk_in = -x

            # 1) 高通
            if a_hp > 0.0:
                y = a_hp * (self._y1 + x - self._x1)
                self._x1, self._y1 = x, y
                x = y

            # 2) 高频搁架
            self._lp += a_lp * (x - self._lp)
            if g_sh != 0.0:
                x = x + g_sh * (x - self._lp)

            # 2.5) AGC：把音量拉平（对方大声不再过载，小声也能听清）
            if agc_on:
                self._rms += a_rms * (x * x - self._rms)
                if self._rms > gate2:
                    want = tgt / math.sqrt(self._rms)
                    if want > gmax:
                        want = gmax
                    elif want < gmin:
                        want = gmin
                    # 增益要压得快、抬得慢，避免"抽吸"感
                    self._g += (a_gdn if want < self._g else a_gup) * (want - self._g)
                    if self._g < self.g_min:
                        self.g_min = self._g
                    if self._g > self.g_max:
                        self.g_max = self._g
                x *= self._g

            # 4) 向下扩展
            if self.exp_on:
                a = abs(x)
                self._env = a if a > self._env else (0.995 * self._env + 0.005 * a)
                if self._env < self.exp_thr:
                    tgtg = (self._env / self.exp_thr) ** (self.exp_ratio - 1.0)
                    self._g2 += 0.02 * (tgtg - self._g2)
                else:
                    self._g2 += 0.20 * (1.0 - self._g2)
                x *= self._g2

            # 3) 软限幅（在 ±limit 内线性，越界平滑压到 limit）
            a = abs(x)
            if a >= lim:
                self.clip_in += 1
            if a > lim:
                over = a - lim
                x = (1.0 if x > 0 else -1.0) * (lim + (1.0 - lim) * (1.0 - math.exp(-over / (1.0 - lim))))
            if x > self.pk_out:
                self.pk_out = x
            elif -x > self.pk_out:
                self.pk_out = -x
            self.rms_acc += x * x
            self.n_acc += 1
            iv = int(x * S16_MAX)
            iv = max(-32768, min(32767, iv))
            out[i * 2:i * 2 + 2] = iv.to_bytes(2, "little", signed=True)

        self.calls += 1
        return bytes(out)
```

### `opt/fmo-bridge/fmo_fm.py`

```python
# -*- coding: utf-8 -*-
"""MMDVM-Host FM Network（g4klx CFMNetwork）UDP 收发。

线协议（见 MMDVM-Host/FMNetwork.cpp）：
  FMS<callsign>\\0   开始发射
  FMD<s16le ...>     音频数据（8000Hz / 16bit 有符号 / 单声道）
  FME                结束
  FMP                心跳（每 5s）

本桥接程序扮演 Host 配置里 [FM Network] 的 Gateway 角色。
"""
import socket
import struct
import threading
import time

FS_START = b"FMS"
FS_DATA = b"FMD"
FS_END = b"FME"
FS_PING = b"FMP"

LOCAL_CALLSIGN = "N0CALL"


class FmNetwork:
    """在 GatewayAddress:GatewayPort 上收 Host 的 FM 音频，并可反向回送。"""

    def __init__(self, bind_addr, bind_port, callsign=LOCAL_CALLSIGN,
                 on_start=None, on_audio=None, on_end=None, debug=False):
        self.bind = (bind_addr, int(bind_port))
        self.callsign = callsign.upper()
        self.debug = debug
        self.on_start = on_start
        self.on_audio = on_audio
        self.on_end = on_end
        self.peer = None                 # 最近一次发包的 Host 地址
        self.seq = 0
        self.tx_active = False
        self._sock = None
        self._stop = False
        self._last_rx = 0.0
        self._ping_t = 0.0

    # ---------------------------------------------------------- 生命周期
    def open(self):
        self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._sock.bind(self.bind)
        self._sock.settimeout(0.2)
        return self

    def close(self):
        self._stop = True
        try:
            if self._sock:
                self._sock.close()
        except Exception:
            pass

    def run_forever(self):
        while not self._stop:
            try:
                data, addr = self._sock.recvfrom(2048)
            except socket.timeout:
                self._tick()
                continue
            except OSError:
                break
            self.peer = addr
            self._last_rx = time.time()
            try:
                self._handle(data)
            except Exception as e:
                if self.debug:
                    print("[FM] handle error:", e)
            self._tick()

    def _tick(self):
        now = time.time()
        if now - self._ping_t >= 5.0 and self.peer:
            self._ping_t = now
            self._send(FS_PING)

    def start_thread(self):
        t = threading.Thread(target=self.run_forever, daemon=True)
        t.start()
        return t

    # ---------------------------------------------------------- 接收
    def _handle(self, data):
        if len(data) < 3 or data[0:2] != b"FM":
            return
        tag = data[0:3]
        if self.debug:
            print("[FM] rx %s len=%d" % (tag.decode("ascii", "ignore"), len(data)))
        if tag == FS_DATA:
            pcm = data[3:]
            if self.on_audio:
                self.on_audio(pcm)
        elif tag == FS_START:
            cs = data[3:].split(b"\x00", 1)[0].decode("ascii", "ignore")
            if self.debug:
                print("[FM] START from", cs)
            if self.on_start:
                self.on_start(cs)
        elif tag == FS_END:
            if self.debug:
                print("[FM] END")
            if self.on_end:
                self.on_end()

    # ---------------------------------------------------------- 发送
    def _send(self, blob):
        if self.peer and self._sock:
            try:
                self._sock.sendto(blob, self.peer)
            except OSError:
                pass

    def send_start(self):
        self.seq = 0
        self.tx_active = True
        self._send(FS_START + self.callsign.encode("ascii") + b"\x00")

    def send_audio(self, pcm_bytes):
        if not self.tx_active:
            self.send_start()
        self.seq += 1
        self._send(FS_DATA + pcm_bytes)

    def send_end(self):
        if self.tx_active:
            self.tx_active = False
            self._send(FS_END)

    def send_ping(self):
        self._send(FS_PING)


def pcm_gain(pcm_bytes, gain):
    if gain == 1.0:
        return pcm_bytes
    n = len(pcm_bytes) // 2
    out = bytearray(len(pcm_bytes))
    for i in range(n):
        v = int.from_bytes(pcm_bytes[i * 2:i * 2 + 2], "little", signed=True)
        v = max(-32768, min(32767, int(v * gain)))
        out[i * 2:i * 2 + 2] = int(v).to_bytes(2, "little", signed=True)
    return bytes(out)
```

### `opt/fmo-bridge/fmo_packet.py`

```python
# -*- coding: utf-8 -*-
"""FMO 语音消息包封装（严格按官方《FMO 语音数据开放协议》）。

层次：消息包(≤1400B) → 传输帧 ×N → 编码语音帧 → 编码载荷(OPUS/RADPCM)
所有多字节字段 **小端序**。CRC32 仅覆盖「帧区」（消息头之后的部分）。
"""
import struct
import zlib

MSG_HEADER_LEN = 64
TF_HEADER_LEN = 8
CVF_HEADER_LEN = 8
MTU = 1400
AGG_MS = 250

CM_PCM, CM_OPUS, CM_RADPCM = 0, 1, 2
DEFAULT_VENDOR = 0x2001          # 软件区 0x2000~0x2FFF（免注册）
VERSION = 1

# 编码帧头里 compressMode(1)+len(2) 之后的 5 字节。
# 官方参考实现（NOCALL 实测，731 帧全部一致）填 0x3D1400E03D，不是零。
# 实测自 EMQX trace 的真实 FMO/RAW 报文。
CODEC_TAG = bytes.fromhex("3d1400e03d")


def build_voice_packet(uid, callsign, frames, stream_begin_utc=None, ts_ms=None,
                       srv_uid=0, vendor=DEFAULT_VENDOR, smeter=0, seq_index0=1):
    """frames: [(compressMode, payload_bytes), ...]  → 完整消息包 bytes

    · 消息头 64B：version(2) vendor(4) UID(4) callsign(12) streamBeginUTC(4)
                  timestamp(4) len(4) frameNum(2) checkSum(4) smeter(1) srvUID(4) reserved(19)
    · 传输帧 8B：index(2, 从1递增) len(2,含头) reserved(4)  + 编码语音帧
    · 编码语音帧 8B：compressMode(1) len(2,含头) reserved(5) + 载荷
    """
    if not ts_ms:
        import time as _t
        ts_ms = int(_t.time() * 1000)
    if stream_begin_utc is None:
        stream_begin_utc = ts_ms

    frame_area = bytearray()
    for i, (cm, payload) in enumerate(frames):
        cvf_len = CVF_HEADER_LEN + len(payload)
        cvf = bytearray()
        cvf += bytes([cm & 0xFF])
        cvf += struct.pack("<H", cvf_len)
        cvf += CODEC_TAG                       # 参考实现填 0x3D1400E03D，不是零
        cvf += payload

        tf_len = TF_HEADER_LEN + cvf_len
        frame_area += struct.pack("<HH", (seq_index0 + i) & 0xFFFF, tf_len)
        frame_area += b"\x00" * 4
        frame_area += cvf

    callsign_bytes = callsign.upper().encode("ascii")[:12].ljust(12, b"\x00")
    total_len = MSG_HEADER_LEN + len(frame_area)
    hdr = bytearray()
    hdr += struct.pack("<H", VERSION)
    hdr += struct.pack("<I", vendor & 0xFFFFFFFF)
    hdr += struct.pack("<I", uid & 0xFFFFFFFF)
    hdr += callsign_bytes
    hdr += struct.pack("<I", stream_begin_utc & 0xFFFFFFFF)
    hdr += struct.pack("<I", ts_ms & 0xFFFFFFFF)
    hdr += struct.pack("<I", total_len)
    hdr += struct.pack("<H", len(frames))
    hdr += struct.pack("<I", zlib.crc32(bytes(frame_area)) & 0xFFFFFFFF)
    hdr += struct.pack("<B", smeter & 0xFF)
    hdr += struct.pack("<I", srv_uid & 0xFFFFFFFF)
    hdr += b"\x00" * 19
    assert len(hdr) == MSG_HEADER_LEN, len(hdr)
    return bytes(hdr) + bytes(frame_area)


def parse_voice_packet(data: bytes):
    """返回 dict（含 frames=[(compressMode, payload), ...]）"""
    if len(data) < MSG_HEADER_LEN:
        raise ValueError("packet too short: %d" % len(data))
    (version, vendor, uid) = struct.unpack_from("<HII", data, 0)
    callsign = data[10:22].split(b"\x00", 1)[0].decode("ascii", "ignore")
    (stream_begin, ts_ms, total_len) = struct.unpack_from("<III", data, 22)
    frame_num = struct.unpack_from("<H", data, 34)[0]
    check = struct.unpack_from("<I", data, 36)[0]
    smeter = data[40]
    srv_uid = struct.unpack_from("<I", data, 41)[0]

    frame_area = data[MSG_HEADER_LEN:total_len]
    frames = []
    off = 0
    while off + TF_HEADER_LEN <= len(frame_area):
        index, tf_len = struct.unpack_from("<HH", frame_area, off)
        cv = frame_area[off + TF_HEADER_LEN: off + tf_len]
        if len(cv) < CVF_HEADER_LEN:
            break
        cm = cv[0]
        cv_len = struct.unpack_from("<H", cv, 1)[0]
        payload = cv[CVF_HEADER_LEN:cv_len]
        frames.append((cm, payload))
        off += tf_len
    return {
        "version": version, "vendor": vendor, "uid": uid, "callsign": callsign,
        "streamBeginUTC": stream_begin, "timestamp": ts_ms, "len": total_len,
        "frameNum": frame_num, "checkSum": check, "smeter": smeter, "srvUID": srv_uid,
        "frames": frames, "crc_ok": (zlib.crc32(frame_area) & 0xFFFFFFFF) == check,
    }


def chunk_frames(payload_frames, max_packet=MTU):
    """按 MTU 把若干「编码语音帧」切成多个消息包（每帧已含 8B 帧头+载荷）。"""
    out, cur, sz = [], [], MSG_HEADER_LEN
    for cm, payload in payload_frames:
        add = TF_HEADER_LEN + CVF_HEADER_LEN + len(payload)
        if cur and sz + add > max_packet:
            out.append(cur); cur, sz = [], MSG_HEADER_LEN
        cur.append((cm, payload)); sz += add
    if cur:
        out.append(cur)
    return out
```

### `opt/fmo-bridge/fmo_presence.py`

```python
# -*- coding: utf-8 -*-
"""FMO 在线宣告（presence）——真实设备靠这些报文感知"谁在线"。

实测报文（来自 EMQX trace 里的真实设备 BG7OYZ / BG8LKV / BG2CPU）：

  FMO/TELE （33 字节，约每 30 秒一次）
    偏移 大小 字段
    0    1   version = 2
    1    4   session（设备内会话值）
    5    4   UID（小端）
    9    12  呼号（不足补 0）
    21   4   UTC 秒（小端）
    25   4   发射频率 MHz（float32 小端）
    29   4   接收频率 MHz（float32 小端）

  FMO/LATE/UID_V1/<uid> （9 字节，延迟上报）
    0    4   延迟毫秒（小端）
    4    4   （实测为变化值）
    8    1   （实测为变化值）

真实设备上报频率后，其他设备才知道"这台在哪个频点"，因此桥接也必须上报
自己的收发频率（对应热点 ch5 的 TX/RX）。
"""
import struct
import time

TELE_VERSION = 2


def build_tele(uid, callsign, tx_mhz=0.0, rx_mhz=0.0, session=None):
    """构造 FMO/TELE 遥测/在线宣告报文（33 字节）。"""
    if session is None:
        session = int(time.time() * 1000) & 0xFFFFFFFF
    b = bytearray()
    b += bytes([TELE_VERSION])
    b += struct.pack("<I", int(session) & 0xFFFFFFFF)
    b += struct.pack("<I", int(uid) & 0xFFFFFFFF)
    b += str(callsign).upper().encode("ascii")[:12].ljust(12, b"\x00")
    b += struct.pack("<I", int(time.time()) & 0xFFFFFFFF)
    b += struct.pack("<f", float(tx_mhz))
    b += struct.pack("<f", float(rx_mhz))
    return bytes(b)


def build_late(latency_ms=0, seq=0, tail=0):
    """构造 FMO/LATE/UID_V1/<uid> 延迟上报（9 字节）。"""
    return (struct.pack("<I", int(latency_ms) & 0xFFFFFFFF)
            + struct.pack("<I", int(seq) & 0xFFFFFFFF)
            + bytes([int(tail) & 0xFF]))


# 真实设备上线时订阅的主题（取自 trace 里 BG8LKV 的 SUBSCRIBE 序列 + roles/user.json）
USER_SUBSCRIPTIONS = [
    "ARCH/HRC",
    "FMO/TELE",
    "FMO/USER_RIG",
    "FMO/PROFILE",
    "FMO/SERVER_INFO",
    "FMO/QSO/+",
    "FMO/LATE/UID_V1/+",
]
```

### `opt/fmo-bridge/fmo_proto.py`

```python
# -*- coding: utf-8 -*-
"""FMO 4.0 协议 / 密码学核心。

与 BG5ESN 的 SAS（fmo-server-authrozier-service）1:1 对齐：
  · 签名输入 = 固定顺序语义值数组 → 确定性 CBOR 编码（不是 JSON 文本）
  · 算法固定 Ed25519
  · binary 字段在 JSON/文本里用 base64url（无 padding）
  · 域分离前缀 ["FMO", 4, "<messageType>", ...]

依赖：cbor2（apt: python3-cbor2）、PyNaCl（apt: python3-nacl）
"""
import base64
import hashlib
import json
import time

import cbor2
from nacl.signing import SigningKey, VerifyKey

PROTOCOL = "FMO"
VERSION = 4


# ---------------------------------------------------------------- 编码工具
def b64u_encode(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


def b64u_decode(text) -> bytes:
    if isinstance(text, bytes):
        text = text.decode("ascii")
    text = text.strip()
    text += "=" * ((4 - len(text) % 4) % 4)
    return base64.urlsafe_b64decode(text)


def cbor_det(obj) -> bytes:
    """确定性（canonical）CBOR —— 对齐 .NET CborWriter 的 preferred serialization。"""
    return cbor2.dumps(obj, canonical=True)


def sha256(raw: bytes) -> bytes:
    return hashlib.sha256(raw).digest()


# ---------------------------------------------------------------- 证书 TBS
def root_ca_tbs(j: dict) -> bytes:
    ext = j.get("extensions", {})
    return cbor_det([
        PROTOCOL, VERSION, "rootCA",
        int(j["sn"]),
        j["issuer"]["name"], j.get("issuer", {}).get("email", ""),
        j["subject"]["name"], b64u_decode(j["subject"]["publicKey"]),
        bool(ext.get("isCA", True)), int(ext.get("pathLen", 1)),
        ext.get("crl", ""), ext.get("license", ""), str(ext.get("keyId", "")),
        int(j["iat"]), int(j["exp"]),
    ])


def intermediate_ca_tbs(j: dict) -> bytes:
    ext = j.get("extensions", {})
    ur = ext.get("uidRange", {})
    countries = sorted(str(c).upper() for c in ext.get("issuingCountries", []))
    return cbor_det([
        PROTOCOL, VERSION, "intermediateCA",
        int(j["sn"]),
        int(j["issuer"]["sn"]), j["issuer"]["name"], b64u_decode(j["issuer"]["publicKey"]),
        j["subject"]["name"], j.get("subject", {}).get("email", ""),
        b64u_decode(j["subject"]["publicKey"]),
        bool(ext.get("isCA", True)), int(ext.get("pathLen", 0)), str(ext.get("keyId", "")),
        ext.get("crl", ""), ext.get("license", ""),
        int(ur.get("start", 0)), int(ur.get("end", 0)),
        countries,
        int(j["iat"]), int(j["exp"]),
    ])


def user_cert_tbs(j: dict) -> bytes:
    sub = j["subject"]
    return cbor_det([
        PROTOCOL, VERSION, "userCert",
        int(j["issuerSn"]),
        str(sub["callsign"]).upper(),
        int(sub["uid"]),
        b64u_decode(sub["publicKey"]),
        int(j["iat"]), int(j["exp"]),
    ])


# ---------------------------------------------------------------- 证书对象
class Cert:
    def __init__(self, j: dict, kind: str):
        self.raw = j
        self.kind = kind
        if kind == "rootCA":
            self.tbs = root_ca_tbs(j)
            self.subject_pub = b64u_decode(j["subject"]["publicKey"])
        elif kind == "intermediateCA":
            self.tbs = intermediate_ca_tbs(j)
            self.subject_pub = b64u_decode(j["subject"]["publicKey"])
            self.issuer_pub = b64u_decode(j["issuer"]["publicKey"])
            self.sn = int(j["sn"])
        elif kind == "userCert":
            self.tbs = user_cert_tbs(j)
            self.subject_pub = b64u_decode(j["subject"]["publicKey"])
            self.callsign = str(j["subject"]["callsign"]).upper()
            self.uid = int(j["subject"]["uid"])
            self.issuer_sn = int(j["issuerSn"])
            self.exp = int(j["exp"])
        else:
            raise ValueError("unknown cert kind: %s" % kind)
        self.signature = b64u_decode(j["signature"])

    def fingerprint(self) -> bytes:
        """SHA-256(TBS 的确定性 CBOR) —— 即 certFingerprint / serverFingerprint。"""
        return sha256(self.tbs)

    def verify_by(self, issuer_public_key: bytes) -> bool:
        try:
            VerifyKey(issuer_public_key).verify(self.tbs, self.signature)
            return True
        except Exception:
            return False


# ---------------------------------------------------------------- MQTT 登录
TBS_TYPE_HTTP = "serverAuthorizerReqHttp"


def server_authorizer_tbs(server_uid, target_callsign, target_uid, role,
                          target_url, target_port, server_fp_bytes,
                          timestamp, user_fp_bytes) -> bytes:
    """SAS HTTP 认证的 12 元素签名数组（与 HttpProofVerifier.BuildTbs 一致）。"""
    return cbor_det([
        PROTOCOL, VERSION, TBS_TYPE_HTTP,
        int(server_uid),
        str(target_callsign).upper(),
        int(target_uid),
        str(role),
        str(target_url),
        int(target_port),
        server_fp_bytes,
        int(timestamp),
        user_fp_bytes,
    ])


def build_mqtt_login(user_cert_json: dict,
                     intermediate_cert_json: dict,
                     user_seed: bytes,
                     server_callsign: str,
                     server_uid: int,
                     mqtt_host: str,
                     mqtt_port: int,
                     server_fingerprint_b64u: str,
                     role: str = "user",
                     timestamp: int = None) -> dict:
    """构造 MQTT CONNECT 的 username/password（FMO 4.0 SAS HTTP 认证器模式）。"""
    uc = Cert(user_cert_json, "userCert")
    ts = int(time.time()) if timestamp is None else int(timestamp)
    server_fp = b64u_decode(server_fingerprint_b64u)

    tbs = server_authorizer_tbs(server_uid, server_callsign, server_uid, role,
                                mqtt_host, int(mqtt_port), server_fp, ts, uc.fingerprint())
    sig = SigningKey(bytes(user_seed)).sign(tbs).signature          # 64 字节

    payload = {
        "certPackage": {
            "intermediateCert": intermediate_cert_json,
            "userCert": user_cert_json,
        },
        "targetCallsign": server_callsign,
        "targetUID": int(server_uid),
        "role": role,
        "targetUrl": mqtt_host,
        "targetPort": int(mqtt_port),
        "serverFingerprint": server_fingerprint_b64u,
        "timestamp": ts,
        "proof": {"signature": b64u_encode(sig)},
    }
    return {
        "username": uc.callsign,
        "password": b64u_encode(json.dumps(payload, separators=(",", ":")).encode("utf-8")),
        "_tbs_hex": tbs.hex(),
    }
```

### `opt/fmo-bridge/fmo_reg.py`

```python
# -*- coding: utf-8 -*-
"""FMO 客户端自动注册（C 方案：服务器自动签发证书）

用户在 FM 配置里只需填「服务器地址 / 端口 / 呼号」：
  1. 本机生成 Ed25519 密钥对 —— **私钥永不离开本机**
  2. GET  <注册地址>/fmo/ping     → 取得服务器身份(uid/callsign/证书指纹/MQTT端口)
  3. POST <注册地址>/fmo/register → 提交 呼号+公钥，服务器用其中级 CA 现场签发
  4. userCert / intermediateCert / 私钥 落盘，之后照常登录

注册地址约定：http://<服务器地址>:8081
"""
import json
import os
import urllib.request

from nacl.signing import SigningKey

import fmo_proto as P

REG_PORT = 8081
TIMEOUT = 15


def register_base(host, port=REG_PORT):
    if host.startswith("http://") or host.startswith("https://"):
        return host.rstrip("/")
    return "http://%s:%d" % (host, int(port))


def _get_json(url, timeout=TIMEOUT):
    req = urllib.request.Request(url, headers={"Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def _post_json(url, body, timeout=TIMEOUT):
    data = json.dumps(body, separators=(",", ":")).encode("utf-8")
    req = urllib.request.Request(url, data=data,
                                 headers={"Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def probe_server(register_url, timeout=TIMEOUT):
    """只查询服务器身份，不签发证书。"""
    return _get_json(register_url.rstrip("/") + "/fmo/ping", timeout)


def ensure_cert(register_url, callsign, uid, cert_path, key_path, inter_path,
                token=None, timeout=TIMEOUT, force=False, log=print):
    """确保证书齐备；缺失则自动注册。返回 (server_info|None, userCert, intermediateCert)。"""
    have = all(os.path.exists(p) for p in (cert_path, key_path, inter_path))
    if have and not force:
        with open(cert_path, encoding="utf-8") as f:
            cert = json.load(f)
        with open(inter_path, encoding="utf-8") as f:
            inter = json.load(f)
        return None, cert, inter

    base = register_url.rstrip("/")
    info = probe_server(base, timeout)
    if not info.get("ok"):
        raise RuntimeError("注册服务无响应: %s" % info)
    server = info.get("server") or {}
    if not server:
        raise RuntimeError("服务器未下发身份信息（CA 未读到 SAS 配置）")

    d = os.path.dirname(os.path.abspath(cert_path))
    if d:
        os.makedirs(d, exist_ok=True)

    sk = SigningKey.generate()
    pub = P.b64u_encode(sk.verify_key.encode())

    body = {"callsign": str(callsign).upper(), "uid": int(uid), "pubkey": pub}
    if token:
        body["token"] = token
    res = _post_json(base + "/fmo/register", body, timeout)
    if not res.get("ok"):
        raise RuntimeError("注册被拒绝: %s" % res.get("error"))

    user = res["userCert"]
    sub = user.get("subject", {})
    if str(sub.get("callsign", "")).upper() != str(callsign).upper():
        raise RuntimeError("证书呼号不匹配: %s" % sub.get("callsign"))
    # uid 传 0 表示由服务器分配唯一值（FAS 会封禁同一 uid 多处登录）
    if int(uid) > 0 and int(sub.get("uid", -1)) != int(uid):
        raise RuntimeError("证书 uid 不匹配: %s" % sub.get("uid"))
    if sub.get("publicKey") != pub:
        raise RuntimeError("证书公钥与本地私钥不匹配（服务器未采用我们提交的公钥）")

    with open(cert_path, "w", encoding="utf-8") as f:
        json.dump(user, f, ensure_ascii=False, indent=2)
    with open(inter_path, "w", encoding="utf-8") as f:
        json.dump(res["intermediateCert"], f, ensure_ascii=False, indent=2)
    with open(key_path, "w", encoding="ascii") as f:
        f.write(P.b64u_encode(sk.encode()) + "\n")
    try:
        os.chmod(key_path, 0o600)
    except OSError:
        pass

    log("自动注册成功：%s uid=%d fingerprint=%s"
        % (user["subject"]["callsign"], user["subject"]["uid"],
           res.get("fingerprint", "?")))
    return server, user, res["intermediateCert"]
```

### `opt/fmo-bridge/fmo_voice.py`

```python
# -*- coding: utf-8 -*-
"""FMO 语音编解码：OPUS（40ms/8kHz/单声道，compressMode=1）与 RADPCM（IMA ADPCM，80ms，compressMode=2）。
   参数严格按官方《FMO 语音数据开放协议》。OPUS 走 ctypes 直调 libopus0，无需 pip。
"""
import ctypes
import ctypes.util

SAMPLE_RATE = 8000
OPUS_FRAME = 320          # 40ms @8kHz
RADPCM_FRAME = 640        # 80ms @8kHz

OPUS_APPLICATION_VOIP = 2048
OPUS_SIGNAL_VOICE = 3001
OPUS_SET_BITRATE = 4002
OPUS_SET_VBR = 4006
OPUS_SET_COMPLEXITY = 4010
OPUS_SET_SIGNAL = 4024
OPUS_AUTO = -1000


class OpusCodec:
    def __init__(self):
        path = ctypes.util.find_library("opus") or "libopus.so.0"
        self.lib = ctypes.CDLL(path)
        L = self.lib
        L.opus_encoder_create.restype = ctypes.c_void_p
        L.opus_encoder_create.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int,
                                          ctypes.POINTER(ctypes.c_int)]
        L.opus_decoder_create.restype = ctypes.c_void_p
        L.opus_decoder_create.argtypes = [ctypes.c_int, ctypes.c_int,
                                          ctypes.POINTER(ctypes.c_int)]
        L.opus_encode.restype = ctypes.c_int
        L.opus_encode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int16),
                                  ctypes.c_int, ctypes.POINTER(ctypes.c_ubyte),
                                  ctypes.c_int]
        L.opus_decode.restype = ctypes.c_int
        L.opus_decode.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ubyte),
                                  ctypes.c_int, ctypes.POINTER(ctypes.c_int16),
                                  ctypes.c_int, ctypes.c_int]
        L.opus_encoder_ctl.restype = ctypes.c_int

        err = ctypes.c_int(0)
        self.enc = L.opus_encoder_create(SAMPLE_RATE, 1, OPUS_APPLICATION_VOIP,
                                         ctypes.byref(err))
        assert err.value == 0, "opus_encoder_create err=%d" % err.value
        L.opus_encoder_ctl(self.enc, OPUS_SET_BITRATE, OPUS_AUTO)
        L.opus_encoder_ctl(self.enc, OPUS_SET_VBR, 1)
        L.opus_encoder_ctl(self.enc, OPUS_SET_COMPLEXITY, 4)
        L.opus_encoder_ctl(self.enc, OPUS_SET_SIGNAL, OPUS_SIGNAL_VOICE)
        err = ctypes.c_int(0)
        self.dec = L.opus_decoder_create(SAMPLE_RATE, 1, ctypes.byref(err))
        assert err.value == 0, "opus_decoder_create err=%d" % err.value

    def encode(self, pcm_bytes: bytes) -> bytes:
        """pcm_bytes: 320 样本 S16LE（640 字节）→ 约 40-60 字节 OPUS 帧"""
        n = len(pcm_bytes) // 2
        src = (ctypes.c_int16 * n).from_buffer_copy(pcm_bytes)
        out = (ctypes.c_ubyte * 512)()
        k = self.lib.opus_encode(self.enc, src, n, out, 512)
        if k < 0:
            raise RuntimeError("opus_encode err=%d" % k)
        return bytes(out[:k])

    def decode(self, opus_frame: bytes, frame_size: int = OPUS_FRAME) -> bytes:
        if not opus_frame:
            return b"\x00" * (frame_size * 2)
        src = (ctypes.c_ubyte * len(opus_frame)).from_buffer_copy(opus_frame)
        out = (ctypes.c_int16 * frame_size)()
        k = self.lib.opus_decode(self.dec, src, len(opus_frame), out, frame_size, 0)
        if k < 0:
            raise RuntimeError("opus_decode err=%d" % k)
        return bytes(bytearray(out))[:k * 2]

    def __del__(self):
        try:
            self.lib.opus_encoder_destroy(self.enc)
            self.lib.opus_decoder_destroy(self.dec)
        except Exception:
            pass


# ============================================================ RADPCM (IMA ADPCM)
STEP_TABLE = [
    7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45,
    50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, 190, 209, 230,
    253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963,
    1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024,
    3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, 7132, 7845, 8630, 9493,
    10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623,
    27086, 29794, 32767,
]
INDEX_TABLE = [-1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8]
_R = 32735          # Q15 0.999 直流抑制系数（截止约 1.3Hz）


class RadPcmCodec:
    """一帧 = 640 样本(80ms,1280B PCM) → 320B ADPCM，状态跨帧连续，带帧恢复点。"""

    def __init__(self):
        self.predictor = 0
        self.index = 0
        self.prev_x = 0
        self.prev_y = 0
        self.frame_index = 0

    def _dc(self, x):
        y = x - self.prev_x + ((_R * self.prev_y) >> 15)
        y = max(-32768, min(32767, y))
        self.prev_x, self.prev_y = x, y
        return y

    def _enc_sample(self, s, step_idx):
        step = STEP_TABLE[step_idx]
        diff = s - self.predictor
        code = 0
        if diff < 0:
            code = 8
            diff = -diff
        delta = step >> 3
        if diff >= step:
            code |= 4; diff -= step; delta += step
        step >>= 1
        if diff >= step:
            code |= 2; diff -= step; delta += step
        step >>= 1
        if diff >= step:
            code |= 1; delta += step
        if code & 8:
            self.predictor -= delta
        else:
            self.predictor += delta
        self.predictor = max(-32768, min(32767, self.predictor))
        step_idx = max(0, min(88, step_idx + INDEX_TABLE[code]))
        return step_idx, code

    def encode(self, pcm_bytes: bytes) -> bytes:
        n = len(pcm_bytes) // 2
        samples = [int.from_bytes(pcm_bytes[i * 2:i * 2 + 2], "little", signed=True)
                   for i in range(n)]
        recov, recov_idx = self.predictor, self.index
        out = bytearray()
        for i in range(0, n, 2):
            self.index, hi = self._enc_sample(self._dc(samples[i]), self.index)
            self.index, lo = self._enc_sample(self._dc(samples[i + 1]), self.index)
            out.append((hi << 4) | lo)
        hdr = bytearray()
        hdr += (self.frame_index & 0xFFFF).to_bytes(2, "little")
        hdr += (recov & 0xFFFF).to_bytes(2, "little")
        hdr += bytes([recov_idx & 0xFF, 0])
        hdr += (320).to_bytes(2, "little")
        self.frame_index = (self.frame_index + 1) & 0xFFFF
        return bytes(hdr) + bytes(out)

    def decode(self, frame: bytes) -> bytes:
        if len(frame) < 8:
            return b"\x00" * (RADPCM_FRAME * 2)
        fi = int.from_bytes(frame[0:2], "little")
        recov = int.from_bytes(frame[2:4], "little", signed=True)
        step_idx = frame[4]
        # adpcmBytes 兼容性（官方协议 §5.2 注记）：早期实现是 8-bit 字段，
        # 线值被截断成 64，且高字节是未初始化填充。故一律以「帧实际长度」为准，
        # 即固定的 320 字节；仅当帧长异常时才回退到低字节判定。
        data_len = len(frame) - 8
        if data_len <= 0 or data_len > 320:
            data_len = frame[6]
            if data_len == 0 or data_len > 320:
                data_len = 320
        if fi == 0 or fi != (self.frame_index & 0xFFFF):
            self.predictor, self.index = recov, step_idx      # 帧恢复点
        out = bytearray()
        for b in frame[8:8 + data_len]:
            for code in ((b >> 4) & 0xF, b & 0xF):
                step = STEP_TABLE[self.index]
                delta = step >> 3
                if code & 4: delta += step
                if code & 2: delta += step >> 1
                if code & 1: delta += step >> 2
                if code & 8:
                    self.predictor -= delta
                else:
                    self.predictor += delta
                self.predictor = max(-32768, min(32767, self.predictor))
                self.index = max(0, min(88, self.index + INDEX_TABLE[code]))
                out += int(self.predictor).to_bytes(2, "little", signed=True)
        self.frame_index = (fi + 1) & 0xFFFF
        return bytes(out)
```

### `opt/mmdvm-web/app.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import subprocess, os, re, json, socket, glob, signal, threading, time, urllib.request, urllib.parse
from flask import Flask, render_template_string, request, jsonify

try:  # CONSOLE_NEW
    import sys as _sys_cn
    if "/opt/mmdvm-web" not in _sys_cn.path:
        _sys_cn.path.insert(0, "/opt/mmdvm-web")
    from console_new import CONSOLE_STANDALONE
except Exception:
    CONSOLE_STANDALONE = '<h3>控制台模块缺失</h3>'


try:  # RFMODE_SAFE
    import sys as _sys_rfms
    if "/opt/mmdvm-web" not in _sys_rfms.path:
        _sys_rfms.path.insert(0, "/opt/mmdvm-web")
    from rfmode_standalone import RFMODE_STANDALONE
except Exception:
    RFMODE_STANDALONE = '<h3>射频模式配置页模块缺失</h3>'


try:
    from fmo_page import FMO_HTML
except Exception:
    FMO_HTML = '<h3>FMO 页面模块缺失</h3>'

try:
    from tetra_page import TETRA_HTML
except Exception:
    TETRA_HTML = '<h3>TDR Link 页面模块缺失</h3>'

app = Flask(__name__)


@app.after_request
def _no_store(resp):
    """★ 控制台页面与接口一律禁用缓存：
    否则改完页面后浏览器还拿旧的（新菜单/新页面看不到）。"""
    try:
        _ct = resp.headers.get('Content-Type', '') or ''
        if _ct.startswith('text/html') or _ct.startswith('application/json'):
            resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
            resp.headers['Pragma'] = 'no-cache'
            resp.headers.pop('Expires', None)
    except Exception:
        pass
    return resp

def run_cmd(cmd_list, timeout=45):
    """执行命令并捕获输出。

    ★ 超时必须杀掉整个进程组：只杀直接子进程（sudo）时，
    被 sudo 拉起的 systemctl 仍持有输出管道，subprocess.run 会
    一直等到管道 EOF —— 表现为网页一直转圈、永不返回。
    """
    try:
        p = subprocess.Popen(cmd_list, stdout=subprocess.PIPE,
                             stderr=subprocess.PIPE, text=True,
                             start_new_session=True)
    except Exception as e:
        return {"ok": False, "code": -1, "stdout": "", "stderr": str(e)}
    try:
        out, err = p.communicate(timeout=timeout)
    except subprocess.TimeoutExpired:
        try:
            os.killpg(os.getpgid(p.pid), signal.SIGKILL)
        except Exception:
            try:
                p.kill()
            except Exception:
                pass
        try:
            out, err = p.communicate(timeout=5)
        except Exception:
            out, err = "", ""
        return {"ok": False, "code": 124,
                "stdout": (out or "").strip(),
                "stderr": "命令执行超时（已终止）"}
    return {"ok": p.returncode == 0, "code": p.returncode,
            "stdout": (out or "").strip(), "stderr": (err or "").strip()}


# ============ DMR ID 数据库（ID ↔ 呼号） ============
_DMR_IDS = None
_DMR_IDS_MTIME = 0
_DMR_CALL_BY_ID = {}
_RADIOID_MEM_CACHE = {}
_RADIOID_PENDING = set()
_RADIOID_LOCK = threading.Lock()
_RADIOID_CACHE_FILE = "/opt/mmdvm-web/data/radioid_cache.json"
_RADIOID_SEED_FILE = "/opt/tetra/radioid_cache.json"

def _load_dmr_ids():
    """加载 /usr/local/etc/DMRIds.dat 到内存（带 mtime 检查）"""
    global _DMR_IDS, _DMR_IDS_MTIME, _DMR_CALL_BY_ID
    path = "/usr/local/etc/hosts/DMRIds.dat"
    try:
        import os
        if not os.path.exists(path):
            _DMR_IDS = {}
            return _DMR_IDS
        mtime = os.path.getmtime(path)
        if _DMR_IDS is not None and mtime == _DMR_IDS_MTIME:
            return _DMR_IDS
        ids = {}
        by_id = {}
        with open(path, encoding="utf-8", errors="ignore") as f:
            for line in f:
                parts = line.strip().split("\t")
                if len(parts) >= 2:
                    dmr_id = parts[0].strip()
                    call = parts[1].strip().upper()
                    if call and dmr_id:
                        ids[call] = dmr_id
                        by_id[dmr_id] = call
        _DMR_IDS = ids
        _DMR_CALL_BY_ID = by_id
        _DMR_IDS_MTIME = mtime
        return ids
    except Exception:
        _DMR_IDS = {}
        _DMR_CALL_BY_ID = {}
        return _DMR_IDS

def lookup_dmr_id(call):
    """按呼号查 DMR ID"""
    if not call or call == '-':
        return ''
    ids = _load_dmr_ids()
    return ids.get(call.upper(), '')

def _load_radioid_cache():
    """加载本地缓存：/opt/tetra（只读种子） + 热点自己的可写缓存。"""
    with _RADIOID_LOCK:
        if _RADIOID_MEM_CACHE:
            return _RADIOID_MEM_CACHE
        out = {}
        for path in (_RADIOID_SEED_FILE, _RADIOID_CACHE_FILE):
            try:
                with open(path, encoding="utf-8") as f:
                    d = json.load(f)
                if isinstance(d, dict):
                    for k, v in d.items():
                        call = str(v or "").strip().upper()
                        if call:
                            out[str(k).strip()] = call
            except Exception:
                pass
        _RADIOID_MEM_CACHE.clear()
        _RADIOID_MEM_CACHE.update(out)
        return _RADIOID_MEM_CACHE


def _save_radioid_cache():
    try:
        os.makedirs(os.path.dirname(_RADIOID_CACHE_FILE), exist_ok=True)
        tmp = _RADIOID_CACHE_FILE + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(_RADIOID_MEM_CACHE, f, ensure_ascii=False, indent=2)
        os.replace(tmp, _RADIOID_CACHE_FILE)
    except Exception:
        pass


def _fetch_radioid_call(dmr_id):
    try:
        url = ("https://database.radioid.net/api/dmr/user/?id=" +
               urllib.parse.quote(str(dmr_id)))
        req = urllib.request.Request(url, headers={"User-Agent": "ARCH-Hotspot/1.0"})
        with urllib.request.urlopen(req, timeout=6) as r:
            data = json.loads(r.read().decode("utf-8", "ignore"))
        rows = data.get("results") or []
        if rows:
            call = str(rows[0].get("callsign") or "").strip().upper()
            if call:
                with _RADIOID_LOCK:
                    _RADIOID_MEM_CACHE[str(dmr_id)] = call
                    _save_radioid_cache()
    except Exception:
        pass
    finally:
        with _RADIOID_LOCK:
            _RADIOID_PENDING.discard(str(dmr_id))


def lookup_dmr_call(dmr_id):
    """数字 DMR/P25 ID 查呼号；未命中时后台查询 RadioID.net 并缓存。"""
    key = str(dmr_id or "").strip()
    if not key.isdigit():
        return ""
    try:
        _load_dmr_ids()
    except Exception:
        pass
    call = _DMR_CALL_BY_ID.get(key, "")
    if call:
        return call
    cache = _load_radioid_cache()
    call = cache.get(key, "")
    if call:
        return call
    with _RADIOID_LOCK:
        if key in _RADIOID_PENDING or len(_RADIOID_PENDING) >= 10:
            return ""
        _RADIOID_PENDING.add(key)
    threading.Thread(target=_fetch_radioid_call, args=(key,), daemon=True).start()
    return ""


def parse_qso_line(line):
    """解析 MMDVM-Host 的 QSO 日志行（兼容 DMR / YSF / D-STAR）"""
    # 多信道：状态脚本会给每条前面加 "[CH2] "，先取出通道号
    ch = ''
    mch = re.search(r'\[CH(\d+)\]', line)   # FIX_FM_CH
    if mch:
        ch = 'CH' + mch.group(1)
        line = line[:mch.start()] + line[mch.end():]   # FIX_FM_CH 只删标记，保留时间戳

    # 提取时间（★ MMDVM 日志里的时间是 UTC，必须换算成本机时区，
    #   否则看板时间会比实际少 8 小时。用 time.timezone（本机 UTC 偏移,秒,西正东负）换算，
    #   对东八区 = UTC+8，且不受夏令时影响（中国无夏令时）。）
    times = re.findall(r'(\d{2}:\d{2}:\d{2})', line)
    t_str = times[-1] if times else ''
    if t_str:
        try:
            import time as _t
            _h, _m, _s = [int(_x) for _x in t_str.split(':')]
            _loc = (_h * 3600 + _m * 60 + _s - int(_t.timezone)) % 86400
            t_str = "%02d:%02d:%02d" % (_loc // 3600, (_loc % 3600) // 60, _loc % 60)
        except Exception:
            pass

    # 检测模式
    low = line.lower()
    if 'd-star' in low or 'dstar' in low:
        mode = 'D-Star'
    elif 'dmr slot 1' in low:
        mode = 'DMR S1'
    elif 'dmr slot 2' in low:
        mode = 'DMR S2'
    elif 'dmr' in low:
        mode = 'DMR'
    elif 'ysf' in low:
        mode = 'YSF'
    elif 'p25' in low:
        mode = 'P25'
    elif 'nxdn' in low:
        mode = 'NXDN'
    elif 'pocsag' in low:
        mode = 'POCSAG'
    elif 'fm' in low:
        mode = 'FM'
    else:
        mode = 'UNKNOWN'

    # 方向：RF 还是 Net
    if 'received network' in low:
        src = 'Net'
    elif 'received rf' in low:
        src = 'RF'
    else:
        src = 'Local'

    # 提取呼号
    call = '-'
    m = re.search(r'from\s+([A-Z0-9][A-Z0-9/\-]+)', line, re.I)
    if m:
        call = m.group(1).strip()
    original_id = call if call.isdigit() else ""
    if original_id:
        resolved = lookup_dmr_call(original_id)
        if resolved:
            call = resolved

    # 提取目标
    target = '-'
    m = re.search(r'to\s+((?:TG|PC|All)\s+\d+)', line, re.I)
    if m:
        target = m.group(1).strip()
    else:
        # ★ YSF 的 "to DG-ID 0 at <呼号>" 单独识别，避免目标显示成 '-'
        m = re.search(r'to\s+(DG-ID\s+\d+)', line, re.I)
        if m:
            target = m.group(1).strip()
        else:
            m = re.search(r'to\s+([A-Z0-9][A-Z0-9/\-]*?)(?:\s*,\s*\d|\.|\s*$)', line, re.I)
            if m:
                target = m.group(1).strip()

    # 提取时长
    duration = '-'
    m = re.search(r'(\d+(?:\.\d+)?)\s*seconds', line, re.I)
    if m:
        duration = f"{float(m.group(1)):.1f}s"
    elif ('header' in low
          or 'received network transmission' in low
          or 'received rf voice transmission' in low
          or 'received network data from' in low):
        # ★ YSF / P25 / NXDN 网络侧没有 header 行，只有“开始行”，
        #   这里一并判为“通话中”，否则这些模式在通联列表里不会变红。
        duration = '通话中'

    # 提取 BER
    ber = '-'
    m = re.search(r'BER[:\s]+(\d+(?:\.\d+)?)%?', line, re.I)
    if m:
        ber = f"{m.group(1)}%"

    # 提取 DMR ID
    dmr_id = original_id
    if not dmr_id:
        try:
            dmr_id = lookup_dmr_id(call)
        except Exception:
            pass

    return {
        'time': t_str,
        'mode': mode,
        'ch': ch,
        'src': src,
        'call': call,
        'target': target,
        'duration': duration,
        'ber': ber,
        'id': dmr_id,
        '_watchdog': ('watchdog' in low and 'expired' in low),
    }

def read_channel_plan():
    try:
        with open('/etc/mmdvm/channels.json', encoding='utf-8') as f:
            data = json.load(f)
        return data if isinstance(data, dict) else {}
    except Exception:
        return {}


def plan_primary():
    plan = read_channel_plan()
    items = []
    for item in plan.get('list', []):
        try:
            if str(item.get('mode', '')).upper() not in ('', 'NONE'):
                items.append(item)
        except Exception:
            pass
    items.sort(key=lambda x: int(x.get('n', 0)))
    return plan, (items[0] if items else None)


def read_ini_value(path, section, key):
    try:
        if not os.path.exists(path):
            return None
        cur = None
        tsec = section.lower()
        tkey = key.lower()
        with open(path, encoding="utf-8", errors="ignore") as fh:
            for line in fh:
                s = line.strip()
                if not s or s.startswith(";") or s.startswith("#"):
                    continue
                if s.startswith("[") and s.endswith("]"):
                    cur = s[1:-1].lower()
                    continue
                if "=" in s and cur == tsec:
                    k, v = s.split("=", 1)
                    if k.strip().lower() == tkey:
                        return v.strip()
    except Exception:
        pass
    return None

def read_freq_hz():
    p = "/etc/MMDVM-Host.ini"
    rxv = read_ini_value(p, "Modem", "RXFrequency") or read_ini_value(p, "Info", "RXFrequency")
    txv = read_ini_value(p, "Modem", "TXFrequency") or read_ini_value(p, "Info", "TXFrequency")
    try: rx = int(rxv) if rxv else 0
    except: rx = 0
    try: tx = int(txv) if txv else 0
    except: tx = 0
    return rx, tx

def read_callsign_id():
    p = "/etc/MMDVM-Host.ini"
    return (read_ini_value(p, "General", "Callsign") or "",
            read_ini_value(p, "General", "Id") or "0")


def read_host_ini_via_sudo():
    """用 sudo cat 读 /etc/MMDVM-Host.ini（无论文件权限如何）"""
    import subprocess as _sp
    for cat_path in ['/bin/cat', '/usr/bin/cat']:
        try:
            r = _sp.run(
                ['sudo', cat_path, '/etc/MMDVM-Host.ini'],
                capture_output=True, text=True, timeout=5
            )
            if r.returncode == 0 and r.stdout:
                return r.stdout
        except Exception:
            continue
    return None

def parse_host_ini(content):
    """从 INI 内容解析 rx_hz/tx_hz/callsign/id"""
    result = {'rx_hz': 0, 'tx_hz': 0, 'callsign': '', 'id': '0'}
    if not content:
        return result
    cur = None
    for line in content.split('\n'):
        s = line.strip()
        if not s or s.startswith(';') or s.startswith('#'):
            continue
        if s.startswith('[') and s.endswith(']'):
            cur = s[1:-1].lower()
            continue
        if '=' not in s:
            continue
        k, v = s.split('=', 1)
        k = k.strip().lower()
        v = v.strip()
        if cur == 'modem':
            if k == 'rxfrequency':
                try: result['rx_hz'] = int(v)
                except: pass
            elif k == 'txfrequency':
                try: result['tx_hz'] = int(v)
                except: pass
        elif cur == 'info' and result['rx_hz'] == 0:
            if k == 'rxfrequency':
                try: result['rx_hz'] = int(v)
                except: pass
            elif k == 'txfrequency':
                try: result['tx_hz'] = int(v)
                except: pass
        elif cur == 'general':
            if k == 'callsign' and not result['callsign']:
                result['callsign'] = v
            elif k == 'id' and result['id'] == '0':
                result['id'] = v
    return result

def _merge_qso(items):
    """Merge voice headers and end lines. get_qso() returns newest first."""
    out = []
    for idx, it in enumerate(items):
        if it.get('_watchdog'):
            continue
        if str(it.get('duration')) != '通话中':
            out.append(it)
            continue
        ch, call, mode = it.get('ch'), it.get('call'), it.get('mode')
        ended = any(
            (not x.get('_watchdog')) and
            str(x.get('duration')) != '通话中' and
            x.get('ch') == ch and x.get('call') == call
            for x in items[:idx]
        )
        watchdog_ended = any(
            x.get('_watchdog') and x.get('ch') == ch and x.get('mode') == mode
            for x in items[:idx]
        )
        if not ended and not watchdog_ended:
            out.append(it)
    return out


# ★ 通话记录短缓存（0.6 秒）：前端 0.5 秒轮询时会把重复请求折叠成一次读取，
#   否则每次都要起一个 journalctl + python，Pi 上很容易把 CPU 打上去。
#   只缓存“读日志”的结果，红色高亮最多延迟 0.6 秒，肉眼无感。
_QSO_CACHE = {'t': 0.0, 'payload': None}
_QSO_LOCK = threading.Lock()
_QSO_TTL = 0.6


def _qso_payload():
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'qso'], timeout=20)
    items = []
    for raw_line in (res['stdout'] or '').split('\n'):
        line = raw_line.strip()
        if not line or line == '=== QSO ===' or line == '\u65e0\u901a\u8bdd\u8bb0\u5f55':
            continue
        try:
            item = parse_qso_line(line)
            if str(item.get('id') or '') in ('9990003', '999000300'):
                continue
            items.append(item)
        except Exception:
            pass
    return {'ok': True, 'qso': _merge_qso(items)}


@app.route('/api/qso')
def api_qso():
    """★ 轻量接口：只返回通话记录（供 Live QSO 轮询，带 0.6 秒折叠缓存）"""
    if _QSO_CACHE['payload'] is not None and (time.time() - _QSO_CACHE['t']) < _QSO_TTL:
        return jsonify(_QSO_CACHE['payload'])
    with _QSO_LOCK:                       # 同一时刻只允许一个真正去读日志
        if _QSO_CACHE['payload'] is not None and (time.time() - _QSO_CACHE['t']) < _QSO_TTL:
            return jsonify(_QSO_CACHE['payload'])
        payload = _qso_payload()
        _QSO_CACHE['t'] = time.time()
        _QSO_CACHE['payload'] = payload
        return jsonify(payload)


def get_telemetry(light=False):
    _status_action = "status-quick" if light else "status"
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', _status_action])
    services, profile, system, network, qso = {}, "未知", {}, {}, []
    current_sec = None
    if res['ok']:
        for raw_line in res['stdout'].split('\n'):
            line = raw_line.strip()
            if line == '=== SERVICES ===': current_sec = 'services'; continue
            if line == '=== PROFILE ===': current_sec = 'profile'; continue
            if line == '=== SYSTEM ===': current_sec = 'system'; continue
            if line == '=== NETWORK ===': current_sec = 'network'; continue
            if line == '=== RF FREQ ===': current_sec = 'rf_freq'; continue
            if line == '=== QSO ===': current_sec = 'qso'; continue
            if current_sec == 'services' and line:
                parts = line.split()
                if len(parts) == 2: services[parts[0]] = (parts[1] == 'active')
            elif current_sec == 'profile' and line:
                m = re.search(r'MODE=(\S+)', line)
                if m: profile = m.group(1)
            elif current_sec == 'system' and '=' in line:
                k, v = line.split('=', 1); system[k] = v
            elif current_sec == 'rf_freq' and '=' in line:
                k, v = line.split('=', 1); system[k] = v
            elif current_sec == 'network' and '=' in line:
                k, v = line.split('=', 1); network[k] = v
            elif current_sec == 'qso' and line and line != '无通话记录':
                qso.append(parse_qso_line(line))
    qso = _merge_qso(qso)          # ★ 合并头/尾，避免通话结束后仍显示"通话中"
    # ★ 从 /etc/MMDVM-Host.ini 读取实时频率
    rx_hz = 0; tx_hz = 0
    try:
        with open("/etc/MMDVM-Host.ini", encoding="utf-8", errors="ignore") as f:
            section = None
            for line in f:
                s = line.strip()
                if s.startswith("[") and s.endswith("]"):
                    section = s[1:-1].lower()
                    continue
                if section in ("modem", "info"):
                    if s.lower().startswith("rxfrequency=") and not rx_hz:
                        try: rx_hz = int(s.split("=",1)[1].strip())
                        except: pass
                    elif s.lower().startswith("txfrequency=") and not tx_hz:
                        try: tx_hz = int(s.split("=",1)[1].strip())
                        except: pass
    except Exception:
        pass
    system['rx_hz'] = rx_hz
    system['tx_hz'] = tx_hz
    system['offset_hz'] = (tx_hz - rx_hz) if rx_hz and tx_hz else 0

    # ★ 终极方案：sudo cat 直接读文件（不受权限影响）
    _content = read_host_ini_via_sudo()
    _parsed = parse_host_ini(_content) if _content else {'rx_hz':0,'tx_hz':0,'callsign':'','id':'0'}
    # 合并：status 输出优先，其次 sudo cat 结果
    try:
        _rx = int(system.get('rx_hz') or 0)
    except (ValueError, TypeError):
        _rx = 0
    try:
        _tx = int(system.get('tx_hz') or 0)
    except (ValueError, TypeError):
        _tx = 0
    if not _rx:
        _rx = _parsed['rx_hz']
    if not _tx:
        _tx = _parsed['tx_hz']
    # ★ 通道规划是射频事实来源；Host.ini 只作兼容回退。
    _plan, _primary = plan_primary()
    if _primary:
        try:
            _rx = int(_primary.get('rx') or 0)
            _tx = int(_primary.get('tx') or 0)
        except Exception:
            pass
    system['rx_hz'] = _rx
    system['tx_hz'] = _tx
    system['offset_hz'] = (_tx - _rx) if (_rx and _tx) else 0
    if _plan.get('callsign'):
        system['callsign'] = str(_plan.get('callsign'))
    elif not system.get('callsign'):
        system['callsign'] = _parsed['callsign'] or ''
    if _plan.get('dmr_id') and str(_plan.get('dmr_id')) not in ('', '0', '000000000'):
        system['id'] = str(_plan.get('dmr_id'))
    elif not system.get('id') or system.get('id') == '0':
        system['id'] = _parsed['id'] or '0'
    system['radio_id'] = system.get('id') or '0'
    if os.path.exists('/etc/mmdvm/soapy-sx-verified'):
        system['sx_probe'] = 'OK'

    return {'services': services, 'profile': profile,
            'system': system, 'network': network,
            'qso': None if light else qso[::-1]}

HTML = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ARCH 业余无线电融合中枢</title>
<style>
:root{
  --bg:#070a0f;
  --sidebar-bg:#0a0e14;
  --sidebar-hover:#121924;
  --card:#0d131b;
  --card-hi:#111823;
  --card-elev:#151d2a;
  --border:#18202c;
  --border-lit:#222b3a;
  --text:#e8eef8;
  --text-2:#c8d2df;
  --muted:#7d8899;
  --dim:#4a5464;
  --mono:ui-monospace,"SF Mono",Menlo,Consolas,"Courier New",monospace;

  /* 主题色（默认绿） */
  --theme-1:#10b981;
  --theme-2:#34d399;
  --theme-3:#059669;
  --theme-rgb:16,185,129;
}
/* 每个页面切换主题 */
body[data-theme="dashboard"]{--theme-1:#10b981;--theme-2:#34d399;--theme-3:#059669;--theme-rgb:16,185,129}
body[data-theme="rf"]       {--theme-1:#10b981;--theme-2:#34d399;--theme-3:#059669;--theme-rgb:16,185,129}
body[data-theme="dmr"]      {--theme-1:#10b981;--theme-2:#34d399;--theme-3:#059669;--theme-rgb:16,185,129}
body[data-theme="ysf"]      {--theme-1:#06b6d4;--theme-2:#22d3ee;--theme-3:#0891b2;--theme-rgb:6,182,212}
body[data-theme="p25"]      {--theme-1:#8b5cf6;--theme-2:#a78bfa;--theme-3:#7c3aed;--theme-rgb:139,92,246}
body[data-theme="nxdn"]     {--theme-1:#ec4899;--theme-2:#f472b6;--theme-3:#db2777;--theme-rgb:236,72,153}
body[data-theme="dstar"]    {--theme-1:#f97316;--theme-2:#fb923c;--theme-3:#ea580c;--theme-rgb:249,115,22}
body[data-theme="fm"]       {--theme-1:#10b981;--theme-2:#34d399;--theme-3:#059669;--theme-rgb:16,185,129}
body[data-theme="pocsag"]   {--theme-1:#f59e0b;--theme-2:#fbbf24;--theme-3:#d97706;--theme-rgb:245,158,11}
body[data-theme="ports"]    {--theme-1:#10b981;--theme-2:#34d399;--theme-3:#059669;--theme-rgb:16,185,129}
body[data-theme="db"]       {--theme-1:#10b981;--theme-2:#34d399;--theme-3:#059669;--theme-rgb:16,185,129}

*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%}
body{
  background:var(--bg);color:var(--text);
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif;
  font-size:15px;line-height:1.5;
  display:flex;overflow:hidden;
  -webkit-font-smoothing:antialiased;
  position:relative;
}

/* ==================== 全局背景光晕 ==================== */
body::before{content:'';position:fixed;inset:0;pointer-events:none;z-index:0;background:radial-gradient(900px 600px at 12% -8%,rgba(var(--theme-rgb),.10),transparent 62%),radial-gradient(760px 520px at 92% 6%,rgba(56,189,248,.06),transparent 60%)}
.sb-icon{
  width:36px;height:36px;border-radius:50%;flex-shrink:0;
  background:linear-gradient(135deg,var(--theme-2) 0%, var(--theme-1) 50%, var(--theme-3) 100%);
  color:#fff;display:grid;place-items:center;
  box-shadow:0 0 24px rgba(var(--theme-rgb),.5),inset 0 0 12px rgba(255,255,255,.2);
}
.sb-name{font-size:16.5px;font-weight:800;letter-spacing:.2px;color:#fff}
.sb-sub{font-size:13px;color:var(--muted);margin-top:2px}
.sb-brand::after{
  content:'';position:absolute;left:14px;right:14px;bottom:-1px;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.4),transparent);
}
.sb-brand{
  padding:14px 14px 16px;
  border-bottom:1px solid var(--border);
  display:flex;align-items:center;gap:10px;
  position:relative;
}

.sb-nav{padding:12px 8px;display:flex;flex-direction:column;gap:2px}
/* .sb-nav-label 已移除（用户要求删掉「」字样）*/
.sb-item{
  display:flex;align-items:center;gap:10px;
  padding:10px 12px;border-radius:8px;
  color:#c8d2df;font-size:16px;font-weight:600;
  cursor:pointer;transition:all .18s;
  user-select:none;position:relative;
}
.sb-item:hover{
  background:linear-gradient(90deg,rgba(var(--theme-rgb),.08),var(--sidebar-hover));
  color:#fff;
}
.sb-item.active{
  background:linear-gradient(135deg,var(--theme-2) 0%, var(--theme-1) 50%, var(--theme-3) 100%);
  color:#fff;font-weight:700;
  box-shadow:0 6px 20px rgba(var(--theme-rgb),.4), inset 0 1px 0 rgba(255,255,255,.15);
}
.sb-item .ico{width:16px;height:16px;flex-shrink:0;display:grid;place-items:center;font-size:15px;opacity:.85}
.sb-item.active .ico{opacity:1;filter:drop-shadow(0 0 4px rgba(255,255,255,.5))}

/* ==================== 主区 ==================== */
.main{
  flex:1;height:100vh;overflow-y:auto;
  position:relative;display:flex;flex-direction:column;
  z-index:1;
}
.main-top{
  position:sticky;top:0;z-index:20;
  background:rgba(7,10,15,.85);
  backdrop-filter:blur(18px);
  border-bottom:1px solid var(--border);
  padding:9px 20px;
  display:flex;align-items:center;justify-content:flex-end;
  min-height:42px;
}
.main-top::after{
  content:'';position:absolute;left:0;right:0;bottom:-1px;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.3),transparent);
}
.status-pill{
  display:inline-flex;align-items:center;gap:6px;
  padding:4px 12px;border-radius:20px;
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.15),rgba(var(--theme-rgb),.05));
  color:var(--theme-2);
  border:1px solid rgba(var(--theme-rgb),.4);
  font-size:13px;font-weight:800;letter-spacing:.4px;
  box-shadow:0 0 16px rgba(var(--theme-rgb),.15);
}
.status-pill .dot{
  width:6px;height:6px;border-radius:50%;
  background:linear-gradient(135deg,var(--theme-2),var(--theme-1));
  box-shadow:0 0 8px var(--theme-2);
  animation:pls 2s ease-in-out infinite;
}
@keyframes pls{0%,100%{opacity:1}50%{opacity:.45}}

.content{flex:1;padding:14px 18px 32px;width:100%;position:relative;z-index:1}

/* ==================== 卡片（带主题渐变边框） ==================== */
.card{
  background:linear-gradient(180deg,var(--card-hi) 0%,var(--card) 100%);
  border:1px solid var(--border);
  border-radius:11px;
  padding:16px 18px;
  margin-bottom:12px;
  position:relative;overflow:hidden;
}
.card::before{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.5),transparent);
}
.card::after{
  content:'';position:absolute;top:0;left:0;width:3px;height:100%;
  background:linear-gradient(180deg,transparent,rgba(var(--theme-rgb),.35) 50%,transparent);
  opacity:.6;
}
.card-head{
  display:flex;align-items:flex-start;justify-content:space-between;
  gap:12px;margin-bottom:12px;
}
.card-head-left{display:flex;align-items:flex-start;gap:10px}
.card-head-left .h-ico{
  width:30px;height:30px;border-radius:8px;
  display:grid;place-items:center;flex-shrink:0;
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.2),rgba(var(--theme-rgb),.08));
  color:var(--theme-2);font-size:16px;
  border:1px solid rgba(var(--theme-rgb),.3);
  box-shadow:0 0 12px rgba(var(--theme-rgb),.15);
}
.card-title{font-size:17px;font-weight:800;color:#fff;letter-spacing:.15px}
.card-sub{font-size:13px;color:var(--muted);margin-top:2px;line-height:1.5}
.card-badge{
  padding:4px 11px;border-radius:16px;
  font-size:11.5px;font-weight:800;letter-spacing:.4px;
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.18),rgba(var(--theme-rgb),.06));
  color:var(--theme-2);
  border:1px solid rgba(var(--theme-rgb),.35);
  box-shadow:0 0 12px rgba(var(--theme-rgb),.1);
}

/* ==================== 模式切换 ==================== */
.mode-strip{display:grid;grid-template-columns:repeat(7,1fr);gap:7px;margin-top:4px}
.mode-pill{
  padding:11px 6px;border-radius:8px;
  border:1px solid var(--border-lit);
  background:linear-gradient(180deg,rgba(255,255,255,.02),rgba(255,255,255,.005));
  color:#9aa6b6;cursor:pointer;transition:all .16s;
  display:flex;flex-direction:column;align-items:center;gap:3px;
  font-weight:700;font-size:14px;position:relative;overflow:hidden;
}
.mode-pill:hover{
  border-color:rgba(var(--theme-rgb),.5);color:#fff;
  background:linear-gradient(180deg,rgba(var(--theme-rgb),.1),rgba(var(--theme-rgb),.03));
}
.mode-pill.active{
  background:linear-gradient(180deg,var(--theme-2) 0%,var(--theme-1) 50%,var(--theme-3) 100%);
  border-color:var(--theme-2);color:#fff;
  box-shadow:0 6px 20px rgba(var(--theme-rgb),.45),inset 0 1px 0 rgba(255,255,255,.2);
}
.mode-pill .st{font-size:11.5px;opacity:.85;font-weight:600}
/* ==================== 多信道矩阵 ==================== */
/* 频率模式列表：一行 3 宫格，两行排满 */
.mc-matrix{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin-top:12px}
@media(max-width:860px){.mc-matrix{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:560px){.mc-matrix{grid-template-columns:1fr}}
.mc-card .mc-icon{width:40px;height:40px;flex:0 0 40px;display:block}
.mc-mode-txt{font-size:14.5px;font-weight:800;color:#fff;white-space:nowrap}
.mc-off{font-size:14.5px;font-weight:800;color:#7d93b2;white-space:nowrap}
.mc-card.off{opacity:.62}
.mc-card{border:1px solid var(--border-lit);border-radius:10px;padding:13px;
  background:linear-gradient(180deg,rgba(var(--theme-rgb),.06),rgba(0,0,0,0))}
.mc-card.on{border-color:rgba(16,185,129,.5)}
.mc-hd{display:flex;align-items:center;justify-content:space-between;font-size:15px;font-weight:800;color:#fff;margin-bottom:7px;letter-spacing:.3px}
.mc-dot{width:7px;height:7px;border-radius:50%;display:inline-block;margin-right:6px;vertical-align:middle}
.mc-row{display:flex;gap:6px;margin-top:6px}
.mc-row select{flex:1;min-width:0}
.mc-row .btn{padding:7px 12px;font-size:15.5px;font-weight:700;color:#fff;white-space:nowrap}
.mc-freq{font-family:var(--mono);font-size:15px;font-weight:700;color:#fff;margin-top:7px;line-height:1.55;letter-spacing:.2px}
.mc-empty{padding:16px;text-align:center;color:var(--dim);font-size:13px}
.mc-foot{display:flex;align-items:center;gap:9px;margin-top:12px;flex-wrap:wrap}
.mc-tip{font-size:13.5px;color:var(--dim)}
.bm-bar{margin:0 0 12px;padding:9px 12px;border-radius:8px;font-size:13px;line-height:1.6;
  border:1px solid var(--border-lit);background:#0b1420}
.bm-bar.ok{border-color:rgba(16,185,129,.55);color:#a7f3d0}
.bm-bar.bad{border-color:rgba(239,68,68,.55);color:#fecaca}
.bm-bar b{font-weight:700}
/* ==================== Live QSO 多信道布局（每通道一行 × 4 宫格） ==================== */
.qso-list{display:block !important;grid-template-columns:none !important}
.qso-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.qso-cell{display:flex;flex-direction:column;gap:6px}
.qso-cell .qso-item,.qso-cell .qso-cell-empty{height:100%;min-height:104px}
.qso-prev{font-size:13.5px;color:#9fb3cc}
.qso-prev b{color:#e8eef8;font-family:var(--mono)}
.qso-slot-lbl{display:flex;align-items:center;gap:7px}
.qso-slot-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:#64748b}
.qso-slot-dot.live{background:#f87171;box-shadow:0 0 8px rgba(239,68,68,.85);animation:none}
.qso-slot-dot.idle{background:#34d399;box-shadow:0 0 6px rgba(52,211,153,.5)}
.qso-slot-state{font-size:12px;font-weight:700;border-radius:4px;padding:1px 7px;margin-left:auto;white-space:nowrap}
.qso-slot-state.live{background:rgba(239,68,68,.16);color:#fca5a5;border:1px solid rgba(239,68,68,.4)}
.qso-slot-state.idle{background:rgba(52,211,153,.12);color:#6ee7b7;border:1px solid rgba(52,211,153,.3)}
.qso-live-badge{font-size:11px;font-weight:800;color:#fecaca;background:rgba(239,68,68,.22);border:1px solid rgba(239,68,68,.45);border-radius:4px;padding:1px 7px}
.qso-ch{margin-bottom:14px}
.qso-ch:last-child{margin-bottom:0}
.qso-ch-head{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700;
  color:#cfe2ff;margin:0 0 8px;flex-wrap:wrap}
.qso-ch-dot{width:8px;height:8px;border-radius:50%;
  background:linear-gradient(135deg,var(--theme-2),var(--theme-1));
  box-shadow:0 0 8px var(--theme-2);flex-shrink:0}
.qso-ch-cnt{margin-left:auto;font-size:11.5px;font-weight:600;color:var(--dim)}
.qso-ch-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
.qso-cell-empty{min-height:104px;display:flex;align-items:center;justify-content:center;gap:7px;
  border:1px dashed var(--border-lit);border-radius:13px;background:#08101c;
  color:var(--dim);font-size:12px}
.qso-cell-empty .dot{width:6px;height:6px;border-radius:50%;
  background:linear-gradient(135deg,var(--theme-2),var(--theme-1));opacity:.6;
  box-shadow:0 0 8px var(--theme-2);animation:none}
@media (max-width:1200px){ .qso-ch-grid{grid-template-columns:repeat(2,minmax(0,1fr))} }
@media (max-width:600px){ .qso-ch-grid{grid-template-columns:1fr} }

/* ==================== 频率与信道 ==================== */
.freq-now{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px}
.freq-chip{display:inline-flex;align-items:center;gap:6px;padding:7px 12px;border-radius:8px;
  border:1px solid var(--border-lit);background:#0b1420;font-size:12.5px;font-family:var(--mono)}
.freq-chip b{color:#6ee7b7}
.freq-rows{display:flex;flex-direction:column;gap:8px;margin-top:14px}
.freq-row{display:grid;grid-template-columns:84px 1fr 1fr 1fr auto;gap:8px;align-items:center;
  padding:9px 11px;border:1px solid var(--border-lit);border-radius:9px;background:#0a121d}
.freq-row .fr-n{font-size:14px;font-weight:700;color:#9fb6d6}
.freq-row select,.freq-row input{min-width:0}
@media (max-width:900px){
  .freq-row{grid-template-columns:1fr 1fr;grid-auto-rows:auto}
  .freq-row .fr-n{grid-column:1/-1}
}
.freq-strip{margin:0 0 12px;padding:8px 12px;border-radius:8px;border:1px dashed var(--border-lit);
  background:#08101c;color:#8fa8c8;font-size:12px;font-family:var(--mono);line-height:1.6}
.freq-strip::before{content:'各通道频率（在 Multi 多信道配置 → 信道规划 里设置）： ';color:#6ee7b7;font-weight:700}

/* ==================== 数据库条 ==================== */
.db-bar{
  display:flex;align-items:center;gap:12px;
  padding:12px 18px;border-radius:11px;margin-bottom:12px;
  background:linear-gradient(90deg,rgba(var(--theme-rgb),.12) 0%,rgba(var(--theme-rgb),.03) 50%,transparent 100%);
  border:1px solid rgba(var(--theme-rgb),.35);
  position:relative;overflow:hidden;
}
.db-bar::before{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.6),transparent);
}
.db-bar-ico{
  width:34px;height:34px;border-radius:9px;flex-shrink:0;
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.25),rgba(var(--theme-rgb),.08));
  color:var(--theme-2);display:grid;place-items:center;
  border:1px solid rgba(var(--theme-rgb),.4);
  box-shadow:0 0 14px rgba(var(--theme-rgb),.2);
}
.db-bar-txt{flex:1}
.db-bar-txt b{display:block;font-size:14.5px;font-weight:800;color:#fff}
.db-bar-txt span{display:block;font-size:13px;color:var(--muted);margin-top:2px}
.db-bar-tag{
  display:inline-block;margin-left:7px;padding:2px 7px;
  font-size:11.5px;font-weight:800;color:var(--theme-2);
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.2),rgba(var(--theme-rgb),.05));
  border-radius:4px;border:1px solid rgba(var(--theme-rgb),.4);
}

/* ==================== 按钮 ==================== */
.btn{
  display:inline-flex;align-items:center;justify-content:center;gap:6px;
  padding:8px 16px;border-radius:8px;
  font-weight:700;font-size:13px;
  transition:all .16s;border:1px solid transparent;cursor:pointer;
}
.btn-green{
  background:linear-gradient(135deg,var(--theme-2) 0%,var(--theme-1) 50%,var(--theme-3) 100%);
  color:#fff;border-color:var(--theme-1);
  box-shadow:0 3px 14px rgba(var(--theme-rgb),.4);
}
.btn-green:hover{box-shadow:0 5px 22px rgba(var(--theme-rgb),.55);transform:translateY(-1px)}
.btn-blue{
  background:linear-gradient(135deg,#60a5fa 0%,#3b82f6 50%,#2563eb 100%);
  color:#fff;border-color:#3b82f6;
  box-shadow:0 3px 14px rgba(59,130,246,.35);
}
.btn-blue:hover{box-shadow:0 5px 22px rgba(59,130,246,.5);transform:translateY(-1px)}
.btn-ghost{
  background:linear-gradient(180deg,rgba(255,255,255,.04),rgba(255,255,255,.01));
  color:#c8d2df;border-color:var(--border-lit);
}
.btn-ghost:hover{background:linear-gradient(180deg,rgba(255,255,255,.08),rgba(255,255,255,.03));color:#fff}
.btn-sm{padding:6px 11px;font-size:13px;border-radius:7px}

/* ==================== 统计卡 ==================== */
.stat-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:11px;margin-bottom:12px}
.stat{
  background:linear-gradient(180deg,var(--card-hi),var(--card));
  border:1px solid var(--border);
  border-radius:10px;padding:13px 15px;
  display:flex;align-items:center;gap:12px;
  position:relative;overflow:hidden;
}
.stat::before{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.35),transparent);
}
.stat-ico{
  width:38px;height:38px;border-radius:10px;flex-shrink:0;
  display:grid;place-items:center;font-size:18px;
}
.stat-ico.r{background:linear-gradient(135deg,rgba(239,68,68,.2),rgba(239,68,68,.06));color:#f87171;border:1px solid rgba(239,68,68,.25)}
.stat-ico.b{background:linear-gradient(135deg,rgba(59,130,246,.2),rgba(59,130,246,.06));color:#60a5fa;border:1px solid rgba(59,130,246,.25)}
.stat-ico.a{background:linear-gradient(135deg,rgba(245,158,11,.2),rgba(245,158,11,.06));color:#fbbf24;border:1px solid rgba(245,158,11,.25)}
.stat-ico.g{background:linear-gradient(135deg,rgba(var(--theme-rgb),.22),rgba(var(--theme-rgb),.06));color:var(--theme-2);border:1px solid rgba(var(--theme-rgb),.3)}
.stat-ico.y{background:linear-gradient(135deg,rgba(34,211,238,.2),rgba(34,211,238,.06));color:#22d3ee;border:1px solid rgba(34,211,238,.25)}
/* ==================== 通话记录表格（紧凑）==================== */
.qso-table{width:100%;border-collapse:collapse;font-size:13.5px}
.qso-table th{position:sticky;top:0;z-index:1;background:#111c2e;color:var(--dim);font-weight:800;font-size:12.5px;letter-spacing:.4px;text-align:left;padding:5px 7px;border-bottom:1px solid var(--border);white-space:nowrap}
.qso-table td{padding:3px 7px;border-bottom:1px solid rgba(255,255,255,.05);white-space:nowrap;vertical-align:middle}
.qso-table tbody tr:nth-child(even) td{background:rgba(255,255,255,.018)}
.qso-table tbody tr:hover td{background:rgba(96,165,250,.08)}
.qso-table tbody tr.new td{background:rgba(52,211,153,.09)}
.qso-table td.mono{font-family:Consolas,monospace;color:#9fb3cc}
.qso-wrap{overflow-x:auto;border-top:1px solid var(--border)}
.qso-slot{font-size:11.5px;font-weight:800;color:#fbbf24;background:rgba(245,158,11,.14);border-radius:3px;padding:0 3px;margin-left:4px}
.stat-lbl{font-size:13.5px;color:var(--muted);font-weight:800;letter-spacing:.5px;text-transform:uppercase}
.stat-val{font-size:19px;font-weight:800;color:#fff;margin-top:3px;font-family:var(--mono);letter-spacing:-.2px}

/* ==================== QSO 卡片（方格平铺，每条不同渐变色） ==================== */
.qso-list{
  display:grid;
  grid-template-columns:repeat(4,minmax(0,1fr));
  gap:10px;
  max-width:100%;
}
@media (max-width:1200px){
  .qso-list{grid-template-columns:repeat(3,minmax(0,1fr))}
}
@media (max-width:900px){
  .qso-list{grid-template-columns:repeat(2,minmax(0,1fr))}
}
@media (max-width:600px){
  .qso-list{grid-template-columns:1fr}
}
.qso-item{
  --qso-c1:#10b981;
  --qso-c2:#34d399;
  --qso-rgb:16,185,129;
  background:linear-gradient(135deg,
    rgba(var(--qso-rgb),.06) 0%,
    rgba(var(--qso-rgb),.02) 40%,
    var(--card) 100%);
  border:1px solid rgba(var(--qso-rgb),.20);
  border-radius:11px;
  padding:12px 14px 11px 16px;
  transition:all .2s;
  position:relative;overflow:hidden;
  min-height:96px;
  display:flex;flex-direction:column;
}
/* 左侧渐变竖条 */
.qso-item::before{
  content:'';position:absolute;left:0;top:0;bottom:0;width:3px;
  background:linear-gradient(180deg,
    var(--qso-c2) 0%,
    var(--qso-c1) 50%,
    var(--qso-c2) 100%);
  box-shadow:0 0 6px rgba(var(--qso-rgb),.35);
}
/* 顶部渐变高光 */
.qso-item::after{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,
    transparent 0%,
    var(--qso-c2) 50%,
    transparent 100%);
  opacity:.4;
}
/* 对角光晕 */
.qso-item .glow{
  position:absolute;top:-30%;right:-20%;width:60%;height:80%;
  background:radial-gradient(circle,rgba(var(--qso-rgb),.05) 0%,transparent 70%);
  pointer-events:none;
}
.qso-item:hover{
  border-color:rgba(var(--qso-rgb),.45);
  background:linear-gradient(135deg,
    rgba(var(--qso-rgb),.12) 0%,
    rgba(var(--qso-rgb),.04) 40%,
    var(--card-hi) 100%);
  box-shadow:
    0 4px 14px rgba(var(--qso-rgb),.12),
    inset 0 0 12px rgba(var(--qso-rgb),.03);
  transform:translateY(-1px);
}
.qso-row1{display:flex;align-items:center;gap:8px;margin-bottom:8px}
.qso-country{
  display:inline-flex;align-items:center;gap:5px;
  padding:2px 8px;border-radius:4px;
  font-size:12px;font-weight:700;
  background:linear-gradient(135deg,rgba(255,255,255,.10),rgba(255,255,255,.03));
  color:#e8eef8;
  border:1px solid rgba(255,255,255,.15);
  white-space:nowrap;
  letter-spacing:.2px;
}
.qso-flag-img{
  width:18px;height:13px;border-radius:2px;
  object-fit:cover;flex-shrink:0;
  box-shadow:0 0 4px rgba(0,0,0,.4);
  border:1px solid rgba(255,255,255,.15);
  vertical-align:middle;
}
.qso-flag-emoji{
  font-size:13px;line-height:1;
}
.qso-country-name{
  font-family:-apple-system,BlinkMacSystemFont,"Microsoft YaHei",sans-serif;
}
.qso-id{
  display:inline-block;padding:1px 6px;border-radius:4px;
  font-size:12.5px;font-weight:700;font-family:var(--mono);
  background:linear-gradient(135deg,rgba(59,130,246,.25),rgba(59,130,246,.08));
  color:#93c5fd;
  border:1px solid rgba(59,130,246,.4);
  letter-spacing:.2px;
}
.qso-call{
  font-size:17px;font-weight:800;
  letter-spacing:.5px;font-family:var(--mono);
  background:linear-gradient(135deg,var(--qso-c2) 0%,var(--qso-c1) 100%);
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
  background-clip:text;
  filter:drop-shadow(0 0 4px rgba(var(--qso-rgb),.25));
}
.qso-tag{
  display:inline-block;padding:2px 9px;
  font-size:11.5px;font-weight:800;letter-spacing:.5px;
  border-radius:4px;font-family:var(--mono);
}
.qso-tag.mode{
  background:linear-gradient(135deg,rgba(var(--qso-rgb),.3),rgba(var(--qso-rgb),.1));
  color:var(--qso-c2);
  border:1px solid rgba(var(--qso-rgb),.5);
  box-shadow:0 0 8px rgba(var(--qso-rgb),.25);
}
.qso-tag.src-rf{
  background:linear-gradient(135deg,rgba(59,130,246,.25),rgba(59,130,246,.08));
  color:#60a5fa;border:1px solid rgba(59,130,246,.45);
}
.qso-tag.src-net{
  background:linear-gradient(135deg,rgba(16,185,129,.25),rgba(16,185,129,.08));
  color:#34d399;border:1px solid rgba(16,185,129,.45);
}
.qso-tag.src-local{
  background:linear-gradient(135deg,rgba(139,92,246,.25),rgba(139,92,246,.08));
  color:#a78bfa;border:1px solid rgba(139,92,246,.45);
}
.qso-time{
  margin-left:auto;font-family:var(--mono);font-size:11.5px;
  color:var(--muted);
  background:rgba(var(--qso-rgb),.1);
  padding:2px 7px;border-radius:4px;
  border:1px solid rgba(var(--qso-rgb),.2);
}
.qso-row2{
  display:flex;align-items:center;gap:10px;flex-wrap:wrap;
  font-size:13px;color:var(--muted);
  padding-top:8px;
  margin-top:auto;
  border-top:1px solid rgba(var(--qso-rgb),.15);
}
.qso-row2 b{
  color:#e8eef8;font-family:var(--mono);font-weight:700;
  text-shadow:0 0 10px rgba(var(--qso-rgb),.35);
}
.ber{padding:2px 7px;border-radius:4px;font-family:var(--mono);font-weight:800;font-size:12px}
.ber.good{background:linear-gradient(135deg,rgba(16,185,129,.25),rgba(16,185,129,.08));color:#34d399;border:1px solid rgba(16,185,129,.45)}
.ber.warn{background:linear-gradient(135deg,rgba(245,158,11,.25),rgba(245,158,11,.08));color:#fbbf24;border:1px solid rgba(245,158,11,.45)}
.ber.bad{background:linear-gradient(135deg,rgba(239,68,68,.25),rgba(239,68,68,.08));color:#f87171;border:1px solid rgba(239,68,68,.45)}
/* ==================== ★ Live QSO 看板：整体放大一号 ==================== */
#qsoPanel .card-title{font-size:19px}
#qsoPanel .card-badge{font-size:14px}
#qsoPanel .qso-ch-head{font-size:15.5px}
/* 时隙标签（时隙 1 (TS1) / 时隙 2 (TS2)）：大 0.5 号 + 红色 */
#qsoPanel .qso-slot-lbl{font-size:13px;font-weight:800;color:#cfe2ff;letter-spacing:.4px;margin:3px 0 5px}
#qsoPanel .qso-ch-cnt{font-size:13px}
#qsoPanel .qso-call{font-size:19px}
#qsoPanel .qso-tag{font-size:13px;padding:2px 10px}
#qsoPanel .qso-time{font-size:14px;padding:2px 8px}
#qsoPanel .qso-id{font-size:13px}
#qsoPanel .qso-country{font-size:13.5px;padding:2px 9px}
#qsoPanel .qso-flag-emoji{font-size:15px}
#qsoPanel .qso-flag-img{width:20px;height:15px}
#qsoPanel .qso-row2{font-size:14.5px}
#qsoPanel .qso-row2 b{font-size:13.5px}
#qsoPanel .ber{font-size:13.5px;padding:2px 8px}
#qsoPanel .qso-empty{font-size:15.5px}
#qsoPanel .qso-cell-empty{font-size:14.5px}
#qsoPanel .qso-empty .dot{width:7px;height:7px}
#qsoPanel .qso-ch-dot{width:9px;height:9px}
/* ★ 正在工作的「宫格」（格子本身）：淡红底（不动通道外框） */
#qsoPanel .qso-item.qso-live{
  background:linear-gradient(180deg,rgba(239,68,68,.50),rgba(239,68,68,.24)) !important;
  border-color:rgba(239,68,68,.78) !important;
  box-shadow:0 0 0 2px rgba(239,68,68,.48),0 0 22px rgba(239,68,68,.38) !important;
}
#qsoPanel .qso-item.qso-live .qso-time{background:rgba(239,68,68,.22);color:#fecaca;border-color:rgba(239,68,68,.45)}
#qsoPanel .qso-item.qso-live .glow{opacity:1}
/* ==================== 系统设置：系统信息 / 网络状态（白字、大一号）==================== */
#sysInfo,#sysNet{font-size:16px !important;color:#fff !important;line-height:1.9}
#sysInfo b,#sysNet b,#sysInfo span,#sysNet span{color:#fff}
#sysInfo .kvw{display:inline-block;min-width:74px;color:#9fb3cc}
/* 网络状态 / 系统信息 的对齐行样式 */
.netrow{display:flex;align-items:center;gap:9px;padding:3px 0;flex-wrap:wrap}
.netico{width:20px;text-align:center;font-size:15px;opacity:.95;flex-shrink:0}
.netlab{min-width:44px;color:#9fb3cc;font-weight:600;flex-shrink:0}
.netdot{width:8px;height:8px;border-radius:50%;flex-shrink:0;background:#64748b}
.netdot.ok{background:#34d399;box-shadow:0 0 7px rgba(52,211,153,.65)}
.netdot.off{background:#64748b;box-shadow:none}
.netdot-none{background:transparent}
.netval{font-family:var(--mono);font-weight:700;color:#fff;min-width:150px;letter-spacing:.3px}
.netval.off{color:#9fb3cc;font-weight:600}
.netval.mono{min-width:0}
.netmeta{color:#c9d6e6;font-size:14px}
.netmeta-k{color:#9fb3cc;margin-right:4px}
.netsep{margin:0 8px;color:#64748b}
#sysNet .dimtxt{color:#9fb3cc}
.qso-empty{padding:36px 20px;text-align:center;color:var(--dim);font-size:14px;grid-column:1/-1}
.qso-empty .dot{
  display:inline-block;width:6px;height:6px;border-radius:50%;
  background:linear-gradient(135deg,var(--theme-2),var(--theme-1));
  margin-right:6px;box-shadow:0 0 8px var(--theme-2);
  animation:pls 1.8s ease-in-out infinite;
}

/* ==================== 协议页 ==================== */
.proto-page{display:none}
.proto-page.active{display:block}
.freq-banner{
  display:flex;align-items:center;gap:12px;
  padding:12px 16px;border-radius:10px;margin-bottom:12px;
  background:linear-gradient(90deg,rgba(var(--theme-rgb),.08) 0%,var(--card-hi) 60%,var(--card) 100%);
  border:1px solid rgba(var(--theme-rgb),.25);
  position:relative;overflow:hidden;
}
.freq-banner::before{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.5),transparent);
}
.freq-banner-ico{
  width:32px;height:32px;border-radius:8px;flex-shrink:0;
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.25),rgba(var(--theme-rgb),.08));
  color:var(--theme-2);display:grid;place-items:center;font-size:15px;
  border:1px solid rgba(var(--theme-rgb),.3);
}
.freq-banner-txt{flex:1}
.freq-banner-txt > b{
  display:block;font-size:14px;color:#fff;font-weight:800;
  margin-bottom:4px;letter-spacing:.15px;
}
.freq-banner-txt > span{
  display:block;font-size:13.5px;color:var(--muted);
  font-family:var(--mono);line-height:1.75;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.freq-banner-txt > span > b{
  display:inline;
  color:#fff;font-weight:800;
  padding:0 2px;
  font-family:var(--mono);
}

.preset-box{
  background:linear-gradient(135deg,rgba(var(--theme-rgb),.06),rgba(var(--theme-rgb),.01));
  border:1px solid rgba(var(--theme-rgb),.3);
  border-radius:10px;padding:12px 16px;margin-bottom:12px;
  position:relative;overflow:hidden;
}
.preset-box::before{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.5),transparent);
}
.preset-box-lbl{
  display:flex;align-items:center;gap:8px;
  font-size:12.5px;font-weight:800;color:var(--theme-2);
  margin-bottom:8px;letter-spacing:.2px;
}
.preset-box select{
  width:100%;padding:9px 12px;border-radius:8px;
  background:rgba(0,0,0,.4);color:#fff;
  border:1px solid var(--border-lit);
  font-size:14.5px;outline:none;
}
.preset-box select:focus{border-color:var(--theme-1);box-shadow:0 0 0 3px rgba(var(--theme-rgb),.15)}

/* ==================== 表单 ==================== */
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px}
.form-grid .fld{display:flex;flex-direction:column;gap:5px}
.form-grid .fld.full{grid-column:1/-1}
.form-grid label{font-size:13px;color:var(--muted);font-weight:700;letter-spacing:.35px}
.form-grid input,
.form-grid select{
  background:linear-gradient(180deg,rgba(0,0,0,.42),rgba(0,0,0,.32));
  border:1px solid var(--border-lit);
  color:#fff;padding:10px 12px;border-radius:8px;
  font-size:15px;outline:none;transition:all .14s;
  min-height:40px;
}
.form-grid input:focus,
.form-grid select:focus{
  border-color:var(--theme-1);
  box-shadow:0 0 0 3px rgba(var(--theme-rgb),.15), inset 0 0 8px rgba(var(--theme-rgb),.05);
}
.form-grid .hint{font-size:13px;color:var(--dim);margin-top:-1px}

/* ==================== 保存按钮（横向多色渐变） ==================== */
.save-row{margin-top:16px;display:flex;align-items:center;gap:10px}
.save-btn{
  flex:1;padding:12px 20px;border-radius:10px;
  color:#fff;font-weight:800;font-size:13.5px;
  letter-spacing:.3px;cursor:pointer;border:none;
  display:inline-flex;align-items:center;justify-content:center;gap:7px;
  transition:all .2s;position:relative;overflow:hidden;
}
.save-btn::before{
  content:'';position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.15),transparent);
  transform:translateX(-100%);transition:transform .6s;
}
.save-btn:hover::before{transform:translateX(100%)}
/* 每个按钮都是横向多色渐变，更华丽 */
.save-btn.rf    {background:linear-gradient(90deg,#047857 0%,#10b981 40%,#34d399 70%,#06b6d4 100%);box-shadow:0 5px 20px rgba(16,185,129,.35)}
.save-btn.dmr   {background:linear-gradient(90deg,#047857 0%,#10b981 40%,#34d399 70%,#06b6d4 100%);box-shadow:0 5px 20px rgba(16,185,129,.35)}
.save-btn.ysf   {background:linear-gradient(90deg,#0891b2 0%,#06b6d4 35%,#22d3ee 65%,#10b981 100%);box-shadow:0 5px 20px rgba(6,182,212,.35)}
.save-btn.p25   {background:linear-gradient(90deg,#6d28d9 0%,#8b5cf6 35%,#a78bfa 65%,#06b6d4 100%);box-shadow:0 5px 20px rgba(139,92,246,.35)}
.save-btn.nxdn  {background:linear-gradient(90deg,#be185d 0%,#ec4899 30%,#a78bfa 60%,#10b981 100%);box-shadow:0 5px 20px rgba(236,72,153,.35)}
.save-btn.dstar {background:linear-gradient(90deg,#c2410c 0%,#f97316 35%,#fbbf24 65%,#10b981 100%);box-shadow:0 5px 20px rgba(249,115,22,.35)}
.save-btn.pocsag{background:linear-gradient(90deg,#b45309 0%,#f59e0b 35%,#fbbf24 60%,#34d399 100%);box-shadow:0 5px 20px rgba(245,158,11,.35)}
.save-btn:hover{transform:translateY(-1px);filter:brightness(1.1)}

.refresh-btn{
  padding:12px 18px;border-radius:10px;
  background:linear-gradient(180deg,rgba(255,255,255,.05),rgba(255,255,255,.02));
  border:1px solid var(--border-lit);
  color:#c8d2df;font-weight:700;font-size:12.5px;
  display:inline-flex;align-items:center;gap:5px;
  cursor:pointer;transition:all .14s;
}
.refresh-btn:hover{
  background:linear-gradient(180deg,rgba(var(--theme-rgb),.12),rgba(var(--theme-rgb),.04));
  border-color:rgba(var(--theme-rgb),.5);color:#fff;
}

/* ==================== 端口卡 ==================== */
.port-card{
  background:linear-gradient(135deg,var(--card-hi),var(--card));
  border:1px solid var(--border-lit);
  border-radius:9px;padding:12px 16px;
  display:flex;align-items:center;gap:14px;
  margin-bottom:8px;transition:all .18s;
  position:relative;overflow:hidden;
}
.port-card::before{
  content:'';position:absolute;left:0;top:0;bottom:0;width:2px;
  background:linear-gradient(180deg,transparent,rgba(var(--theme-rgb),.6) 50%,transparent);
  opacity:0;transition:opacity .2s;
}
.port-card:hover{border-color:rgba(var(--theme-rgb),.4);box-shadow:0 4px 14px rgba(var(--theme-rgb),.08)}
.port-card:hover::before{opacity:1}
.port-num{
  font-family:var(--mono);font-size:14px;font-weight:800;
  color:var(--theme-2);flex-shrink:0;min-width:60px;
  background:linear-gradient(135deg,var(--theme-2),var(--theme-1));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
  background-clip:text;
}
.port-info{flex:1}
.port-info b{display:block;font-size:13px;color:#fff}
.port-info span{display:block;font-size:13px;color:var(--muted);margin-top:2px}
.port-badge{
  padding:4px 11px;border-radius:14px;
  font-size:12px;font-weight:800;letter-spacing:.4px;
  font-family:var(--mono);
}
.port-badge.listen{
  background:linear-gradient(135deg,rgba(16,185,129,.22),rgba(16,185,129,.08));
  color:#34d399;border:1px solid rgba(16,185,129,.4);
  box-shadow:0 0 10px rgba(16,185,129,.2);
}
.port-badge.closed{
  background:linear-gradient(135deg,rgba(90,101,119,.12),rgba(90,101,119,.04));
  color:#6b7280;border:1px solid rgba(90,101,119,.25);
}

/* ==================== 数据库页 ==================== */
.db-cards{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.db-card{
  background:linear-gradient(135deg,var(--card-hi),var(--card));
  border:1px solid var(--border-lit);
  border-radius:10px;padding:16px 18px;
  position:relative;overflow:hidden;transition:all .18s;
}
.db-card::before{
  content:'';position:absolute;top:0;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.4),transparent);
}
.db-card:hover{border-color:rgba(var(--theme-rgb),.4);box-shadow:0 6px 20px rgba(var(--theme-rgb),.1)}
.db-card h3{font-size:14.5px;font-weight:800;color:#fff;margin-bottom:5px;display:flex;align-items:center;gap:8px}
.db-card p{font-size:13px;color:var(--muted);margin-bottom:14px;line-height:1.55}
.db-card .hdr-tag{
  margin-left:auto;padding:2px 8px;border-radius:4px;
  font-size:11.5px;font-weight:800;letter-spacing:.4px;
}
.db-card .hdr-tag.blue{
  background:linear-gradient(135deg,rgba(59,130,246,.22),rgba(59,130,246,.06));
  color:#60a5fa;border:1px solid rgba(59,130,246,.35);
}
.db-card .hdr-tag.green{
  background:linear-gradient(135deg,rgba(16,185,129,.22),rgba(16,185,129,.06));
  color:#34d399;border:1px solid rgba(16,185,129,.35);
}

.console{
  background:linear-gradient(180deg,#020609,#010407);
  border:1px solid var(--border);
  border-radius:9px;padding:14px 18px;
  font-family:var(--mono);font-size:13.5px;color:#a5d9b9;
  white-space:pre;                    /* ★ 关键：不自动换行 */
  overflow:auto;
  min-height:120px;max-height:520px;
  line-height:1.75;                    /* ★ 关键：加大行高 */
  margin-top:12px;
  box-shadow:inset 0 0 20px rgba(var(--theme-rgb),.05);
  word-break:normal;
  overflow-wrap:normal;
}
.console > div{                      /* 每行独立 div */
  padding:1px 0;
  line-height:1.75;
  white-space:pre;
}
.console .line-odd{opacity:.95}
.console .line-even{opacity:.85}
.console .tag-in{color:var(--theme-2);font-weight:700}

/* ==================== 页脚 ==================== */
.footer{
  margin-top:18px;padding:18px 0 6px;
  border-top:1px solid var(--border);
  text-align:center;color:var(--muted);
  font-size:13px;line-height:1.8;
  position:relative;
}
.footer::before{
  content:'';position:absolute;top:-1px;left:0;right:0;height:1px;
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.3),transparent);
}
.footer .title{
  color:#c8d2df;font-weight:700;font-size:12.5px;
  display:inline-block;
}
.footer .title-link{
  color:inherit;text-decoration:none;display:inline-block;
  background:linear-gradient(90deg,var(--theme-2),var(--theme-1));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
  background-clip:text;border-bottom:1px dashed rgba(var(--theme-rgb),.45);
  transition:all .18s;
}
.footer .title-link:hover{
  border-bottom-color:var(--theme-2);
  filter:drop-shadow(0 0 8px rgba(var(--theme-rgb),.5));
}
.footer .author-link{
  color:var(--theme-2);
  font-weight:700;
  text-decoration:none;
  background:linear-gradient(90deg,var(--theme-2),var(--theme-1));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
  background-clip:text;
  border-bottom:1px dashed rgba(var(--theme-rgb),.5);
  transition:all .18s;
}
.footer .author-link:hover{
  border-bottom-color:var(--theme-2);
  filter:drop-shadow(0 0 8px rgba(var(--theme-rgb),.5));
}
.footer .author{
  background:linear-gradient(90deg,var(--theme-2),var(--theme-1));
  -webkit-background-clip:text;-webkit-text-fill-color:transparent;
  background-clip:text;font-weight:700;
}

/* ==================== 移动端 ==================== */
@media (max-width:900px){
  .mode-strip{grid-template-columns:repeat(3,1fr)}
  .stat-grid{grid-template-columns:repeat(2,1fr)}
  .form-grid{grid-template-columns:1fr}
  .db-cards{grid-template-columns:1fr}
}
@media (max-width:768px){
  body{flex-direction:column;overflow:auto}
  .sidebar{width:100%;height:auto;position:relative;border-right:none;border-bottom:1px solid var(--border)}
  .sb-nav{flex-direction:row;overflow-x:auto;padding:6px}
  
  .sb-item{padding:8px 11px;font-size:13px;flex-shrink:0;gap:6px}
  .sb-item .ico{font-size:13px}
  .main{height:auto;overflow:auto}
  .content{padding:12px 10px}
}

/* ============ 下拉框深色主题 ============ */
select,
select.input {
  background: #0a121d !important;
  color: #e8eef8 !important;
  border-color: #243145 !important;
}
select option,
select.input option {
  background: #0d1520 !important;
  color: #e8eef8 !important;
  padding: 8px 12px !important;
}
select option:checked,
select option:hover {
  background: linear-gradient(135deg, #10b981, #047857) !important;
  color: #fff !important;
}
/* 表单里的 select 也统一 */
.form-grid select,
.fld select {
  background: #0a121d !important;
  color: #e8eef8 !important;
}
.form-grid select option,
.fld select option {
  background: #0d1520 !important;
  color: #e8eef8 !important;
}
/* BM 下拉框特化 */
#dmrBmHost,
#dmrBmHost option {
  background: #0a121d !important;
  color: #e8eef8 !important;
}
#dmrBmHost option {
  padding: 10px 14px !important;
  line-height: 1.6 !important;
}
#dmrBmHost option:checked {
  background: linear-gradient(135deg, #10b981, #047857) !important;
  color: #fff !important;
}

/* ==========================================================================
   ★★★ UI v2 科技感皮肤（覆盖层，不改动原有规则结构）
   ========================================================================== */

body{
  background:
    radial-gradient(1200px 600px at 12% -10%, rgba(16,185,129,.10), transparent 60%),
    radial-gradient(900px 500px at 88% 0%, rgba(56,189,248,.10), transparent 55%),
    radial-gradient(700px 700px at 50% 120%, rgba(168,85,247,.07), transparent 60%),
    var(--bg);
  background-attachment:fixed !important;
}
body::before{
  content:'';position:fixed;inset:0;pointer-events:none;z-index:0;
  background-image:
    linear-gradient(rgba(120,160,220,.045) 1px, transparent 1px),
    linear-gradient(90deg, rgba(120,160,220,.045) 1px, transparent 1px);
  background-size:44px 44px, 44px 44px;
  mask-image:radial-gradient(circle at 50% 0%, #000 0%, transparent 78%);
  -webkit-mask-image:radial-gradient(circle at 50% 0%, #000 0%, transparent 78%);
}
body::after{
  content:'';position:fixed;left:0;right:0;top:0;height:2px;z-index:60;pointer-events:none;
  background:linear-gradient(90deg,transparent,#22d3ee,#34d399,#a78bfa,transparent);
  opacity:.55;animation:scanX 7s linear infinite;
}
.card-badge{
  background:rgba(56,189,248,.10) !important;border:1px solid rgba(56,189,248,.32) !important;
  color:#7dd3fc !important;letter-spacing:.6px;
}
.badge,.db-bar-tag{
  background:linear-gradient(135deg,rgba(16,185,129,.16),rgba(56,189,248,.10)) !important;
  border:1px solid rgba(52,211,153,.30) !important;color:#6ee7b7 !important;
}

/* 实时通联看板：一行 4 张，更宽更耐看 */
.qso-list{grid-template-columns:repeat(4,minmax(0,1fr)) !important;gap:12px}
.qso-item{min-height:104px;border-radius:13px;padding:13px 15px 12px 17px}
.qso-item .qso-call{font-size:16px;letter-spacing:.3px}
.qso-item .qso-row2{gap:10px;flex-wrap:wrap}

/* ---------- 智能选择器（服务器 / 反射器） ---------- */
.sp{
  border:1px solid rgba(56,189,248,.22);border-radius:13px;overflow:hidden;
  background:linear-gradient(180deg,rgba(10,18,32,.72),rgba(8,14,26,.86));
}
.sp-head{
  display:flex;align-items:center;gap:10px;padding:10px 13px;
  background:linear-gradient(90deg,rgba(56,189,248,.10),rgba(16,185,129,.05),transparent);
  border-bottom:1px solid rgba(120,160,220,.14);
}
.sp-title{font-size:14px;font-weight:800;letter-spacing:.7px;text-transform:uppercase;color:#bcd2ef}
.sp-hint{font-size:12.5px;color:#6f86a6;margin-left:auto;white-space:nowrap}
.sp-bar{display:flex;align-items:center;gap:9px;padding:10px 13px 4px}
.sp-ico{font-size:14px;opacity:.7}
.sp-in{
  flex:1;background:rgba(3,8,16,.75);border:1px solid rgba(120,160,220,.26);
  color:#e8f1ff;border-radius:9px;padding:9px 12px;font-size:14.5px;font-family:inherit;
  transition:border-color .18s, box-shadow .18s;
}
.sp-in:focus{outline:none;border-color:rgba(56,189,248,.72);box-shadow:0 0 0 3px rgba(56,189,248,.13)}
.sp-in::placeholder{color:#5b7093}
.sp-cnt{font-size:13px;color:#7dd3fc;font-variant-numeric:tabular-nums;white-space:nowrap}
.sp-chips{display:flex;flex-wrap:wrap;gap:6px;padding:9px 13px 6px}
.sp-chip{
  font-size:12.5px;font-weight:700;padding:3.5px 9px;border-radius:99px;cursor:pointer;
  background:rgba(120,160,220,.09);border:1px solid rgba(120,160,220,.20);color:#9fb6d6;
  transition:all .16s;user-select:none;
}
.sp-chip:hover{background:rgba(56,189,248,.16);color:#cfeaff;border-color:rgba(56,189,248,.42)}
.sp-chip.on{
  background:linear-gradient(135deg,rgba(16,185,129,.26),rgba(56,189,248,.20));
  border-color:rgba(52,211,153,.62);color:#a7f3d0;
}
.sp-chip b{opacity:.62;font-weight:700;margin-left:4px}
.sp-list{max-height:236px;overflow:auto;padding:4px 7px 8px}
.sp-list::-webkit-scrollbar{width:9px}
.sp-list::-webkit-scrollbar-thumb{background:rgba(120,160,220,.26);border-radius:9px}
.sp-row{
  display:flex;align-items:center;gap:9px;padding:8px 10px;border-radius:9px;cursor:pointer;
  border:1px solid transparent;transition:all .13s;
}
.sp-row:hover{background:rgba(56,189,248,.10);border-color:rgba(56,189,248,.28)}
.sp-row.on{background:linear-gradient(90deg,rgba(16,185,129,.20),rgba(56,189,248,.10));border-color:rgba(52,211,153,.5)}
.sp-row .nm{font-size:14px;font-weight:700;color:#e6efff;min-width:118px}
.sp-row .nt{font-size:11.5px;font-weight:800;padding:2px 6px;border-radius:5px;background:rgba(167,139,250,.16);color:#c4b5fd}
.sp-row .hs{font-size:13px;color:#8fa6c4;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--mono)}
.sp-row .pt{font-size:12.5px;color:#6f86a6;font-variant-numeric:tabular-nums}
.sp-empty{padding:22px;text-align:center;color:#5b7093;font-size:13.5px}
.sp-sel{
  display:flex;align-items:center;gap:9px;padding:9px 13px;
  border-top:1px solid rgba(120,160,220,.14);
  background:linear-gradient(90deg,rgba(16,185,129,.09),transparent);
  font-size:13.5px;color:#a7f3d0;
}
.sp-sel b{color:#fff;font-family:var(--mono);font-size:13.5px}
.sp-sel .none{color:#fbbf24}
.sp-go{
  margin-left:auto;font-size:12.5px;background:rgba(56,189,248,.14);
  border:1px solid rgba(56,189,248,.36);color:#7dd3fc;border-radius:7px;padding:4px 10px;cursor:pointer;
}
.sp-go:hover{background:rgba(56,189,248,.26)}
.sp-mod{display:flex;gap:10px;align-items:center;padding:10px 13px;border-top:1px solid rgba(120,160,220,.12);flex-wrap:wrap}
.sp-mod label{font-size:13px;color:#9fb6d6}
.sp-mod select{
  background:rgba(3,8,16,.75);border:1px solid rgba(120,160,220,.26);color:#e8f1ff;
  border-radius:8px;padding:6px 9px;font-size:14px;font-family:inherit;
}
.essid-note{font-size:12px;color:#6f86a6;margin-top:5px;line-height:1.55}
.essid-note b{color:#6ee7b7}
/* ★ 恢复：之前被正则误删的规则 */
svg{display:block;flex-shrink:0}
input,select,textarea{font-family:inherit;font-size:inherit}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#a7f3d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fecaca}
.plrow .fq{font-size:13px}
.plout,pre{font-size:13px}

/* ===== INLINE3_CSS 内联页样式（类名已加前缀，与主控制台隔离）===== */

*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#dfe8f5;font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
body.embed .pgfmo-top{display:none}
.pgfmo-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.pgfmo-top h1{font-size:18px;font-weight:650;letter-spacing:.3px}
.badge{font-size:12px;padding:3px 9px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.pgfmo-spacer{flex:1}
button{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:6px 12px;border-radius:7px;font-size:14px;font-family:inherit}
button:hover{background:#1d3260;border-color:#3b5686}
button:active{transform:translateY(1px)}
button.pgfmo-sm{padding:4px 9px;font-size:12px}
button.pgfmo-primary{background:#14532d;border-color:#1d6b4a;color:#bbf7d0}
.card{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;padding:14px;margin-bottom:14px}
.card>h2{font-size:14px;font-weight:640;color:#9fb6d6;margin-bottom:11px;letter-spacing:.3px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:10px}
.kv{background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:9px 11px}
.kv .pgfmo-k{font-size:12.5px;color:#7d93b2;letter-spacing:.4px;text-transform:uppercase}
.kv .pgfmo-v{font-size:16px;font-weight:600;margin-top:3px;word-break:break-all}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row+.row{margin-top:9px}
.pgfmo-lbl{font-size:13.5px;color:#9fb6d6;min-width:96px}
.dot{width:9px;height:9px;border-radius:99px;background:#4b5563;display:inline-block;flex-shrink:0}
.dot.on{background:#22c55e;box-shadow:0 0 7px #22c55e}
.dot.off{background:#ef4444;box-shadow:0 0 7px #ef4444}
.pgfmo-svcs{display:flex;flex-wrap:wrap;gap:8px}
.svc{display:flex;align-items:center;gap:7px;font-size:12.5px;background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:6px 10px}
input,select{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:6px 9px;border-radius:7px;font-size:14px;font-family:inherit;min-width:110px}
input[type=checkbox]{min-width:0;width:15px;height:15px;accent-color:#22c55e}
input.pgfmo-wide{min-width:210px}
pre{background:#070d18;border:1px solid #1b2840;border-radius:8px;padding:11px;font:12.5px/1.5 Consolas,monospace;color:#9fb6d6;max-height:340px;overflow:auto;white-space:pre-wrap;word-break:break-all}
.hint{font-size:13.5px;color:#7d93b2;margin-top:8px}
.pgfmo-warn{font-size:13.5px;color:#fbbf24;margin-top:8px}
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:99;display:none;padding:9px 14px;border-radius:11px;background:rgba(13,21,32,.74);border:1px solid rgba(var(--theme-rgb),.42);color:#eaf2ff;font-size:13.5px;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);max-width:min(420px,84vw);text-align:center}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#bbf7d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fca5a5}


*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#dfe8f5;font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
body.embed .pgtetra-top{display:none}
.pgtetra-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.pgtetra-top h1{font-size:18px;font-weight:650}
.badge{font-size:12px;padding:3px 9px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.pgtetra-spacer{flex:1}
button{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:6px 12px;border-radius:7px;font-size:14px;font-family:inherit}
button:hover{background:#1d3260}
button.pgtetra-sm{padding:4px 9px;font-size:12px}
button.pgtetra-primary{background:#14532d;border-color:#1d6b4a;color:#bbf7d0}
button.pgtetra-danger{background:#3a1a1a;border-color:#6e2626;color:#fca5a5}
.card{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;padding:14px;margin-bottom:14px}
.card>h2{font-size:14px;font-weight:640;color:#9fb6d6;margin-bottom:11px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.kv{background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:9px 11px}
.kv .pgtetra-k{font-size:12.5px;color:#7d93b2;letter-spacing:.4px;text-transform:uppercase}
.kv .pgtetra-v{font-size:16px;font-weight:600;margin-top:3px;word-break:break-all}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row+.row{margin-top:9px}
.hint{font-size:13.5px;color:#7d93b2;margin-top:8px}
.pgtetra-warn{font-size:13.5px;color:#fbbf24;margin-top:8px}
input,select{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:6px 9px;border-radius:7px;font-size:14px;font-family:inherit;min-width:92px;width:100%}
input[type=checkbox]{min-width:0;width:15px;height:15px;accent-color:#22c55e}
.dot{width:9px;height:9px;border-radius:99px;background:#4b5563;display:inline-block}
.dot.on{background:#22c55e;box-shadow:0 0 7px #22c55e}
.dot.off{background:#ef4444}
.tch{background:#0c1526;border:1px solid #1b2840;border-radius:10px;padding:12px;margin-bottom:10px}
.tch h3{font-size:14px;color:#7dd3fc;margin-bottom:9px;display:flex;align-items:center;gap:8px}
.tsrow{display:flex;gap:14px;flex-wrap:wrap;margin-top:9px;padding-top:9px;border-top:1px dashed #1e2b44}
.tsrow label{display:flex;align-items:center;gap:5px;font-size:13px}
pre{background:#070d18;border:1px solid #1b2840;border-radius:8px;padding:11px;font:12.5px/1.5 Consolas,monospace;color:#9fb6d6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all}
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:99;display:none;padding:9px 14px;border-radius:11px;background:rgba(13,21,32,.74);border:1px solid rgba(var(--theme-rgb),.42);color:#eaf2ff;font-size:13.5px;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);max-width:min(420px,84vw);text-align:center}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#bbf7d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fca5a5}


*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#dfe8f5;font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
body.embed .pgmulti-top{display:none}
a{color:#4fc3f7;text-decoration:none}
.pgmulti-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.pgmulti-top h1{font-size:18px;font-weight:650;letter-spacing:.3px}
.badge{font-size:12px;padding:3px 9px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.pgmulti-spacer{flex:1}
button{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:6px 12px;border-radius:7px;font-size:14px;font-family:inherit}
button:hover{background:#1d3260;border-color:#3b5686}
button:active{transform:translateY(1px)}
button.pgmulti-sm{padding:4px 9px;font-size:12px}
button.dngr{background:#3a1a1a;border-color:#6e2626;color:#fca5a5}
.card{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;padding:14px;margin-bottom:14px}
.card>h2{font-size:14px;font-weight:640;color:#9fb6d6;margin-bottom:11px;letter-spacing:.3px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:10px}
.kv{background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:9px 11px}
.kv .k{font-size:12.5px;color:#7d93b2;letter-spacing:.4px;text-transform:uppercase}
.kv .v{font-size:16px;font-weight:600;margin-top:3px;word-break:break-all}
.pgmulti-chs{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px;margin-bottom:14px}
.ch{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;overflow:hidden}
.ch-hd{display:flex;align-items:center;gap:9px;padding:11px 14px;background:linear-gradient(180deg,#17233a,#111c30);border-bottom:1px solid #1e2b44}
.ch-hd .num{font-size:16px;font-weight:680}
.ch-hd .mode{font-size:12px;font-weight:700;padding:3px 9px;border-radius:6px;background:#1b3050;color:#7dd3fc;letter-spacing:.5px}
.ch-bd{padding:13px 14px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row+.row{margin-top:9px}
select{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:6px 9px;border-radius:7px;font-size:14px;font-family:inherit}
.dot{width:9px;height:9px;border-radius:99px;background:#4b5563;display:inline-block;flex-shrink:0}
.dot.on{background:#22c55e;box-shadow:0 0 7px #22c55e}
.dot.off{background:#ef4444;box-shadow:0 0 7px #ef4444}
.st{display:flex;align-items:center;gap:6px;font-size:13.5px;color:#9fb6d6}
.qso{margin-top:10px;padding:8px 10px;background:#0c1526;border:1px solid #1b2840;border-radius:8px;font-size:13.5px;color:#9fb6d6;min-height:34px}
.pgmulti-svcs{display:flex;flex-wrap:wrap;gap:8px}
.svc{display:flex;align-items:center;gap:7px;font-size:12.5px;background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:6px 10px}
/* ---- 信道规划器 ---- */
.pgmulti-pl{background:linear-gradient(165deg,#111d33,#0b1322);border:1px solid #1f304d;border-radius:12px;padding:15px;margin-bottom:14px;position:relative;overflow:hidden}
.pgmulti-pl:before{content:"";position:absolute;inset:0 0 auto 0;height:2px;background:linear-gradient(90deg,#22d3ee,#3b82f6,#8b5cf6,transparent)}
.pgmulti-pl h2{font-size:14px;font-weight:660;color:#9fb6d6;margin-bottom:13px;display:flex;align-items:center;gap:8px}
.pgmulti-pl h2 .pgmulti-tag{font-size:12px;font-weight:700;letter-spacing:1.2px;color:#22d3ee;background:#0a2733;border:1px solid #155e75;border-radius:5px;padding:2px 7px}
.pgmulti-chips{display:flex;gap:7px;flex-wrap:wrap}
.chip{cursor:pointer;min-width:40px;text-align:center;padding:7px 13px;border-radius:8px;border:1px solid #2a3a56;background:#0c1526;color:#9fb6d6;font-size:14px;font-weight:650;user-select:none;transition:.14s}
.chip:hover{border-color:#3b5686;color:#cfe0f5}
.chip.on{background:linear-gradient(180deg,#0e7490,#155e75);border-color:#22d3ee;color:#e0f7ff;box-shadow:0 0 12px rgba(34,211,238,.28)}
.plrow{display:grid;grid-template-columns:64px 132px 1fr 1fr;gap:9px;align-items:center;padding:8px 10px;background:#0a1322;border:1px solid #1b2840;border-radius:9px;margin-top:8px}
.plrow .n{font-weight:700;color:#7dd3fc;font-size:14.5px;letter-spacing:.3px}
.plrow.dis{opacity:.34}
.pgmulti-plout{margin-top:12px;padding:11px 13px;background:#070d18;border:1px solid #1b2840;border-radius:9px;font:12.5px/1.6 Consolas,monospace;white-space:pre-wrap;word-break:break-all;color:#9fd8b4;max-height:250px;overflow:auto;display:none}
.pgmulti-lbl{font-size:12.5px;color:#7d93b2;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px}
.pgmulti-f{display:flex;flex-direction:column}
input[type=text],input[type=number]{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:7px 10px;border-radius:7px;font-size:14.5px;font-family:inherit;width:100%}
input[type=text]:focus,input[type=number]:focus{outline:none;border-color:#22d3ee;box-shadow:0 0 0 2px rgba(34,211,238,.14)}
input[type=number]{-moz-appearance:textfield}
pre{background:#070d18;border:1px solid #1b2840;border-radius:8px;padding:12px;max-height:430px;overflow:auto;font:12.5px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-all;color:#b9cbe4}
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:99;display:none;padding:9px 14px;border-radius:11px;background:rgba(13,21,32,.74);border:1px solid rgba(var(--theme-rgb),.42);color:#eaf2ff;font-size:13.5px;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);max-width:min(420px,84vw);text-align:center}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#a7f3d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fecaca}
/* ==================== ★ 整体字号放大 0.5 号 ==================== */
body{font:14.5px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}
.pgmulti-top h1{font-size:18.5px}
.badge{font-size:13.5px}
button{font-size:13.5px}
button.pgmulti-sm{font-size:13.5px}
.card>h2{font-size:14.5px}
.kv .k{font-size:12px}
.kv .v{font-size:15.5px}
.ch-hd .num{font-size:16.5px}
.ch-hd .mode{font-size:13.5px}
.pgmulti-lbl{font-size:12px}
.plrow{grid-template-columns:96px 132px 1fr 1fr}
.plrow .n{font-size:14px}
.plrow .fq{font-size:13px}
input[type=text],input[type=number]{font-size:14px}
select{font-size:13.5px}
.pgmulti-plout,pre{font-size:13px}
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:99;display:none;padding:9px 14px;border-radius:11px;background:rgba(13,21,32,.74);border:1px solid rgba(var(--theme-rgb),.42);color:#eaf2ff;font-size:13.5px;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);max-width:min(420px,84vw);text-align:center}


/* ========== Light Theme (.light class on body) ========== */
body.light {
  --bg:#e8e3d9 !important;
  --sidebar-bg:#e6e1d7 !important;
  --sidebar-hover:#ded8cc !important;
  --card:#fbfaf7 !important;
  --card-hi:#fdfcf9 !important;
  --card-elev:#e8e3d9 !important;
  --border:rgba(0,0,0,.14) !important;
  --border-lit:rgba(0,0,0,.18) !important;
  --text:#111827 !important;
  --text-2:#475569 !important;
  --muted:#94a3b8 !important;
  --dim:#cbd5e1 !important;
}
body.light,
body.light .sidebar,
body.light .main { background:#f2efe8 !important; }
body.light .card {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
  box-shadow:0 2px 8px rgba(0,0,0,.04) !important;
}
body.light .card::before { background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.12),transparent) !important; }
body.light .card::after { background:linear-gradient(180deg,transparent,rgba(var(--theme-rgb),.08) 50%,transparent) !important; }
body.light .sb-name { color:#0f172a !important; }
body.light .sb-sub { color:#6b7280 !important; }
body.light .sb-item { color:#374151 !important; }
body.light .sb-item:hover { background:#f8f6f1 !important; color:#0f172a !important; }
body.light .sb-item.active { box-shadow:0 4px 12px rgba(var(--theme-rgb),.2) !important; }
body.light .content,
body.light .card-title,
body.light h1, body.light h2, body.light h3,
body.light .big { color:#0f172a !important; }
body.light .main-top { background:rgba(242,239,232,.98) !important; }
body.light .status-pill {
  background:rgba(var(--theme-rgb),.08) !important;
  color:var(--theme-1) !important;
  border-color:rgba(var(--theme-rgb),.2) !important;
}
body.light input, body.light select, body.light textarea {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.18) !important;
  color:#0f172a !important;
}
body.light .mc-card {
  background:#fbfaf7 !important; background:#fbfaf7 !important; border-color:rgba(0,0,0,.14) !important; }
body.light .mode-pill { background:rgba(255,255,255,.8) !important; border-color:rgba(0,0,0,.1) !important; color:#374151 !important; }
body.light .mode-pill.active { background:linear-gradient(180deg,var(--theme-2),var(--theme-1)) !important; color:#fff !important; }
body.light .terminal, body.light pre.mini { background:#f8f6f1 !important; color:#1e293b !important; border-color:rgba(0,0,0,.14) !important; }
body.light ::-webkit-scrollbar-thumb { background:var(--dim) !important; }
body.light ::-webkit-scrollbar-track { background:rgba(0,0,0,.03) !important; }
body.light .sidebar { border-right:1px solid rgba(0,0,0,.08) !important; }

/* ========== Font Override ========== */
body, input, select, textarea, button {
  font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei','Noto Sans CJK SC',system-ui,sans-serif !important;
}
code, pre, .mono, .terminal {
  font-family:ui-monospace,'SFMono-Regular','Cascadia Mono','Noto Sans Mono',Menlo,Consolas,'Courier New',monospace !important;
}

/* ========== Theme Toggle Button ========== */
.theme-toggle-btn {
  display:inline-flex;align-items:center;justify-content:center;
  width:30px;height:30px;border-radius:6px;
  border:1px solid var(--border-lit);
  background:transparent;color:var(--text-2);
  cursor:pointer;font-size:15px;
  transition:all .18s;flex-shrink:0;
  opacity:.7;
}
.theme-toggle-btn:hover {
  opacity:1;
  border-color:rgba(var(--theme-rgb),.5);
  color:var(--theme-2);
  background:rgba(var(--theme-rgb),.08);
}

/* ========== Better dark mode shadows ========== */
body:not(.light) .card {
  box-shadow:0 4px 20px rgba(0,0,0,.25), 0 0 0 1px rgba(255,255,255,.03);
}

/* ================================================================
   COMPREHENSIVE LIGHT THEME - All components properly styled
   ================================================================ */
:root {
  --bg-input:#0a121d;
}
body.light {
  /* Core palette */
  --bg:#e8e3d9 !important;
  --bg-input:#fbfaf7 !important;
  --sidebar-bg:#e6e1d7 !important;
  --sidebar-hover:#ded8cc !important;
  --card:#fbfaf7 !important;
  --card-hi:#fdfcf9 !important;
  --card-elev:#e8e3d9 !important;
  --border:rgba(0,0,0,.14) !important;
  --border-lit:rgba(0,0,0,.18) !important;
  --text:#111827 !important;
  --text-2:#1f2937 !important;
  --muted:#4b5563 !important;
  --dim:#6b7280 !important;
}

/* --- Body & Layout --- */
body.light,
body.light .sidebar,
body.light .main,
body.light .content {
  background:#f2efe8 !important;
  color:#0f172a !important;
}
body.light .main-top {
  background:rgba(242,239,232,.98) !important;
  border-bottom-color:rgba(0,0,0,.06) !important;
}
body.light::before {
  background:
    radial-gradient(1200px 600px at 12% -10%, rgba(16,185,129,.04), transparent 60%),
    radial-gradient(900px 500px at 88% 0%, rgba(56,189,248,.04), transparent 55%) !important;
}
body.light::after {
  background:linear-gradient(90deg,transparent,rgba(56,189,248,.15),rgba(52,211,153,.15),rgba(139,92,246,.15),transparent) !important;
  opacity:.3 !important;
}

/* --- Sidebar --- */
body.light .sidebar {
  border-right-color:rgba(0,0,0,.08) !important;
}
body.light .sb-name { color:#0f172a !important; }
body.light .sb-sub { color:#4b5563 !important; }
body.light .sb-item { color:#374151 !important; }
body.light .sb-item:hover {
  background:rgba(0,0,0,.04) !important;
  color:#0f172a !important;
}
body.light .sb-item.active {
  background:linear-gradient(135deg,var(--theme-2),var(--theme-1),var(--theme-3)) !important;
  color:#fff !important;
  box-shadow:0 4px 12px rgba(var(--theme-rgb),.25) !important;
}
body.light .sb-brand { border-bottom-color:rgba(0,0,0,.06) !important; }
body.light .sb-brand::after {
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.15),transparent) !important;
}

/* --- Cards --- */
body.light .card {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
  box-shadow:0 1px 4px rgba(0,0,0,.07), 0 0 0 1px rgba(0,0,0,.04) !important;
}
body.light .card:hover {
  border-color:rgba(0,0,0,.18) !important;
  box-shadow:0 4px 14px rgba(0,0,0,.1), 0 0 0 1px rgba(0,0,0,.05) !important;
}
body.light .card::before {
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.12),transparent) !important;
}
body.light .card::after {
  background:linear-gradient(180deg,transparent,rgba(var(--theme-rgb),.08) 50%,transparent) !important;
}
body.light .card-title,
body.light .card-head h2,
body.light .card-head h3 { color:#0f172a !important; }
body.light .card-sub { color:#4b5563 !important; }
body.light .card-badge {
  background:rgba(var(--theme-rgb),.08) !important;
  color:var(--theme-1) !important;
  border-color:rgba(var(--theme-rgb),.2) !important;
}

/* --- Text hierarchy --- */
body.light h1, body.light h2, body.light h3, body.light h4 { color:#0f172a !important; }
body.light .big { color:#0f172a !important; }
body.light .stat-lbl { color:#4b5563 !important; }
body.light .stat-val { color:#0f172a !important; }
body.light .mc-mode-txt { color:#0f172a !important; }
body.light .mc-off { color:#4b5563 !important; }
body.light .mc-hd { color:#0f172a !important; }
body.light .mc-freq { color:#0f172a !important; }
body.light .mc-empty { color:#6b7280 !important; }
body.light .mc-tip { color:#6b7280 !important; }

/* --- Status indicators --- */
body.light .cyan { color:#0891b2 !important; }
body.light .green { color:#059669 !important; }
body.light .red { color:#dc2626 !important; }
body.light .amber { color:#d97706 !important; }
body.light .status-pill {
  background:rgba(var(--theme-rgb),.06) !important;
  color:var(--theme-1) !important;
  border-color:rgba(var(--theme-rgb),.15) !important;
}

/* --- Mode pills --- */
body.light .mode-pill {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.1) !important;
  color:#374151 !important;
}
body.light .mode-pill:hover {
  border-color:rgba(var(--theme-rgb),.4) !important;
  color:#0f172a !important;
  background:rgba(var(--theme-rgb),.04) !important;
}
body.light .mode-pill.active {
  background:linear-gradient(180deg,var(--theme-2),var(--theme-1)) !important;
  color:#fff !important;
  border-color:var(--theme-1) !important;
}

/* --- MC Cards --- */
body.light .mc-card {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .mc-card.on { border-color:rgba(16,185,129,.4) !important; }

/* --- QSO items --- */
body.light .qso-item {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .qso-item:hover {
  border-color:rgba(var(--theme-rgb),.3) !important;
  box-shadow:0 4px 12px rgba(0,0,0,.06) !important;
}
body.light .qso-call { 
  -webkit-text-fill-color:var(--theme-1) !important;
  color:var(--theme-1) !important;
}
body.light .qso-country {
  background:rgba(0,0,0,.04) !important;
  color:#0f172a !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .qso-id {
  background:rgba(59,130,246,.08) !important;
  color:#2563eb !important;
  border-color:rgba(59,130,246,.2) !important;
}
body.light .qso-time {
  background:rgba(var(--theme-rgb),.06) !important;
  color:#374151 !important;
  border-color:rgba(var(--theme-rgb),.15) !important;
}
body.light .qso-row2 { color:#374151 !important; border-top-color:rgba(0,0,0,.06) !important; }
body.light .qso-row2 b { color:#0f172a !important; }
body.light .qso-prev { color:#4b5563 !important; }
body.light .qso-prev b { color:#0f172a !important; }
body.light .qso-empty { color:#6b7280 !important; }
body.light .qso-cell-empty {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
  color:#6b7280 !important;
}
body.light .qso-tag.mode {
  background:rgba(var(--theme-rgb),.08) !important;
  color:var(--theme-1) !important;
  border-color:rgba(var(--theme-rgb),.2) !important;
}
body.light .qso-tag.src-rf {
  background:rgba(59,130,246,.08) !important;
  color:#2563eb !important;
  border-color:rgba(59,130,246,.2) !important;
}
body.light .qso-tag.src-net {
  background:rgba(16,185,129,.08) !important;
  color:#059669 !important;
  border-color:rgba(16,185,129,.2) !important;
}
body.light .qso-tag.src-local {
  background:rgba(139,92,246,.08) !important;
  color:#7c3aed !important;
  border-color:rgba(139,92,246,.2) !important;
}
body.light .qso-slot-state.live {
  background:rgba(239,68,68,.08) !important;
  color:#dc2626 !important;
  border-color:rgba(239,68,68,.2) !important;
}
body.light .qso-slot-state.idle {
  background:rgba(16,185,129,.08) !important;
  color:#059669 !important;
  border-color:rgba(16,185,129,.2) !important;
}
body.light .qso-live-badge {
  background:rgba(239,68,68,.08) !important;
  color:#dc2626 !important;
  border-color:rgba(239,68,68,.2) !important;
}
body.light .qso-item.qso-live {
  background:rgba(239,68,68,.04) !important;
  border-color:rgba(239,68,68,.3) !important;
  box-shadow:0 0 0 1px rgba(239,68,68,.15) !important;
}
body.light .ber.good {
  background:rgba(16,185,129,.08) !important;
  color:#059669 !important;
  border-color:rgba(16,185,129,.2) !important;
}
body.light .ber.warn {
  background:rgba(245,158,11,.08) !important;
  color:#d97706 !important;
  border-color:rgba(245,158,11,.2) !important;
}
body.light .ber.bad {
  background:rgba(239,68,68,.08) !important;
  color:#dc2626 !important;
  border-color:rgba(239,68,68,.2) !important;
}
/* 日间模式下，通联列表的说明性文字全部使用高对比黑色 */
body.light .qso-ch-head,
body.light .qso-slot-lbl,
body.light #qsoPanel .qso-slot-lbl,
body.light #qsoPanel .qso-ch-head,
body.light #qsoPanel .qso-ch-cnt,
body.light #qsoPanel .qso-cell-empty,
body.light #qsoPanel .qso-empty {
  color:#111827 !important;
}

/* --- Stat cards --- */
body.light .stat {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .stat::before {
  background:linear-gradient(90deg,transparent,rgba(var(--theme-rgb),.1),transparent) !important;
}

/* --- QSO table --- */
body.light .qso-table th {
  background:#f1f5f9 !important;
  color:#374151 !important;
  border-bottom-color:rgba(0,0,0,.08) !important;
}
body.light .qso-table td { border-bottom-color:rgba(0,0,0,.1) !important; color:#111827 !important; }
body.light .qso-table tbody tr:nth-child(even) td { background:rgba(0,0,0,.02) !important; }
body.light .qso-table tbody tr:hover td { background:rgba(59,130,246,.04) !important; }
body.light .qso-table td.mono { color:#374151 !important; }

/* --- Port cards --- */
body.light .port-card {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .port-info b { color:#0f172a !important; }
body.light .port-info span { color:#4b5563 !important; }

/* --- DB cards --- */
body.light .db-card {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .db-card h3 { color:#0f172a !important; }
body.light .db-card p { color:#4b5563 !important; }
body.light .db-bar {
  background:linear-gradient(90deg,rgba(var(--theme-rgb),.06),rgba(var(--theme-rgb),.02),transparent) !important;
  border-color:rgba(var(--theme-rgb),.15) !important;
}
body.light .db-bar-txt b { color:#0f172a !important; }
body.light .db-bar-txt span { color:#4b5563 !important; }

/* --- Buttons --- */
body.light .btn-ghost {
  background:#fbfaf7 !important;
  color:#374151 !important;
  border-color:rgba(0,0,0,.18) !important;
}
body.light .btn-ghost:hover {
  background:#fbfaf7 !important;
  color:#0f172a !important;
}
body.light .refresh-btn {
  background:#fbfaf7 !important;
  color:#374151 !important;
  border-color:rgba(0,0,0,.18) !important;
}

/* --- Forms --- */
body.light input,
body.light select,
body.light textarea {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.18) !important;
  color:#0f172a !important;
}
body.light input:focus,
body.light select:focus,
body.light textarea:focus {
  border-color:var(--theme-1) !important;
  box-shadow:0 0 0 3px rgba(var(--theme-rgb),.08) !important;
}
body.light select option {
  background:#fbfaf7 !important;
  color:#0f172a !important;
}
body.light .form-grid label { color:#374151 !important; }
body.light .form-grid .hint { color:#6b7280 !important; }
body.light .form-grid input,
body.light .form-grid select {
  background:#fbfaf7 !important;
  color:#0f172a !important;
  border-color:rgba(0,0,0,.18) !important;
}
body.light .preset-box select {
  background:#fbfaf7 !important;
  color:#0f172a !important;
}

/* --- Freq banner --- */
body.light .freq-banner {
  background:linear-gradient(90deg,rgba(var(--theme-rgb),.04),#f8f9fb 60%,#f8f9fb) !important;
  background:linear-gradient(90deg,rgba(var(--theme-rgb),.04),#ffffff 60%,#ffffff) !important;
  border-color:rgba(var(--theme-rgb),.12) !important;
}
body.light .freq-banner-txt > b { color:#0f172a !important; }
body.light .freq-banner-txt > span { color:#374151 !important; }
body.light .freq-banner-txt > span > b { color:#0f172a !important; }

/* --- Freq chips/rows --- */
body.light .freq-chip {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
  color:#374151 !important;
}
body.light .freq-chip b { color:#059669 !important; }
body.light .freq-row {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .freq-row .fr-n { color:#374151 !important; }
body.light .freq-strip {
  background:#f2efe8 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
  color:#374151 !important;
}
body.light .freq-strip::before { color:#059669 !important; }

/* --- BM bar --- */
body.light .bm-bar {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
  color:#374151 !important;
}
body.light .bm-bar.ok { border-color:rgba(16,185,129,.3) !important; color:#059669 !important; }
body.light .bm-bar.bad { border-color:rgba(239,68,68,.3) !important; color:#dc2626 !important; }

/* --- Console / Log --- */
body.light .console {
  background:#fbfaf7 !important;
  color:#1e293b !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .console .tag-in { color:#059669 !important; }

/* --- Smart selector --- */
body.light .sp {
  background:#fbfaf7 !important;
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.14) !important;
}
body.light .sp-head {
  background:rgba(0,0,0,.02) !important;
  border-bottom-color:rgba(0,0,0,.06) !important;
}
body.light .sp-title { color:#374151 !important; }
body.light .sp-hint { color:#6b7280 !important; }
body.light .sp-in {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.1) !important;
  color:#0f172a !important;
}
body.light .sp-in::placeholder { color:#6b7280 !important; }
body.light .sp-cnt { color:#0891b2 !important; }
body.light .sp-chip {
  background:rgba(0,0,0,.04) !important;
  border-color:rgba(0,0,0,.14) !important;
  color:#374151 !important;
}
body.light .sp-chip:hover { background:rgba(56,189,248,.08) !important; color:#0f172a !important; }
body.light .sp-chip.on {
  background:rgba(16,185,129,.08) !important;
  border-color:rgba(16,185,129,.3) !important;
  color:#059669 !important;
}
body.light .sp-row:hover { background:rgba(0,0,0,.03) !important; }
body.light .sp-row .nm { color:#111827 !important; }
body.light .sp-row .hs { color:#374151 !important; }
body.light .sp-row .pt { color:#6b7280 !important; }
body.light .sp-empty { color:#6b7280 !important; }
body.light .sp-sel { color:#059669 !important; border-top-color:rgba(0,0,0,.06) !important; }
body.light .sp-sel b { color:#0f172a !important; }
body.light .sp-sel .none { color:#d97706 !important; }
body.light .sp-go {
  background:rgba(56,189,248,.08) !important;
  border-color:rgba(56,189,248,.2) !important;
  color:#0891b2 !important;
}
body.light .sp-mod label { color:#374151 !important; }
body.light .sp-mod select {
  background:#fbfaf7 !important;
  border-color:rgba(0,0,0,.1) !important;
  color:#0f172a !important;
}
body.light .essid-note { color:#6b7280 !important; }
body.light .essid-note b { color:#059669 !important; }

/* --- Network info --- */
body.light #sysInfo,
body.light #sysNet { color:#0f172a !important; }
body.light #sysInfo b, body.light #sysNet b,
body.light #sysInfo span, body.light #sysNet span { color:#0f172a !important; }
body.light #sysInfo .kvw { color:#4b5563 !important; }
body.light .netlab { color:#4b5563 !important; }
body.light .netval { color:#0f172a !important; }
body.light .netval.off { color:#6b7280 !important; }
body.light .netmeta { color:#374151 !important; }
body.light .netmeta-k { color:#4b5563 !important; }
body.light .netsep { color:#cbd5e1 !important; }
body.light #sysNet .dimtxt { color:#4b5563 !important; }

/* --- Footer --- */
body.light .footer { border-top-color:rgba(0,0,0,.06) !important; color:#4b5563 !important; }

/* --- Toast --- */
body.light #toast.ok {
  background:#e6f7ee !important;
  border-color:#a7f3d0 !important;
  color:#065f46 !important;
}
body.light #toast.bad {
  background:#fde8e8 !important;
  border-color:#fecaca !important;
  color:#991b1b !important;
}

/* --- Scrollbar --- */
body.light ::-webkit-scrollbar-thumb { background:rgba(0,0,0,.15) !important; }
body.light ::-webkit-scrollbar-track { background:rgba(0,0,0,.02) !important; }

/* --- Save buttons (keep colorful in light mode) --- */
body.light .save-btn { color:#fff !important; }

/* --- Theme toggle --- */
body.light .theme-toggle-btn {
  border-color:rgba(0,0,0,.1) !important;
  color:#4b5563 !important;
}
body.light .theme-toggle-btn:hover {
  border-color:rgba(var(--theme-rgb),.3) !important;
  color:var(--theme-1) !important;
  background:rgba(var(--theme-rgb),.06) !important;
}


/* ================================================================
   PREMIUM VISUAL POLISH - Animations, Glass, Depth
   ================================================================ */

/* --- Smooth page transitions --- */
.proto-page {
  /* animation removed */
}
@keyframes fadeUp {
  from { opacity:0; transform:translateY(8px); }
  to { opacity:1; transform:none; }
}

/* --- Card hover lift --- */
.card {
  transition:border-color .2s, box-shadow .2s, transform .2s;
}
.card:hover {
  transform:translateY(-1px);
  box-shadow:0 8px 24px rgba(0,0,0,.2), 0 0 0 1px rgba(255,255,255,.03);
}

/* --- Button press effect --- */
.btn:active,
.save-btn:active,
.mode-pill:active {
  transform:scale(.97) !important;
}

/* --- Smooth scroll --- */
.main { scroll-behavior:smooth; }

/* --- Status pill glow --- */
.status-pill .dot {
  animation:pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
  0%,100% { opacity:1; box-shadow:0 0 8px var(--theme-2); }
  50% { opacity:.6; box-shadow:0 0 16px var(--theme-2); }
}

/* --- Stat card number animation --- */
.stat-val {
  transition:color .3s;
}

/* --- Sidebar item indicator --- */
.sb-item.active::before {
  content:'';
  position:absolute;
  left:0;top:50%;transform:translateY(-50%);
  width:3px;height:60%;
  background:var(--theme-2);
  border-radius:0 2px 2px 0;
  box-shadow:0 0 8px var(--theme-2);
}

/* --- Better focus rings --- */
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
button:focus-visible {
  outline:2px solid rgba(var(--theme-rgb),.5);
  outline-offset:2px;
}

/* --- Smooth select transitions --- */
select {
  transition:border-color .15s, box-shadow .15s;
}

/* --- QSO card stagger animation --- */
.qso-item {
  /* animation removed - was causing flicker on refresh */
}

@keyframes fadeIn {
  from { opacity:0; transform:translateY(6px); }
  to { opacity:1; transform:none; }
}

/* --- Scan line (already exists, just enhance) --- */
@keyframes scanX {
  0% { transform:translateX(-100%); }
  100% { transform:translateX(100%); }
}

/* --- Mode pill shimmer on hover --- */
.mode-pill::before {
  content:'';
  position:absolute;inset:0;
  background:linear-gradient(90deg,transparent,rgba(255,255,255,.06),transparent);
  transform:translateX(-100%);
  transition:transform .5s;
}
.mode-pill:hover::before {
  transform:translateX(100%);
}

/* --- Better scrollbar --- */
::-webkit-scrollbar { width:6px; height:6px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb {
  background:rgba(255,255,255,.12);
  border-radius:3px;
}
::-webkit-scrollbar-thumb:hover {
  background:rgba(255,255,255,.2);
}

/* --- Loading spinner --- */
@keyframes spin {
  to { transform:rotate(360deg); }
}
.loading::after {
  content:'';
  display:inline-block;
  width:14px;height:14px;
  border:2px solid rgba(var(--theme-rgb),.3);
  border-top-color:var(--theme-2);
  border-radius:50%;
  animation:spin .6s linear infinite;
  margin-left:8px;
  vertical-align:middle;
}

@media(max-width:760px){
  #tgSrvCards{grid-template-columns:1fr !important;}
  #tgSrvForm{grid-template-columns:1fr 1fr !important;}
  #tgSrvForm > div:last-child{grid-column:1/-1;justify-content:flex-start !important;}
}
@media(max-width:460px){
  #tgSrvForm{grid-template-columns:1fr !important;}
  #tgSrvForm > div:last-child{grid-column:auto;}
}
</style>
<style id="fmStyleUnify">
/* ===== FM 联网配置：统一到当前控制台风格 ===== */
#page-fmo .pgfmo-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
#page-fmo .pgfmo-top h1{font-size:20px;font-weight:800;color:#fff;letter-spacing:.2px;margin:0}
#page-fmo .card{background:var(--card);border:1px solid var(--border);border-radius:14px;padding:16px 18px;margin-bottom:14px}
#page-fmo .card h2{font-size:17px;font-weight:800;color:#fff;margin:0 0 12px;padding-left:10px;border-left:3px solid var(--theme-2);line-height:1.25}
#page-fmo .grid{display:grid;gap:10px;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}
#page-fmo .kv{display:block;background:var(--card-hi);border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin:0}
#page-fmo .pgfmo-k{font-size:12.5px;color:var(--muted);font-weight:700;letter-spacing:.4px;text-transform:none}
#page-fmo .pgfmo-v{font-size:15px;font-weight:600;color:var(--text);margin-top:4px;word-break:break-all}
#page-fmo .hint{font-size:13.5px;color:var(--muted);line-height:1.75;margin-top:9px}
#page-fmo .pgfmo-warn{font-size:13.5px;color:#fbbf24;line-height:1.75;margin-top:8px}
#page-fmo input,#page-fmo select{background:var(--card-hi);border:1px solid var(--border);border-radius:9px;color:var(--text);padding:9px 11px;width:100%;font-size:14px;outline:none;box-sizing:border-box}
#page-fmo input:focus,#page-fmo select:focus{border-color:var(--theme-2)}
#page-fmo button.pgfmo-sm{background:var(--card-hi);border:1px solid var(--border);border-radius:9px;color:var(--text);padding:8px 14px;font-size:13.5px;cursor:pointer;transition:.15s}
#page-fmo button.pgfmo-sm:hover{border-color:var(--theme-2);color:var(--theme-2)}
#page-fmo button.pgfmo-primary{background:rgba(var(--theme-rgb),.16);border-color:rgba(var(--theme-rgb),.45);color:var(--theme-2);font-weight:700}
#page-fmo .pgfmo-svcs .kv{min-width:150px}
</style>
</head>
<body>

<!-- ============ 侧边栏 ============ -->
<aside class="sidebar">
  <div class="sb-brand">
    <div class="sb-icon" aria-hidden="true" style="border-radius:11px;background:linear-gradient(135deg,#42a5ff,#3ddc97);color:#04111e;font-weight:950;font-size:19px;box-shadow:0 8px 24px rgba(66,165,255,.22)">A</div>
    <div>
      <div style="display:flex;align-items:center;gap:6px">
        <div class="sb-name">ARCH 融合中枢</div>
        <button id="themeBtn" type="button" onclick="toggleTheme()" aria-label="切换日间/夜间模式" title="切换日间/夜间模式" style="border:0;background:transparent;color:var(--muted);font-size:16px;line-height:1;padding:0;cursor:pointer">🌙</button>
      </div>
      <div class="sb-sub"><span id="sbCall">NOCALL</span></div>
    </div>
    
  </div>

  <nav class="sb-nav">
        <div class="sb-item active" data-page="dashboard">
      <span class="ico">📊</span>设备运行状态
    </div>
    <div class="sb-item" data-page="rfmode"><span class="ico">📻</span>射频模式配置</div>
    <div class="sb-item" data-page="dmr">
      <span class="ico">🎙️</span>DMR 模式配置
    </div>
    <div class="sb-item" data-page="ysf">
      <span class="ico">🔊</span>YSF 模式配置
    </div>
    <div class="sb-item" data-page="p25">
      <span class="ico">📡</span>P25 模式配置
    </div>
    <div class="sb-item" data-page="nxdn">
      <span class="ico">📶</span>NXDN 模式配置
    </div>
    <div class="sb-item" data-page="dstar">
      <span class="ico">⭐</span>D-STAR 反射器
    </div>
    <div class="sb-item" data-page="tetra-external"><span class="ico">📻</span>TDR Link 配置</div>
    <div class="sb-item" data-page="fmo"><span class="ico">📡</span>FM 联网配置</div>
    <div class="sb-item" data-page="pocsag">
      <span class="ico">📟</span>POCSAG 寻呼
    </div>
    <div class="sb-item" data-page="ports">
      <span class="ico">🔌</span>端口监听矩阵
    </div>
    <div class="sb-item" data-page="calls">
      <span class="ico">📞</span>通话记录
    </div>
    <div class="sb-item" data-page="db">
      <span class="ico">🗄️</span>数据库更新
    </div>
    <div class="sb-item" data-page="sys">
      <span class="ico">⚙️</span>系统设置
    </div>
  </nav>
</aside>

<!-- ============ 主区 ============ -->
<main class="main">
  <div class="content">

    <!-- ============ 状态总览 ============ -->
    <section class="page" id="page-dashboard">
      <div class="card" id="freqModeCard">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
                <path d="M3 12h3l3-8 3 16 3-8h6" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
              </svg>
            </div>
            <div>
              <div class="card-title">频率模式列表</div>
            </div>
          </div>
          
        </div>
        <div id="mcMatrix" class="mc-matrix">
          <div class="mc-empty">正在读取多信道状态...</div>
        </div>
      </div>

      <div class="stat-grid">
        <div class="stat">
          <div class="stat-ico r">🌡️</div>
          <div>
            <div class="stat-lbl">CPU 核心温度</div>
            <div class="stat-val" id="stCpu">--</div>
          </div>
        </div>
        <div class="stat">
          <div class="stat-ico b">🧠</div>
          <div>
            <div class="stat-lbl">内存占用</div>
            <div class="stat-val" id="stMem">--</div>
          </div>
        </div>
        <div class="stat">
          <div class="stat-ico a">⏱️</div>
          <div>
            <div class="stat-lbl">系统运行时间</div>
            <div class="stat-val" id="stUptime">--</div>
          </div>
        </div>
        <div class="stat">
          <div class="stat-ico g">📻</div>
          <div>
            <div class="stat-lbl">SX1255 射频板</div>
            <div class="stat-val" id="stSx">--</div>
          </div>
        </div>
        <div class="stat">
          <div class="stat-ico y">⚡</div>
          <div>
            <div class="stat-lbl">CPU 实时占用</div>
            <div class="stat-val" id="stCpuUse">--</div>
          </div>
        </div>
      </div>

      <div class="card" id="tetraGrid" style="display:none">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(244,114,182,.12);color:#f472b6">&#128225;</div>
            <div>
              <div class="card-title">TDR Link 模式状态（TDR Link）</div>
              <div class="card-sub">上面一行是 4 个时隙状态（TS1 控制信道 / TS2-4 通话时隙），下面一行是已注册到本TDR Link 基站的终端</div>
            </div>
          </div>
          <span class="card-badge" id="tgBadge">--</span>
        </div>

        <div id="tgSlots" style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:11px">
          <div class="sp-empty">正在读取时隙&hellip;</div>
        </div>

        <div style="margin-top:14px">
          <div class="hint" style="margin-bottom:7px"><b style="color:var(--text)">已注册终端</b>
            <span id="tgTermBadge"></span></div>
          <div style="overflow:auto">
            <table id="tgTermTable" style="width:100%;border-collapse:collapse;font-size:13.5px">
              <thead><tr style="color:var(--text);text-align:left">
                <th style="padding:7px 9px;border-bottom:1px solid var(--border)">ID (ISSI)</th>
                <th style="padding:7px 9px;border-bottom:1px solid var(--border)">呼号</th>
                <th style="padding:7px 9px;border-bottom:1px solid var(--border)">RSSI 信号</th>
                <th style="padding:7px 9px;border-bottom:1px solid var(--border)">最后活动</th>
                <th style="padding:7px 9px;border-bottom:1px solid var(--border)">电量 / 节能</th>
                <th style="width:130px;min-width:110px;padding:7px 9px;border-bottom:1px solid var(--border)">附着组</th>
                <th style="padding:7px 9px;border-bottom:1px solid var(--border)">状态</th>
              </tr></thead>
              <tbody id="tgTerms"></tbody>
            </table>
          </div>
        </div>

        <div id="tgServerPanel" style="margin-top:14px;border:1px solid var(--border);border-radius:12px;padding:14px;background:var(--card2)">
          <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px">
            <b style="font-size:14px;color:var(--text)">服务器切换</b>
            <span class="hint" id="tgSrvCurrent">正在读取服务器配置…</span>
          </div>
          <div id="tgSrvCards" style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-bottom:12px">
            <div class="sp-empty">正在读取…</div>
          </div>
          <div id="tgSrvForm" style="display:grid;grid-template-columns:minmax(180px,1fr) minmax(180px,1fr) auto;gap:9px;align-items:end">
            <div class="fld"><label>DMR ID</label>
              <input id="tgSrvDmrId" type="text" inputmode="numeric" maxlength="7" placeholder="例如 0000000"></div>
            <div class="fld"><label>密码</label>
              <input id="tgSrvPassword" type="password" autocomplete="new-password" placeholder="TDR Link 登录密码"></div>
            <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
              <a id="tgSrvRegister" class="btn btn-ghost btn-sm" href="https://tetra.hljrsa.cn/" target="_blank" rel="noopener" style="display:none">注册 ARCH 账号</a>
              <button class="btn btn-green btn-sm" onclick="tgSaveServers()">保存服务器并重启</button>
            </div>
          </div>
          <div class="hint" id="tgSrvMsg" style="margin-top:8px">TDR Link Pack 使用 BM 的 DMR ID；ARCH 使用注册页面生成的 DMR ID。</div>
        </div>

                <div class="freq-banner" style="margin-top:12px">
          <div class="freq-banner-ico">&#128225;</div>
          <div class="freq-banner-txt">
            <b>TDR Link 收发频率（本模式专用，独立于多通道规划）</b>
            <span id="tgFreqNow">读取中&hellip;</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="tgLoad()">&#8635; 刷新</button>
        </div>

        <div class="form-grid" style="margin-top:10px">
          <div class="fld"><label>下行 / 发射频率 TX (MHz)</label>
            <input id="tgTx" type="number" min="350" max="500" step="0.025" placeholder="439.400" oninput="tgFreqHint()">
            <div class="hint">可填 350.000-500.000 MHz，按 25 kHz 栅格换算载波</div></div>
          <div class="fld"><label>上行 / 接收频率 RX (MHz)</label>
            <input id="tgRx" type="number" min="350" max="500" step="0.025" placeholder="429.400" oninput="tgFreqHint()">
            <div class="hint">可填 350.000-500.000 MHz</div></div>
          <div class="fld"><label>频率 / 载波换算</label>
            <input id="tgCarrier" readonly style="background:#0b1420;color:#7d93b2">
            <div class="hint" id="tgCarrierHz">自动选择 TDR Link 3 / 4 / 5 频段</div></div>
        </div>

        <div class="mc-foot" style="margin:10px 0 0">
          <button class="btn btn-green btn-sm" onclick="tgSaveFreq()">&#128190; 保存频率并重启基站</button>
          <span class="hint" id="tgFreqMsg" style="margin-left:10px"></span>
        </div>

        <div class="form-grid" style="margin-top:12px">
          <div class="fld"><label>当前TDR Link 参数</label>
            <div class="hint" id="tgParams" style="line-height:1.9">读取中&hellip;</div></div>
        </div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-ghost btn-sm" onclick="tgLoad()">&#8635; 刷新</button>
          <button class="btn btn-ghost btn-sm" onclick="window.open('http://192.168.1.10:8080', '_blank')">&#9881; 打开 TDR Link 配置 (8080端口)</button>
          <span class="hint" id="tgMsg" style="margin-left:10px"></span>
        </div>
      </div>



<div class="card" id="qsoPanel">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
                <path d="M3 12h3l3-8 3 16 3-8h6" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
              </svg>
            </div>
            <div>
              <div class="card-title">通联列表</div>
            </div>
          </div>
          <span class="card-badge">实时解析</span>
        </div>
        <div class="qso-list" id="qsoList">
          <div class="qso-empty"><span class="dot"></span>当前信道守候中...</div>
        </div>

      </div>
    </section>

    <!-- ============ RF ============ -->
    <!-- （频率设置已并入 /multi 的「信道规划」，此页已删除） -->
    <section class="page proto-page" id="page-freq-old" style="display:none;visibility:hidden;height:0;overflow:hidden">
      
    </section>

    <section class="page proto-page" id="page-rf" style="display:none">
<div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none">
                <path d="M3 12h3l3-8 3 16 3-8h6" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
              </svg>
            </div>
            <div>
              <div class="card-title">频率与信道规划（统一设置）</div>
              <div class="card-sub">本机是<b>多信道同时收发</b>：先定<b>中心频率与通道间距</b>，再为每路通道选择协议、
                必要时单独改该通道的频率。保存后会写入 MMDVM-Multi.ini 与各通道 MMDVM-Host-N.ini，
                并只重启受影响的通道 —— 其它协议页不再需要（也不应该）单独设频率。</div>
            </div>
          </div>
          <span class="card-badge">唯一来源</span>
        </div>

        <div id="freqNow" class="freq-now">
          <div class="mc-empty">正在读取当前各通道频率...</div>
        </div>

        <div class="form-grid" style="margin-top:14px">
          <div class="fld"><label>通道数量 (1-6)   <!-- CH_LIMIT_FIX --></label>
            <input id="fCh" type="number" min="1" max="6" value="1" inputmode="numeric">
            <div class="hint">减少通道会自动停掉并禁用多余的通道实例</div></div>
          <div class="fld"><label>接收中心频率 (MHz)</label>
            <input id="fRx" type="text" inputmode="decimal" value="434.450" oninput="freqHint()">
            <div class="hint" id="fRxMhz">= 434450000 Hz</div></div>
          <div class="fld"><label>发射中心频率 (MHz)</label>
            <input id="fTx" type="text" inputmode="decimal" value="439.450" oninput="freqHint()">
            <div class="hint" id="fTxMhz">= 439450000 Hz</div></div>
          <div class="fld"><label>通道间距 (kHz)</label>
            <input id="fGap" type="text" inputmode="decimal" value="25" oninput="freqHint()">
            <div class="hint">25 = 25 kHz 标准网格；12.5 = 窄带</div></div>
        </div>

        <div class="freq-rows" id="fRows">
          <div class="mc-empty">正在读取通道规划...</div>
        </div>

        <div class="save-row">
          <button class="save-btn rf" onclick="freqSave(false)">✓ 保存并应用（写入配置 + 重启受影响通道）</button>
          <button class="refresh-btn" onclick="freqSave(true)">仅保存规划</button>
          <button class="refresh-btn" onclick="freqLoad()">↻ 重新读取</button>
        </div>
        <div class="hint" style="margin-top:10px">
          说明：每个通道的 RX/TX 留空或点「按间距算」= 使用公式值；直接填写则使用你填的具体频率。
          多信道同时工作时，各通道频率<b>不能重叠</b>（间距需 ≥ 12.5 kHz）。
        </div>
      </div>
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">📻</div>
            <div>
              <div class="card-title">全局射频载波与基带参数 (RF &amp; Station)</div>
              <div class="card-sub">MMDVM-Host 与 SX1255 物理射频基带集中设置：对 DMR / YSF / P25 / NXDN / D-STAR 等全部模式统一生效</div>
            </div>
          </div>
          <span class="card-badge">SX1255 · DUPLEX</span>
        </div>

        <form class="form-grid" id="form-rf" onsubmit="saveForm(event,'rf')">
          <div class="fld"><label>热点呼号 (Callsign - 全局)</label>
            <input name="callsign" value="" placeholder="NOCALL"><div class="hint">写入 MMDVM-Host.ini [General]，各数字模式通用</div></div>
          <div class="fld full"><div class="hint" style="border:1px dashed var(--border-lit);border-radius:9px;padding:10px 12px;line-height:1.7">
            <b style="color:#fbbf24">频率已统一管理，本页不再单独设置。</b><br>
            本机是<b>多信道同时收发</b>（每路通道可跑不同协议、不同频率），所以频率只在
            左侧「<b>🧩 Multi 多信道配置 → 信道规划</b>」里设置：每路通道一行，填协议 + RX + TX，
            点「应用规划」后自动写入 MMDVM-Multi.ini 与各通道 MMDVM-Host-N.ini。
            本页只负责<b>呼号、发射功率、基带增益</b>。
            <div style="margin-top:9px">
              <button type="button" class="btn btn-green btn-sm" onclick="switchPage('rfmode')">前往「Multi 多信道配置 → 信道规划」设置 →</button>
            </div>
          </div></div>
          <input type="hidden" name="duplex" value="1">
          <div class="fld"><label>工作模式（固定）</label>
            <input value="全双工热点 (Duplex Hotspot) — 本系统固定双工运行" readonly
                   style="background:#0b1420;color:#7d93b2;cursor:not-allowed"></div>
          <div class="fld"><label>射频发射功率档位 (SX1255)</label>
            <select name="power" id="rfPower" onchange="rfPowerChanged()">
              <option value="7">大功率 — 约 +7 dBm / 5 mW（远距离、抗干扰）</option>
              <option value="0">小功率 — 0 dB / 0 dBm / 1 mW（近距离、更健康）</option>
            </select>
            <div class="hint">切换档位会自动调整下面的 TxGain（大功率=30dB / 小功率=10dB），也可再手动微调</div></div>
          <div class="fld"><label>SX1255 硬件基带接收增益 (RxGain 0-60 dB)</label>
            <input name="rxgain" type="number" value="50"><div class="hint">写入 MMDVM-IQ.ini [Soapy] 推荐 50dB</div></div>
          <div class="fld"><label>SX1255 硬件基带发射增益 (TxGain 0-40 dB)</label>
            <input name="txgain" id="rfTxGain" type="number" value="30"><div class="hint">写入 MMDVM-IQ.ini [Soapy]：大功率推荐 30dB / 小功率推荐 10dB（切档位会自动填）</div></div>
          <div class="fld full"><label>台站信息描述 (Description)</label>
            <input name="description" value="ARCH Duplex Hotspot"><div class="hint">仅作记录显示；后端 save-rf 暂不接受该参数（要改请编辑 MMDVM-Host.ini 的 [Info] Description）</div><div class="hint">DMR 身份识别码 (Radio ID) 已移到 <b>DMR 模式</b>页面 —— 它只对 DMR 有效</div></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-rf" class="save-btn rf">✓ 保存射频参数并更新 MMDVM-Host.ini &amp; MMDVM-IQ.ini</button>
          <button class="refresh-btn" onclick="restartSvc('mmdvmhost')">↻ 重启射频基带</button>
        </div>
      </div>
    </section>

    <!-- ============ DMR ============ -->
    <section class="page proto-page" id="page-dmr" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--green-dim);color:var(--green2)">🎙️</div>
            <div>
              <div class="card-title">DMR 模式配置与 BrandMeister 网关</div>
              <div class="card-sub">BrandMeister Master 服务器路由与色码/ESSID 参数</div>
            </div>
          </div>
          <span class="card-badge">DMRGateway</span>
        </div>

        <div class="freq-banner">
          <div class="freq-banner-ico">📡</div>
          <div class="freq-banner-txt">
            <b>当前热点物理射频载波频率 <span style="color:var(--green2);font-size:11.5px;margin-left:6px">全模式共享</span></b>
            <span>RX: <b class="dyn-rx">--</b> MHz ｜ TX: <b class="dyn-tx">--</b> MHz ｜ 差频: <b class="dyn-off">--</b> MHz</span>
            <span style="margin-top:5px;color:var(--muted2);font-size:11.5px">手台侧：TX <b class="dyn-rx">--</b> MHz ｜ RX <b class="dyn-tx">--</b> MHz ｜ 色码按本页设置，时隙 1/2 均启用</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="switchPage('rfmode')">⚙ 修改射频频率 →</button>
        </div>

        <div class="preset-box" style="padding:0;border:none;background:none">
          <div class="sp" id="sp-dmr">
            <div class="sp-head">
              <span class="sp-title">🌐 DMR 服务器选择器</span>
              <span class="sp-hint" id="spDmrSrc">数据源：ARCH Cloudflare 镜像（每 4 小时同步）</span>
            </div>
            <div class="sp-bar">
              <span class="sp-ico">🔎</span>
              <input class="sp-in" id="spDmrIn" autocomplete="off" spellcheck="false"
                     placeholder="搜索服务器：4602 / China / 日本 / 韩国 …（默认只列 BrandMeister，可点下方网络标签切换）">
              <span class="sp-cnt" id="spDmrCnt">加载中…</span>
            </div>
            <div class="sp-chips" id="spDmrChips"></div>
            <div class="sp-list" id="spDmrList"><div class="sp-empty">正在读取主机库…</div></div>
            <div class="sp-sel">
              <span>当前选择：</span><b id="spDmrSel">（未选择）</b>
              <button type="button" class="sp-go" onclick="spLoadCurrent('dmr')">↺ 载入当前配置</button>
            </div>
          </div>
        </div>

        <div class="bm-bar" id="bmBar">切换到本页会自动检测 BrandMeister 会话状态</div>
        <div class="mc-foot" style="margin:0 0 12px">
          <button class="btn btn-green" onclick="bmCheck()">↻ 重新检测 BM 会话</button>
          <button class="btn" onclick="restartSvc('dmrgateway')">重连 DMRGateway</button>
          <span class="mc-tip">BrandMeister 会校验配置报文里的<b>呼号</b>与 DMR ID：
            呼号与 BM 注册不一致时 Master 会立刻断开（日志 "Master is closing down"）。
            本页保存后系统会自动把网关呼号同步为上面的呼号。</span>
        </div>

        <form class="form-grid" id="form-dmr" onsubmit="saveForm(event,'dmr')">
          <div class="fld"><label>电台呼号 (Callsign)</label><input name="callsign" value="" placeholder="NOCALL"></div>
          <div class="fld"><label>DMR ID（7 位）</label>
            <input name="id" value="" inputmode="numeric" autocomplete="off" placeholder="7 位数字，例如 4600123">
            <div class="essid-note">写入 MMDVM-Host.ini <b>[General] Id</b> 与 DMRGateway 的 <b>Id</b>。</div>
          </div>
          <div class="fld"><label>ESSID 扩展（可选）</label>
            <div style="display:flex;align-items:center;gap:10px">
              <label style="display:flex;align-items:center;gap:6px;cursor:pointer;font-size:13.5px;color:#9fb6d6">
                <input type="checkbox" id="essidOn" checked style="accent-color:#10b981;width:15px;height:15px">启用 ESSID
              </label>
              <input name="essid" id="essidVal" value="01" placeholder="01" maxlength="2" inputmode="numeric" autocomplete="off" style="width:90px">
            </div>
            <div class="essid-note">
              · <b>启用</b>：最终 ID = <b>7 位 DMR ID</b> + <b>2 位 ESSID</b>（01–99），例 <b>4600123</b> + <b>01</b> → <b>460012301</b><br>
              · <b>不启用</b>：仅使用 7 位 DMR ID（部分网络或热点不需要 ESSID）
            </div>
          </div>
          <div class="fld"><label>色码 ColorCode (0-15)</label><input name="cc" type="number" value="1" min="0" max="15"></div>
          <div class="fld"><label>BM 服务器主机地址（可手动修改）</label>
            <input name="bm_host" id="dmrBmHost" value="" autocomplete="off" spellcheck="false" placeholder="从上方选择器点选，或粘贴主机名 / IP">
            <div class="essid-note">从上方选择器点选会自动填入；也可直接粘贴主机名或 IP</div>
          </div>
          <div class="fld"><label>BM 端口 (默认 62031)</label><input name="bm_port" type="number" value="62031"></div>
          <div class="fld"><label>BrandMeister 热点连接密码</label><input name="bm_pw" type="password" placeholder="请输入热点连接密码，非 BM 网站登录密码"></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-dmr" class="save-btn dmr">✓ 保存 DMR 参数并重载网关</button>
          <button class="refresh-btn" onclick="spRefresh('dmr')">↻ 重新载入服务器列表</button>
          <button class="refresh-btn" onclick="updateDb('dmrhosts')">↻ 更新 DMR 服务器列表（ARCH 镜像）</button>
          <button class="refresh-btn" onclick="rebuildHosts()">⟳ 从本地主机文件重建主机库</button>
        </div>
      </div>
    </section>

    <!-- ============ YSF ============ -->
    <section class="page proto-page" id="page-ysf" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(6,182,212,.12);color:#22d3ee">🔊</div>
            <div>
              <div class="card-title">YSF 模式配置 (Yaesu System Fusion / C4FM)</div>
              <div class="card-sub">WIRES-X 房间互联、指令穿透与 FT-70D 大写兼容支持</div>
            </div>
          </div>
          <span class="card-badge" style="background:rgba(6,182,212,.12);color:#22d3ee;border-color:rgba(6,182,212,.4)">YSFGateway</span>
        </div>
        <div class="freq-banner">
          <div class="freq-banner-ico" style="background:rgba(6,182,212,.12);color:#22d3ee">📡</div>
          <div class="freq-banner-txt">
            <b>当前热点物理射频载波频率 <span style="color:#22d3ee;font-size:11.5px;margin-left:6px">全模式共享</span></b>
            <span>RX: <b class="dyn-rx">--</b> MHz ｜ TX: <b class="dyn-tx">--</b> MHz ｜ 差频: <b class="dyn-off">--</b> MHz</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="switchPage('rfmode')">⚙ 修改射频频率 →</button>
        </div>
        <div class="preset-box" style="padding:0;border:none;background:none">
          <div class="sp" id="sp-ysf">
            <div class="sp-head">
              <span class="sp-title">🌐 YSF / WIRES-X 房间选择器</span>
              <span class="sp-hint" id="spYsfSrc">数据源：Pi-Star YSFHosts</span>
            </div>
            <div class="sp-bar">
              <span class="sp-ico">🔎</span>
              <input class="sp-in" id="spYsfIn" autocomplete="off" spellcheck="false"
                     placeholder="搜索房间：编号 / 国家 / 名称，例如 46087 / CN / China">
              <span class="sp-cnt" id="spYsfCnt">加载中…</span>
            </div>
            <div class="sp-chips" id="spYsfChips"></div>
            <div class="sp-list" id="spYsfList"><div class="sp-empty">正在读取房间列表…</div></div>
            <div class="sp-sel">
              <span>当前选择：</span><b id="spYsfSel">（未选择）</b>
              <button type="button" class="sp-go" onclick="spLoadCurrent('ysf')">↺ 载入当前配置</button>
            </div>
          </div>
        </div>
        <form class="form-grid" id="form-ysf" onsubmit="saveForm(event,'ysf')" style="margin-top:14px">
          <input type="hidden" name="suffix" value="RPT">
          <div class="fld"><label>呼号 (Callsign)</label><input name="callsign" value="" placeholder="NOCALL"></div>
          <div class="fld"><label>启动房间 (Startup Room)</label>
            <input name="startup" id="ysfStartup" value="" placeholder="用上方选择器点选，或手填房间名称（如 CN-YSF-TG46020）；留空=不自动连接">
            <div class="hint">本机 YSFGateway 按 <b>反射器名称</b> 连接（官方示例：<code>Startup=Alabama-Link</code>），所以要填 <b>名称</b>、不是房间号。上方选择器可按 编号 / 国家 / 名称 搜索 1400+ 个 Pi-Star YSF 房间，点选后自动填入名称。</div></div>
          <div class="fld"><label>WIRES-X 指令穿透 (Command Passthrough)</label>
            <select name="wiresx">
              <option value="1">启用（允许手台用 WIRES-X 自行切换房间）</option>
              <option value="0">禁用（固定锁定启动房间）</option>
            </select>
            <div class="hint">启用后可用手台 WIRES-X 搜索并切换房间</div></div>
          <div class="fld"><label>UPPERCASE Hostfiles — 八重洲 FT-70D 兼容</label>
            <select name="uppermode">
              <option value="1">启用（WiresXMakeUpper=1）</option>
              <option value="0">禁用（WiresXMakeUpper=0）</option>
            </select>
            <div class="hint">FT-70D 的 WIRES-X 搜索<b>只识别大写字母</b>。房间名含小写字母时
              FT-70D 搜不到、连不上。启用后网关会把房间名统一转为大写，
              使 FT-70D 能正常搜索并连接房间。</div></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-ysf" class="save-btn ysf">✓ 保存并应用 YSF 配置</button>
          <button class="refresh-btn" onclick="updateDb('ysfhosts')">↻ 更新 YSF 房间列表</button>
        </div>
      </div>
    </section>

    <!-- ============ P25 ============ -->
    <section class="page proto-page" id="page-p25" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(139,92,246,.12);color:#a78bfa">📡</div>
            <div>
              <div class="card-title">P25 (APCO Phase 1) 数字参数</div>
              <div class="card-sub">手台设置：TX=本页 RX，RX=本页 TX；NAC 293；TG 46002。支持 Pi-Star P25Hosts 全球呼叫组预设切换</div>
            </div>
          </div>
          <span class="card-badge" style="background:rgba(139,92,246,.12);color:#a78bfa;border-color:rgba(139,92,246,.4)">P25Gateway</span>
        </div>
        <div class="freq-banner">
          <div class="freq-banner-ico" style="background:rgba(139,92,246,.12);color:#a78bfa">📡</div>
          <div class="freq-banner-txt">
            <b>当前热点物理射频载波频率 <span style="color:#a78bfa;font-size:11.5px;margin-left:6px">全模式共享</span></b>
            <span>RX: <b class="dyn-rx">--</b> MHz ｜ TX: <b class="dyn-tx">--</b> MHz ｜ 差频: <b class="dyn-off">--</b> MHz</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="switchPage('rfmode')">⚙ 修改射频频率 →</button>
        </div>
        <div class="preset-box" style="border-color:rgba(139,92,246,.3)">
          <div class="preset-box-lbl" style="color:#a78bfa">🌐 P25 呼叫组预设（点选即写入下方「静态通话组」）</div>
          <select id="p25Preset" onchange="applyStaticPreset('p25', this.value)">
            <option value="">— 请选择预设呼叫组 (TG) —</option>
            <option value="10460">P25-10460（CN China Group 中国呼叫组 / TG 10460）</option>
            <option value="10100">P25-10100（Asia Pacific 亚太区 / TG 10100）</option>
            <option value="10200">P25-10200（Europe 欧洲区 / TG 10200）</option>
          </select>
          <div class="hint" id="p25HostHint">主机库加载后会自动补充 Pi-Star P25Hosts 预设</div>
        </div>
        <form class="form-grid" id="form-p25" onsubmit="saveForm(event,'p25')">
          <div class="fld"><label>呼号 (Callsign)</label><input name="callsign" value="" placeholder="NOCALL"></div>
          <div class="fld"><label>NAC 访问码 (默认 293 / $293)</label><input name="nac" value="293"></div>
          <div class="fld full"><label>静态通话组列表 (Static TGs)</label><input id="p25Static" name="static" value="46002"></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-p25" class="save-btn p25">✓ 保存并应用 P25 配置</button>
          <button class="refresh-btn" onclick="updateDb('p25hosts')">↻ 更新 P25 主机列表</button>
        </div>
      </div>
    </section>

    <!-- ============ NXDN ============ -->
    <section class="page proto-page" id="page-nxdn" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(236,72,153,.12);color:#f472b6">📶</div>
            <div>
              <div class="card-title">NXDN (IDAS / Nexedge) 数字参数</div>
              <div class="card-sub">支持 Pi-Star NXDNHosts 全球通话组预设选择</div>
            </div>
          </div>
          <span class="card-badge" style="background:rgba(236,72,153,.12);color:#f472b6;border-color:rgba(236,72,153,.4)">NXDNGateway</span>
        </div>
        <div class="freq-banner">
          <div class="freq-banner-ico" style="background:rgba(236,72,153,.12);color:#f472b6">📡</div>
          <div class="freq-banner-txt">
            <b>当前热点物理射频载波频率 <span style="color:#f472b6;font-size:11.5px;margin-left:6px">全模式共享</span></b>
            <span>RX: <b class="dyn-rx">--</b> MHz ｜ TX: <b class="dyn-tx">--</b> MHz ｜ 差频: <b class="dyn-off">--</b> MHz</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="switchPage('rfmode')">⚙ 修改射频频率 →</button>
        </div>
        <div class="preset-box" style="border-color:rgba(236,72,153,.3)">
          <div class="preset-box-lbl" style="color:#f472b6">🌐 NXDN 通话组预设（点选即写入下方「静态通话组」）</div>
          <select id="nxdnPreset" onchange="applyStaticPreset('nxdn', this.value)">
            <option value="">— 请选择预设通话组 (TG) —</option>
            <option value="10460">NXDN-10460（CN China National 核心互联组）</option>
            <option value="65000">NXDN-65000（World-Wide 全球通用）</option>
          </select>
          <div class="hint" id="nxdnHostHint">主机库加载后会自动补充 Pi-Star NXDNHosts 预设</div>
        </div>
        <form class="form-grid" id="form-nxdn" onsubmit="saveForm(event,'nxdn')">
          <div class="fld"><label>呼号 (Callsign)</label><input name="callsign" value="" placeholder="NOCALL"></div>
          <div class="fld"><label>NXDN 用户 ID</label><input name="id" type="number" value="" placeholder="例如 46164"></div>
          <div class="fld"><label>RAN 访问码 (1-63)</label><input name="ran" type="number" value="1" min="1" max="63"></div>
          <div class="fld"><label>协议格式 (Protocol)</label>
            <select name="protocol"><option value="Icom">Icom (IDAS 协议)</option><option value="Kenwood">Kenwood (Nexedge 协议)</option></select></div>
          <div class="fld"><label>静态通话组 (Static TGs)</label><input id="nxdnStatic" name="static" value="10460, 65000"></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-nxdn" class="save-btn nxdn">✓ 保存并应用 NXDN 配置</button>
          <button class="refresh-btn" onclick="updateDb('nxdnhosts')">↻ 更新 NXDN 主机列表</button>
        </div>
      </div>
    </section>

    <!-- ============ D-STAR ============ -->
    <section class="page proto-page" id="page-dstar" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(249,115,22,.12);color:#fb923c">⭐</div>
            <div>
              <div class="card-title">D-STAR / ircDDBGateway 参数</div>
              <div class="card-sub">支持 Pi-Star 全球 REF / XRF / DCS 反射器选择</div>
            </div>
          </div>
          <span class="card-badge" style="background:rgba(249,115,22,.12);color:#fb923c;border-color:rgba(249,115,22,.4)">ircddbgatewayd</span>
        </div>
        <div class="freq-banner">
          <div class="freq-banner-ico" style="background:rgba(249,115,22,.12);color:#fb923c">📡</div>
          <div class="freq-banner-txt">
            <b>当前热点物理射频载波频率 <span style="color:#fb923c;font-size:11.5px;margin-left:6px">全模式共享</span></b>
            <span>RX: <b class="dyn-rx">--</b> MHz ｜ TX: <b class="dyn-tx">--</b> MHz ｜ 差频: <b class="dyn-off">--</b> MHz</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="switchPage('rfmode')">⚙ 修改射频频率 →</button>
        </div>
        <div class="preset-box" style="padding:0;border:none;background:none">
          <div class="sp" id="sp-dstar">
            <div class="sp-head">
              <span class="sp-title">⭐ D-STAR 反射器选择器</span>
              <span class="sp-hint" id="spDstarSrc">数据源：Pi-Star DPlus / DExtra / DCS / CCS / XLX / FCS</span>
            </div>
            <div class="sp-bar">
              <span class="sp-ico">🔎</span>
              <input class="sp-in" id="spDstarIn" autocomplete="off" spellcheck="false"
                     placeholder="搜索反射器：REF / XRF / DCS / CCS / XLX / FCS，或输入编号、国家名">
              <span class="sp-cnt" id="spDstarCnt">加载中…</span>
            </div>
            <div class="sp-chips" id="spDstarChips"></div>
            <div class="sp-list" id="spDstarList"><div class="sp-empty">正在读取反射器库…</div></div>
            <div class="sp-mod">
              <label>模块</label>
              <select id="spDstarMod">
                <option value="A" selected>A（ARCH 多模融合，推荐）</option><option value="B">B</option>
                <option value="C">C</option><option value="D">D</option>
                <option value="E">E</option><option value="F">F</option>
                <option value="G">G</option><option value="H">H</option>
                <option value="I">I</option><option value="J">J</option>
                <option value="K">K</option><option value="L">L</option>
                <option value="M">M</option><option value="N">N</option>
                <option value="O">O</option><option value="P">P</option>
                <option value="Q">Q</option><option value="R">R</option>
                <option value="S">S</option><option value="T">T</option>
                <option value="U">U</option><option value="V">V</option>
                <option value="W">W</option><option value="X">X</option>
                <option value="Y">Y</option><option value="Z">Z</option>
              </select>
              <label>开机</label>
              <select id="spDstarStartup">
                <option value="1" selected>自动连接 (Startup)</option>
                <option value="0">手动连接 (Manual)</option>
              </select>
              <span class="sp-hint">模块 A-Z 对应反射器房间（XLX 最多 26 个）。ARCH 的 46002 多模融合挂在 <b>XLXHLJ 模块 A</b>；选好反射器和模块后点保存，网关会自动重连。</span>
            </div>
            <div class="sp-sel">
              <span>当前选择：</span><b id="spDstarSel">（未选择）</b>
              <button type="button" class="sp-go" onclick="spLoadCurrent('dstar')">↺ 载入当前配置</button>
            </div>
          </div>
        </div>
        <div class="preset-box" style="border-color:rgba(249,115,22,.3);margin-top:10px">
          <div class="preset-box-lbl" style="color:#fb923c">🌐 ircDDB 网关路由服务器 (ircDDB Server Host)</div>
          <select id="dstarIrcHost">
            <option value="rr.ircddb.net">rr.ircddb.net（ircDDB 官方）</option>
            <option value="rr.opendv.net">rr.opendv.net（OpenDV）</option>
            <option value="rr.aprs2.net">rr.aprs2.net（备用）</option>
          </select>
          <div class="hint">呼叫路由（ircddbEnabled=1）时使用的服务器，写入
            <b>/etc/ircddbgateway</b> 的 <b>ircddbHostname</b>。</div>
        </div>
        <form class="form-grid" id="form-dstar" onsubmit="saveForm(event,'dstar')" style="margin-top:14px">
          <input type="hidden" name="reflector1" id="dstar_reflector" value="">
          <input type="hidden" name="dstar_module" id="dstar_module" value="B">
          <input type="hidden" name="atStartup1" id="dstar_atstartup" value="1">
          <div class="fld"><label>网关呼号 (Gateway Callsign, 尾带 G)</label><input name="gw_call" value="" placeholder="NOCALL  G"></div>
          <div class="fld"><label>模块呼号 (Module Callsign, 尾带 B/C)</label><input name="rep_call" value="" placeholder="NOCALL  B"></div>
          <div class="fld"><label>ircDDB 登录用户名</label><input name="irc_user" value="" placeholder="小写呼号"></div>
          <div class="fld"><label>ircDDB 认证密码</label><input name="irc_pw" type="password" placeholder="请输入热点连接密码，非 BM 网站登录密码"></div>

          <div class="fld full" style="grid-column:1/-1;margin-top:6px">
            <div style="border-top:1px solid #1e2b44;padding-top:12px">
              <div style="font-size:13.5px;font-weight:650;color:#fb923c;margin-bottom:10px">
                ⚙ D-STAR 网关开关（ircDDBGateway）
              </div>
              <div class="form-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px">

                <div class="fld"><label>呼叫路由 (Callsign Routing)</label>
                  <select name="callsign_routing" id="dsRouting">
                    <option value="1">启用 —— 接入 ircDDB 网络，支持呼叫路由</option>
                    <option value="0">关闭 —— 不使用 ircDDB 呼叫路由</option>
                  </select>
                  <div class="hint">启用后写入 ircDDBGateway 的 <b>ircddbEnabled=1</b>：
                    其他电台可直接用你的呼号呼叫你（Callsign Routing），
                    而不需要知道你当前挂在哪个反射器上。需要上方 ircDDB 用户名/密码正确。</div>
                </div>

                <div class="fld"><label>时间通告 (Time Announcement)</label>
                  <select name="time_announce" id="dsTime">
                    <option value="1">启用 —— 语音播报当前时间</option>
                    <option value="0">关闭</option>
                  </select>
                  <div class="hint">启用后由 <b>timeserver</b> 定时在 D-STAR 上语音播报日期与时间。
                    实际开关状态记录在 <b>/etc/timeserver.dissable</b> 标记文件（存在=关闭），
                    播报的语言与间隔写入 <b>/etc/timeserver</b>。</div>
                </div>

                <div class="fld"><label>XRF 使用 D-Plus 协议 (Use D-Plus for XRF)</label>
                  <select name="use_dplus_xrf" id="dsXrf">
                    <option value="0">关闭 —— XRF 走原生 DExtra 协议</option>
                    <option value="1">启用 —— XRF 主机走 D-Plus 协议</option>
                  </select>
                  <div class="hint">部分网络（如 XLX 反射器）的 XRF 端口只接受 D-Plus 协议。
                    启用后写入 <b>/etc/hostfiles.nodextra</b> 标记文件，
                    让 XRF 主机列表使用 D-Plus 方式解析与连接。</div>
                </div>

              </div>
            </div>
          </div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-dstar" class="save-btn dstar">✓ 保存并应用 D-STAR 配置</button>
          <button class="refresh-btn" onclick="spRefresh('dstar')">↻ 重新载入反射器列表</button>
          <button class="refresh-btn" onclick="rebuildHosts()">⟳ 从 Pi-Star 重建主机库</button>
        </div>
      </div>
    </section>

    <!-- ============ FM ============ -->
    <section class="page proto-page" id="page-fm" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">🎛️</div>
            <div>
              <div class="card-title">FM 模拟双工异频参数</div>
              <div class="card-sub">传统模拟 FM 热点，支持 CTCSS 亚音</div>
            </div>
          </div>
          <span class="card-badge">Analog Duplex</span>
        </div>
        <div class="freq-banner">
          <div class="freq-banner-ico">📡</div>
          <div class="freq-banner-txt">
            <b>当前热点物理射频载波频率 <span style="color:var(--green2);font-size:11.5px;margin-left:6px">全模式共享</span></b>
            <span>RX: <b class="dyn-rx">--</b> MHz ｜ TX: <b class="dyn-tx">--</b> MHz ｜ 差频: <b class="dyn-off">--</b> MHz</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="switchPage('rfmode')">⚙ 修改射频频率 →</button>
        </div>
        <form class="form-grid" id="form-fm" onsubmit="saveForm(event,'fm')">
          <div class="fld"><label>呼号 (Callsign)</label><input name="callsign" value="" placeholder="NOCALL"></div>
          <div class="fld"><label>CTCSS 亚音频 (Hz)</label><input name="ctcss" value="88.5"></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-fm" class="save-btn rf">✓ 保存并应用 FM 配置</button>
        </div>
      </div>
    </section>

    <!-- ============ POCSAG ============ -->
    <section class="page proto-page" id="page-pocsag" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(245,158,11,.12);color:#fbbf24">📟</div>
            <div>
              <div class="card-title">POCSAG 数字寻呼与 DAPNET 接入</div>
              <div class="card-sub">支持无线电爱好者去中心化寻呼网络 DAPNET 网关</div>
            </div>
          </div>
          <span class="card-badge" style="background:rgba(245,158,11,.12);color:#fbbf24;border-color:rgba(245,158,11,.4)">Pager Tx</span>
        </div>
        <div class="preset-box" style="border-color:rgba(245,158,11,.3)">
          <div class="preset-box-lbl" style="color:#fbbf24">🌐 DAPNET 服务器地址 (DAPNET Server)</div>
          <select>
            <option value="dapnet.afu.rwth-aachen.de">dapnet.afu.rwth-aachen.de</option>
            <option value="dapnet.proxy.cn">dapnet.proxy.cn</option>
          </select>
        </div>
        <form class="form-grid" id="form-pocsag" onsubmit="saveForm(event,'pocsag')" style="margin-top:14px">
          <div class="fld"><label>寻呼发射频率 (MHz)</label>
            <input id="pocMhz" type="text" inputmode="decimal" value="439.500" oninput="pocFreqTyped()">
            <input type="hidden" name="freq" id="pocFreq" value="439500000">
            <div class="hint" id="pocMhzHz">= 439500000 Hz（按习惯填 MHz：439.500 / 439.5125）</div></div>
          <div class="fld"><label>DAPNET 鉴权密钥</label><input type="password" placeholder="请输入热点连接密码，非 BM 网站登录密码"></div>
        </form>
        <div class="save-row">
          <button type="submit" form="form-pocsag" class="save-btn pocsag">✓ 保存并应用 POCSAG 配置</button>
        </div>
      </div>
    </section>

    <!-- ============ Multi 多信道配置（内嵌 /multi 看板） ============ -->
    <!-- ============ 射频模式配置（独立页面内嵌） ============ -->

    <!-- ============ 端口矩阵 ============ -->

    <section class="page proto-page" id="page-ports" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">🔌</div>
            <div>
              <div class="card-title">UDP 端口拓扑与套接字进程矩阵</div>
              <div class="card-sub">实时 ss -lunp 端口占用探针</div>
            </div>
          </div>
          <span class="card-badge">自愈校验 OK</span>
        </div>
        <div id="portList" style="margin-top:8px">
          <div class="qso-empty">正在扫描端口...</div>
        </div>
      </div>
    </section>

    <!-- ============ 数据库 ============ -->
    <section class="page proto-page" id="page-db" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">🗄️</div>
            <div>
              <div class="card-title">服务器与呼号数据库更新中心</div>
              <div class="card-sub">一键同步全球最新 Master 主节点、YSF 房间列表与 RadioID.net 呼号库</div>
            </div>
          </div>
          <span class="card-badge">Sync Ready</span>
        </div>
        <div class="db-cards">
          <div class="db-card">
            <h3>🌐 更新网关主机与房间列表 <span class="hdr-tag blue">每日自动</span></h3>
            <p>拉取最新的 DMR_Hosts、YSFHosts、P25Hosts 与 NXDNHosts，收录全球 2,900+ 节点。</p>
            <button class="btn btn-blue" style="width:100%" onclick="updateDb('hosts')">
              ⬇ 立即更新网关服务器列表
            </button>
          </div>
          <div class="db-card">
            <h3>🌍 更新全球呼号与 ID 数据库 <span class="hdr-tag green">RadioID.net</span></h3>
            <p>下载全球业余无线电电台数据 (DMRIds.dat / user.csv)，在热点通话时直接屏幕解析呼号、姓名与所属国籍。</p>
            <button class="btn btn-green" style="width:100%" onclick="updateDb('dmrid')">
              ⟳ 立即更新全球 ID 数据库
            </button>
          </div>
        </div>
        <div style="margin-top:18px">
          <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:10px">
            <div style="display:flex;align-items:center;gap:8px;font-size:13px;font-weight:800;color:var(--green2)">
              &gt;_ 更新任务实时输出控制台
            </div>
            <span style="font-size:13px;color:var(--dim);font-family:var(--mono)">/usr/local/bin/update-database.sh</span>
          </div>
          <div class="console" id="dbConsole"><span class="tag-in">[系统启动]</span> 本地主机与 ID 数据库已加载就绪
当前网关主机版本: 2026.09.08-rev4（收录 540+ BM 主节点，1,840+ YSF 房间，320+ P25 组）
当前全球 RadioID 数据库: 285,412 条台站呼号映射记录（RadioID.net 同步）</div>
        </div>
      </div>
    </section>

    <!-- ============ 系统设置 ============ -->
    <section class="page proto-page" id="page-sys" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico">⚙️</div>
            <div>
              <div class="card-title">系统设置</div>
              
            </div>
          </div>
          <span class="card-badge">SYSTEM</span>
        </div>
        <div class="form-grid">
          <div class="fld full"><label>系统信息</label>
            <div class="hint" id="sysInfo" style="line-height:1.9">读取中…</div></div>
          <div class="fld full"><label>网络状态</label>
            <div class="hint" id="sysNet" style="line-height:1.9">读取中…</div>
            <div style="margin-top:8px"><button class="btn" onclick="sysLoad()">↻ 刷新信息</button></div></div>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(34,211,238,.12);color:#22d3ee">📶</div>
            <div>
              <div class="card-title">WiFi 无线网络</div>
              
            </div>
          </div>
          <span class="card-badge" id="wifiBackend">WIFI</span>
        </div>
        <div class="hint" id="wifiStatus" style="line-height:1.9">读取中…</div>
        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin:12px 0">
          <button class="btn btn-green" onclick="wifiScan()">🔎 扫描附近 WiFi</button>
          <button class="btn" onclick="wifiSaved()">📋 已保存的网络</button>
          <button class="btn" onclick="wifiStatusShow()">↻ 刷新状态</button>
          <button class="btn" id="wifiRadioBtn" onclick="wifiRadioToggle()">📴 关闭 WiFi</button>
          <span class="hint" id="wifiMsg"></span>
        </div>
        <div id="wifiList" class="hint" style="line-height:2;max-height:300px;overflow:auto;border:1px solid var(--border);border-radius:8px;padding:8px">点「扫描附近 WiFi」列出热点（含 2.4G/5G、信号、加密方式）；点「用它」自动填入 SSID</div>
        <div class="form-grid" style="margin-top:14px">
          <div class="fld"><label>SSID（WiFi 名称，支持中文）</label>
            <input id="wifiSsid" placeholder="例如 MLCIA 4"></div>
          <div class="fld"><label>密码（留空 = 开放网络）</label>
            <input id="wifiPw" type="password" placeholder="WPA / WPA2 / WPA3 密码">
            <div class="hint">连接成功后写入系统，重启自动重连</div></div>
          <div class="fld"><label>附加选项</label>
            <label style="display:flex;gap:7px;align-items:center;font-size:13px;color:var(--muted)">
              <input type="checkbox" id="wifiHidden"> 隐藏网络（不广播 SSID，需手动输入）
            </label></div>
          <div class="fld full">
            <button class="btn btn-green" onclick="wifiConnect()">✓ 连接此 WiFi</button>
            <button class="btn" style="margin-left:8px" onclick="wifiDisconnect()">断开当前 WiFi</button>
            <span class="hint" id="wifiConnMsg"></span>
          </div>
        </div>
        <div id="wifiSavedBox" style="margin-top:14px"></div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(96,165,250,.12);color:#60a5fa">🛠️</div>
            <div>
              <div class="card-title">网络参数（IP / 掩码 / 网关 / DNS）</div>
              <div class="card-sub">有线与无线都支持，改完立即生效；若网段变化，本页面会断开，请用新地址重新访问</div>
            </div>
          </div>
          <span class="card-badge">LAN / WLAN</span>
        </div>
        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
          <span class="hint">网卡</span>
          <select id="netIface" onchange="netParamsLoad()" style="min-width:180px">
            <option value="eth0">eth0（有线网口）</option>
            <option value="wlan0">wlan0（WiFi 无线）</option>
          </select>
          <span class="hint">获取方式</span>
          <select id="netMode" style="min-width:150px">
            <option value="static">静态地址</option>
            <option value="dhcp">自动获取 (DHCP)</option>
          </select>
          <button class="btn" onclick="netParamsLoad()">↻ 读取当前配置</button>
          <span class="hint" id="netMsg"></span>
        </div>
        <div class="form-grid">
          <div class="fld"><label>IP 地址</label><input id="netIp" placeholder="192.168.7.134"></div>
          <div class="fld"><label>子网掩码 / 前缀</label><input id="netMask" placeholder="255.255.255.0 或 24"></div>
          <div class="fld"><label>网关</label><input id="netGw" placeholder="192.168.7.1"></div>
          <div class="fld"><label>DNS（多个用逗号分隔）</label><input id="netDns" placeholder="192.168.7.1,1.1.1.1"></div>
          <div class="fld full">
            <button class="btn btn-green" onclick="netApply()">✓ 应用网络参数</button>
          </div>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(239,68,68,.12);color:#f87171">🔌</div>
            <div>
              <div class="card-title">系统操作</div>
              <div class="card-sub">热点服务与电源控制</div>
            </div>
          </div>
        </div>
        <div style="display:flex;gap:10px;flex-wrap:wrap">
          <button class="btn" onclick="sysAction('restart-hotspot')">↻ 重启热点服务</button>
          <button class="btn" onclick="sysAction('restart-web')">↻ 重启 Web 控制台</button>
          <button class="btn btn-blue" onclick="sysAction('reboot')">⏻ 重启设备</button>
          <button class="btn" style="background:#7f1d1d;color:#fee2e2" onclick="sysAction('shutdown')">⏻ 关机</button>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(251,191,36,.12);color:#fbbf24">⬆️</div>
            <div>
              <div class="card-title">系统更新</div>
              
            </div>
          </div>
          <span class="card-badge">APT</span>
        </div>
        <div class="hint" id="aptStat" style="line-height:1.9">读取中…</div>
        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;margin-top:12px">
          <button class="btn" onclick="aptCheck()">🔄 刷新软件源并检查更新</button>
          <button class="btn btn-green" onclick="aptUpgrade()">⬆️ 一键系统更新</button>
          <button class="btn" onclick="aptClean()">🧹 清理无用包</button>
          <button class="btn" onclick="aptStatus()">📜 更新进度/日志</button>
          <span class="hint" id="aptMsg"></span>
        </div>
        <pre id="aptOut" style="display:none;margin-top:12px;padding:12px;background:rgba(0,0,0,.28);border:1px solid var(--border);border-radius:8px;max-height:320px;overflow:auto;white-space:pre-wrap;font-size:13px;line-height:1.6;color:var(--muted)"></pre>
        <div class="hint" style="margin-top:10px">
          · 系统更新通常需要 5-20 分钟（视包量），期间热点服务照常工作；完成后建议重启一次<br>
          · 内核 / 引导固件级升级在下次重启后生效；日志：/var/log/mmdvm-sysupdate.log
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(168,85,247,.12);color:#c084fc">🧩</div>
            <div>
              <div class="card-title">固件更新</div>
              
            </div>
          </div>
          <span class="card-badge">FIRMWARE</span>
        </div>
        <div class="hint" id="fwStat" style="line-height:1.9">读取中…</div>
        <div class="form-grid" style="margin-top:12px">
          <div class="fld full"><label>固件发布页（可修改）</label>
            <input id="fwUrl" placeholder="https://www.nocall.org.cn/nocall/mmdvm-Multi">
            <div class="hint">默认已填好你提供的地址；页面暂无文件或打不开时会明确提示</div></div>
          <div class="fld full">
            <button class="btn" onclick="fwSaveUrl()">💾 保存地址</button>
            <button class="btn btn-blue" style="margin-left:8px" onclick="fwCheck()">🔎 检查发布页/新版本</button>
            <button class="btn" style="margin-left:8px" onclick="fwInfoLoad()">↻ 刷新当前固件信息</button>
          </div>
          <div class="fld full"><label>安装新引擎（三种方式任选）</label>
            <input id="fwDirect" placeholder="① 粘贴安装包直链（.tar.gz / .tgz / .zip / .deb）" style="width:100%">
            <div style="margin-top:8px;display:flex;gap:10px;flex-wrap:wrap;align-items:center">
              <button class="btn" onclick="fwDownload()">⬇️ ② 下载这个直链</button>
              <button class="btn btn-green" onclick="fwInstallPkg()">⚙️ 安装已下载/已上传的包</button>
              <input type="file" id="fwFile" style="font-size:13px;max-width:230px">
              <button class="btn" onclick="fwUpload()">⬆️ ③ 上传到设备</button>
            </div>
            <div class="hint">安装流程：备份旧引擎 → 解压（tar/zip/deb）→ 找到 MMDVM-Multi 或包内 install 脚本 → 替换 /usr/local/bin/MMDVM-Multi → 自动重启引擎。日志：/var/log/mmdvm-firmware.log</div></div>
          <div class="fld full"><label>在线升级（推荐：从 ARCH 固件源自动下载 + 校验 SHA-256）</label>
            <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
              <button class="btn" onclick="fwInfoLoad()">↻ 刷新本机信息</button>
              <button class="btn btn-blue" onclick="fwOnlineCheck()">🔎 检查在线固件</button>
              <button class="btn btn-green" onclick="fwOnlineUpgrade()">⬆️ 在线升级固件</button>
              <span class="hint" id="fwOnlineMsg"></span>
            </div>
            <div class="hint">固件源：<code>https://arch.hljrsa.cn/downloads/firmware/mmdvm-multi.json</code>（可在后台用 fw-url 改）；升级会自动备份旧引擎并重启 MMDVM-Multi。</div></div>
          <div class="fld full"><label>树莓派引导固件（EEPROM / VL805）</label>
            <button class="btn" onclick="bootFwStatus()">🔎 查看引导固件状态</button>
            <button class="btn btn-blue" style="margin-left:8px" onclick="bootFwUpdate()">⬆️ 更新引导固件</button>
            <button class="btn" style="margin-left:8px" onclick="fwLog()">📜 固件更新日志</button>
            <span class="hint" id="fwMsg"></span>
          </div>
        </div>
        <pre id="fwOut" style="display:none;margin-top:12px;padding:12px;background:rgba(0,0,0,.28);border:1px solid var(--border);border-radius:8px;max-height:340px;overflow:auto;white-space:pre-wrap;font-size:13px;line-height:1.6;color:var(--muted)"></pre>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(96,165,250,.12);color:#60a5fa">🕒</div>
            <div>
              <div class="card-title">时间与同步</div>
              
            </div>
          </div>
          <span class="card-badge">TIME</span>
        </div>
        <div class="hint" id="sysTime" style="line-height:1.9">读取中…</div>
        <div class="form-grid" style="margin-top:14px">
          <div class="fld"><label>时区</label>
            <input id="sysTzInput" list="sysTzList" placeholder="Asia/Shanghai">
            <datalist id="sysTzList">
              <option value="Asia/Shanghai"></option>
              <option value="Asia/Hong_Kong"></option>
              <option value="Asia/Taipei"></option>
              <option value="Asia/Tokyo"></option>
              <option value="Asia/Singapore"></option>
              <option value="UTC"></option>
            </datalist>
            <div class="hint">中国大陆用 Asia/Shanghai</div></div>
          <div class="fld"><label>NTP 自动校时</label>
            <div style="display:flex;gap:10px;flex-wrap:wrap">
              <button class="btn btn-green" onclick="sysNtp(true)">开启</button>
              <button class="btn" onclick="sysNtp(false)">关闭</button>
            </div></div>
          <div class="fld full">
            <button class="btn btn-blue" onclick="sysSetTz()">✓ 设置时区</button>
            <button class="btn" style="margin-left:8px" onclick="sysTimeSync()">⟳ 立即同步时间</button>
            <span class="hint" id="sysTimeMsg"></span>
          </div>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(245,158,11,.12);color:#fbbf24">⏰</div>
            <div>
              <div class="card-title">定时重启</div>
              
            </div>
          </div>
        </div>
        <div class="hint" id="sysSched" style="line-height:1.9">读取中…</div>
        <div class="form-grid" style="margin-top:14px">
          <div class="fld"><label>频率</label>
            <select id="sysSchedMode">
              <option value="daily">每天</option>
              <option value="weekly">每周一</option>
            </select></div>
          <div class="fld"><label>时刻（HH:MM，24 小时制）</label>
            <input id="sysSchedTime" value="04:00" placeholder="04:00"></div>
          <div class="fld full">
            <button class="btn btn-green" onclick="sysSchedSave()">✓ 保存并开启</button>
            <button class="btn" style="margin-left:8px;background:#7f1d1d;color:#fee2e2" onclick="sysSchedOff()">关闭定时重启</button>
            <span class="hint" id="sysSchedMsg"></span>
          </div>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(148,163,184,.12);color:#94a3b8">🧰</div>
            <div>
              <div class="card-title">维护与诊断</div>
              <div class="card-sub">配置备份、服务日志、网络连通性测试</div>
            </div>
          </div>
        </div>
        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
          <button class="btn" onclick="sysBackup()">💾 备份配置</button>
          <select id="sysLogSvc" style="min-width:160px">
            <option value="mmdvm-multi">mmdvm-multi</option>
            <option value="mmdvmhost@1">mmdvmhost@1</option>
            <option value="mmdvmhost@2">mmdvmhost@2</option>
            <option value="mmdvmhost@3">mmdvmhost@3</option>
            <option value="mmdvmhost@4">mmdvmhost@4</option>
            <option value="mmdvmhost@5">mmdvmhost@5</option>
            <option value="mmdvm-web">mmdvm-web</option>
          </select>
          <input id="sysLogN" value="60" style="width:70px" title="行数">
          <button class="btn" onclick="sysLogs()">📜 查看日志</button>
          <input id="sysPingHost" value="223.5.5.5" style="width:130px" title="要 ping 的主机">
          <button class="btn" onclick="sysPing()">🌐 ping 测试</button>
          <button class="btn" onclick="sysOutClear()">清空输出</button>
        </div>
        <pre id="sysOut" style="display:none;margin-top:12px;padding:12px;background:rgba(0,0,0,.28);border:1px solid var(--border);border-radius:8px;max-height:340px;overflow:auto;white-space:pre-wrap;font-size:13px;line-height:1.6;color:var(--muted)"></pre>
      </div>
      <div class="card" id="modeSwitchCard" style="padding:12px 16px">
        <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
          <div class="h-ico" style="background:rgba(var(--theme-rgb),.12);color:var(--theme-2)">🔁</div>
          <div style="flex:1;min-width:190px">
            <div class="card-title" style="font-size:15px">运行模式</div>
            <div class="hint" style="margin-top:3px" id="modeHint">常规模式：DMR / D-STAR / YSF / P25 / NXDN / FM 六个通道同时工作</div>
          </div>
          <button class="btn" onclick="modeSwitch('mmdvm')">切到常规模式</button>
          <button class="btn btn-green" onclick="modeSwitch('tetra')">切到TDR Link 模式</button>
        </div>
        <div class="hint" style="margin-top:6px">切换到TDR Link 模式会先停掉上面六个通道，把射频交给 TDR Link基站，并自动打开 8080 配置界面。</div>
      </div>
      <div class="card" style="padding:12px 16px;margin-bottom:0">
        <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
          <div class="h-ico" style="background:rgba(var(--theme-rgb),.12);color:var(--theme-2)">🌓</div>
          <div style="flex:1;min-width:150px">
            <div class="card-title" style="font-size:15px">显示模式</div>
            <div class="hint" style="margin-top:3px" id="themeModeHint">当前：夜间模式（深色）</div>
          </div>
          <button class="btn" id="themeModeBtn" onclick="themeModeToggle()">☀️ 切换到日间模式</button>
        </div>
      </div>
    </section>

    <!-- ============ 通话记录 ============ -->
    <section class="page proto-page" id="page-calls" style="display:none">
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(16,185,129,.12);color:#34d399">📞</div>
            <div>
              <div class="card-title">通话记录</div>
              
            </div>
          </div>
          <span class="card-badge">CALL LOG</span>
        </div>
        <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
          <button class="btn btn-green" onclick="callsLoad()">↻ 立即刷新</button>
          <button class="btn" id="callsAutoBtn" onclick="callsToggleAuto()">⏱ 自动刷新：开</button>
          <span class="hint" id="callsMsg"></span>
        </div>
      </div>
      <div id="callsBox"><div class="card"><div class="qso-empty">正在读取通话记录…</div></div></div>
    </section>

    <!-- ===== 射频模式配置（内联页，含引擎/服务/日志诊断卡） ===== -->
<section class="page proto-page" id="page-rfmode" style="display:none">

      <!-- ===== ① 运行模式（常规模式 <-> TDR Link 模式） ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(244,114,182,.12);color:#f472b6">&#128256;</div>
            <div>
              <div class="card-title">运行模式切换（常规模式 &#8644; TDR Link 模式）</div>
              <div class="card-sub">
                一块 SX1255 同一时刻只能被一个程序独占：
                <b>常规模式</b> = 多通道射频收发（MMDVM-Multi 各通道同时跑）；
                <b>TDR Link 模式</b> = TDR Link基站。
                两种模式互斥，这里一键切换（切换失败会自动回滚）
              </div>
            </div>
          </div>
          <span class="card-badge" id="rfmModeBadge">--</span>
        </div>

        <div class="freq-banner">
          <div class="freq-banner-ico">&#128256;</div>
          <div class="freq-banner-txt">
            <b>当前运行模式</b>
            <span id="rfmModeNow">读取中&hellip;</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="rfmModeStatus()">&#8635; 刷新</button>
        </div>

        <div class="form-grid" style="margin-top:12px">
          <div class="fld">
            <button class="btn btn-green" id="rfmBtnMmdvm" onclick="rfmSwitchMode('mmdvm')" style="width:100%">&#128257; 一键切换到常规模式（多通道射频收发）</button>
            <div class="hint">启动 MMDVM-Multi 引擎 + 各通道（IQ/Host），按下面「信道规划」的内容跑多路协议</div>
          </div>
          <div class="fld">
            <button class="btn btn-ghost" id="rfmBtnTetra" onclick="rfmSwitchMode('tetra')" style="width:100%">&#128257; 一键切换到TDR Link 模式（TDR Link）</button>
            <div class="hint">停止全部多通道射频收发，把 SX1255 交给 TDR Link基站</div>
          </div>
        </div>
        <div class="hint" style="margin-top:8px">
          切换约需 20&ndash;40 秒，期间射频会短暂中断；切回常规模式后多通道会按「信道规划」里的配置自动恢复。
        </div>
      </div>

      <!-- ===== ② 台站与射频参数 ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--green-dim);color:var(--green2)">&#128295;</div>
            <div>
              <div class="card-title">台站与射频参数（呼号 &middot; 功率 &middot; 增益）</div>
              <div class="card-sub">热点呼号写入 MMDVM-Host 与各网关；功率/增益写入 MMDVM-IQ.ini [Soapy]，全模式通用</div>
            </div>
          </div>
          <span class="card-badge">SX1255</span>
        </div>

        <div class="form-grid">
          <div class="fld"><label>本机呼号 (Callsign)</label>
            <input id="rfmCall" placeholder="NOCALL" maxlength="10" autocomplete="off">
            <div class="hint">一定要与 BrandMeister / 网关注册的呼号一致（会写进 MMDVM-Host 与各网关）</div></div>
          <div class="fld"><label>射频发射功率档位 (SX1255)</label>
            <select id="rfmPower">
              <option value="7">大功率 &#8212; 7 级 / +7 dBm / 5 mW（远距离、抗干扰）</option>
              <option value="0">小功率 &#8212; 0 级 / 0 dBm / 1 mW（近距离、更健康）</option>
            </select>
            <div class="hint">切换档位会自动把 TxGain 设为 30dB（大功率）或 10dB（小功率）</div></div>
          <div class="fld"><label>SX1255 硬件基带接收增益 (RxGain 0-60 dB)</label>
            <input id="rfmRxGain" type="number" min="0" max="60" value="50">
            <div class="hint">MMDVM-IQ.ini [Soapy]，推荐 50 dB（此值为当前配置，已自动读取）</div></div>
          <div class="fld"><label>SX1255 硬件基带发射增益 (TxGain 0-40 dB)</label>
            <input id="rfmTxGain" type="number" min="0" max="40" value="30">
            <div class="hint">大功率推荐 30dB / 小功率推荐 10dB</div></div>
        </div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="rfmSaveStation()">&#128190; 保存呼号 / 功率 / 增益</button>
          <span class="hint" id="rfmStMsg" style="margin-left:10px"></span>
        </div>
      </div>

      <!-- ===== ③ 信道规划 ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--amber-dim);color:var(--amber2)">&#128226;</div>
            <div>
              <div class="card-title">信道规划（多信道同时收发）</div>
              <div class="card-sub">先定通道数量与中心频率，再给每路选协议；频率可直接填 MHz —— 系统自动换算成 Hz 并写入 MMDVM-Multi.ini 与各通道配置</div>
            </div>
          </div>
          <span class="card-badge">统一频率架构</span>
        </div>

        <div class="freq-banner">
          <div class="freq-banner-ico">&#128225;</div>
          <div class="freq-banner-txt">
            <b>当前各通道实际频率</b>
            <span id="rfmBanner">读取中&hellip;</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="rfmPlanLoad()">&#8635; 重新读取</button>
        </div>

        <div class="form-grid" style="margin-top:12px">
          <div class="fld"><label>通道数量 (1 - 6)</label>
            <select id="rfmNCh" onchange="rfmNChChanged()"></select>
            <div class="hint">调小 = 停用多余通道（<b>配置会保留</b>）；以后调回大数字再保存即可恢复多通道<br><b style="color:#fbbf24">采样率随通道数自动切换：1-5 路 → 150 kHz（省 CPU）；6-11 路 → 300 kHz</b></div></div>
          <div class="fld"><label>接收中心频率 (MHz)</label>
            <input id="rfmCrx" type="text" inputmode="decimal" placeholder="434.450" oninput="rfmCenterTyped()">
            <div class="hint" id="rfmCrxHz">例：434.450</div></div>
          <div class="fld"><label>发射中心频率 (MHz)</label>
            <input id="rfmCtx" type="text" inputmode="decimal" placeholder="439.450" oninput="rfmCenterTyped()">
            <div class="hint" id="rfmCtxHz">例：439.450</div></div>
          <div class="fld"><label>通道间距 (kHz)</label>
            <select id="rfmGap" onchange="rfmPlanFreqs()">
              <option value="25">25 kHz（标准，6 路以内用这个）</option>
              <option value="12.5">12.5 kHz（窄带）</option>
            </select>
            <div class="hint">SX1255 各通道按 25 kHz 网格排列，中心频率决定整体位置</div></div>
        </div>

        <div id="rfmRows" style="margin-top:12px"><div class="sp-empty">正在读取通道规划&hellip;</div></div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="rfmPlanApply(false)">&#9889; 保存并应用（写入配置 + 重启受影响通道）</button>
          <button class="btn btn-ghost btn-sm" onclick="rfmPlanApply(true)">仅保存规划</button>
          <span class="hint" id="rfmPlanMsg" style="margin-left:10px"></span>
        </div>
        <div class="hint" style="margin-top:9px;line-height:1.75">
          <b style="color:#fbbf24">频率输入习惯：</b>直接按 MHz 填 ——
          25 kHz 步进写 <b>439.500</b>（= 439500000 Hz），12.5 kHz 步进写 <b>439.5125</b>（= 439512500 Hz）。<br>
          改任意一路的频率时，会以「它」为准反推中心频率，其它通道按硬件网格跟着走；
          表格右侧显示的 MHz 就是设备真实收发频率，对讲机请照它设置。
        </div>
      </div>

      <!-- ===== ③ 各通道状态与切换 ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(56,189,248,.12);color:#38bdf8">&#128251;</div>
            <div>
              <div class="card-title">各通道状态与模式切换</div>
              <div class="card-sub">列出全部 1 - 7 路（含未启用），可随时切模式 / 重启；未启用的通道把「通道数量」调大后保存即可恢复</div>
            </div>
          </div>
          <span class="card-badge">ARCH 融合中枢</span>
        </div>

        <div id="rfmChans"><div class="sp-empty">正在读取通道&hellip;</div></div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-ghost btn-sm" onclick="rfmLoad()">&#8635; 刷新状态</button>
          <button class="btn btn-ghost btn-sm" onclick="rfmRestartAll()">&#8634; 全部重启</button>
          <span class="hint" id="rfmMsg" style="margin-left:10px"></span>
        </div>
      </div>


    </section>











    <!-- ============ 页脚 ============ -->
    <footer class="footer">
      <div class="title"><a class="title-link" href="https://arch.hljrsa.cn/" target="_blank" rel="noopener noreferrer">ARCH 业余无线电融合中枢</a></div>
      <div>致敬开源项目及作者：MMDVM / MMDVMHost (Jonathan Naylor, G4KLX) · Pi-Star &amp; Gateways (Andy Taylor, MW0MWZ)</div>
      <div>ircDDBGateway (Michael Wheeler, G7RZU &amp; G4KLX) · SoapySX1255 (Pothosware 及开源贡献者) · FlowStation / Bluestation (Razvan Zeces, YO6RZV)</div>
      <div>本项目以 GNU GPL v3 许可证发布，欢迎在相同许可证下继续开源。</div>
<div class="author">作者：BG2GA · <a href="https://arch.hljrsa.cn/disclaimer/">免责声明</a></div>
    </footer>

  </div>
</main>

<div id="toast"></div>

<script>
/* ==================== ★ 前端错误上报（远程诊断用）====================
   浏览器里任何 JS 报错都会：① 在页面顶部显示红色错误条；② POST 到
   /api/clientlog，写进设备日志 /tmp/mmdvm-client.log，便于远程定位。
   window.__uiBuild 是界面版本号，用于确认浏览器是否加载了最新页面。 */
window.__uiBuild = 'UI-20260911-2150';
(function(){
  function rep(kind, msg, src, line, col){
    try{
      fetch('/api/clientlog', {method:'POST', headers:{'Content-Type':'application/json'},
        body: JSON.stringify({kind:kind, msg:String(msg == null ? '' : msg), src:String(src == null ? '' : src),
                              line:line == null ? '' : line, col:col == null ? '' : col,
                              build:window.__uiBuild, url:location.href,
                              ua:(navigator && navigator.userAgent) || ''})});
    }catch(e){}
    if(kind !== 'error' && kind !== 'promise'){ return; }   // 诊断信息只写日志，不弹红条
    try{
      var b = document.getElementById('jsErrBar');
      if(!b){
        b = document.createElement('div');
        b.id = 'jsErrBar';
        b.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);z-index:99999;background:rgba(13,21,32,.97);border:1px solid #2b3f57;color:#eaf2ff;padding:20px 34px;border-radius:14px;box-shadow:0 24px 64px rgba(0,0,0,.7),0 0 0 1px rgba(255,255,255,.05) inset;font-size:16px;font-weight:600;letter-spacing:.3px;text-align:center;max-width:min(560px,86vw);max-height:40vh;overflow-y:auto;word-break:break-word;white-space:pre-wrap;line-height:1.6;opacity:0;transition:opacity .3s ease,transform .3s cubic-bezier(.2,.9,.3,1.25);display:none'
          + 'padding:8px 12px;font:13px/1.5 Consolas,monospace;white-space:pre-wrap;max-height:40%;overflow:auto';
        (document.body || document.documentElement).appendChild(b);
      }
      b.textContent += '[前端错误 ' + window.__uiBuild + '] ' + kind + ': ' + msg
        + (src ? ('  @ ' + src + ':' + line + ':' + col) : '') + '\n';
    }catch(e){}
  }
  window.__repErr = rep;
  window.addEventListener('error', function(e){ rep('error', e.message, e.filename, e.lineno, e.colno); });
  window.addEventListener('unhandledrejection', function(e){
    var r = e && e.reason;
    rep('promise', (r && (r.message || r)) || 'unknown');
  });
  /* ★ 结构自愈：若某页面区块被错误嵌套在另一个 .page 内（历史补丁遗留的标签不平衡），
     浏览器里自动把它挪回正确的父容器，避免"点开一片空白"。 */
  try{
    var _mainEl = null;
    var _all = document.querySelectorAll('.page');
    if(_all && _all.length){
      for(var _i = 0; _i < _all.length; _i++){
        var _p = _all[_i].parentElement;
        if(_p && _p.classList && _p.classList.contains('page')){
          _mainEl = _mainEl || document.querySelector('main') || document.body;
          _mainEl.appendChild(_all[_i]);
          rep && rep('heal', 'moved ' + _all[_i].id + ' out of ' + _p.id, location.pathname, '', '');
        }
      }
    }
  }catch(e){}
  window.addEventListener('load', function(){
    rep('boot', '页面已加载', location.pathname, '', '');
    try{

    }catch(e){}
  });
})();



// ============ 呼号前缀 → 国家/地区 + 国旗 ============
// 数据来源：ITU 呼号前缀分配表
// 注意：台湾地区前缀（BM-BQ, BU-BX）不列入，按需求跳过
const PREFIX_DB = {
  // --- 中国 ---
  "B":    {flag:"🇨🇳", name:"中国"},
  "XS":   {flag:"🇨🇳", name:"中国"},
  "3H":   {flag:"🇨🇳", name:"中国"}, "3I":   {flag:"🇨🇳", name:"中国"},
  "3J":   {flag:"🇨🇳", name:"中国"}, "3K":   {flag:"🇨🇳", name:"中国"},
  "3L":   {flag:"🇨🇳", name:"中国"}, "3M":   {flag:"🇨🇳", name:"中国"},
  "3N":   {flag:"🇨🇳", name:"中国"}, "3O":   {flag:"🇨🇳", name:"中国"},
  "3P":   {flag:"🇨🇳", name:"中国"}, "3Q":   {flag:"🇨🇳", name:"中国"},
  "3R":   {flag:"🇨🇳", name:"中国"}, "3S":   {flag:"🇨🇳", name:"中国"},
  "3T":   {flag:"🇨🇳", name:"中国"}, "3U":   {flag:"🇨🇳", name:"中国"},
  "VR":   {flag:"🇭🇰", name:"中国香港"},
  "XX":   {flag:"🇲🇴", name:"中国澳门"},
  // --- 亚洲 ---
  "JA":   {flag:"🇯🇵", name:"日本"}, "JB":   {flag:"🇯🇵", name:"日本"},
  "JC":   {flag:"🇯🇵", name:"日本"}, "JD":   {flag:"🇯🇵", name:"日本"},
  "JE":   {flag:"🇯🇵", name:"日本"}, "JF":   {flag:"🇯🇵", name:"日本"},
  "JG":   {flag:"🇯🇵", name:"日本"}, "JH":   {flag:"🇯🇵", name:"日本"},
  "JI":   {flag:"🇯🇵", name:"日本"}, "JJ":   {flag:"🇯🇵", name:"日本"},
  "JK":   {flag:"🇯🇵", name:"日本"}, "JL":   {flag:"🇯🇵", name:"日本"},
  "JM":   {flag:"🇯🇵", name:"日本"}, "JN":   {flag:"🇯🇵", name:"日本"},
  "JO":   {flag:"🇯🇵", name:"日本"}, "JP":   {flag:"🇯🇵", name:"日本"},
  "JQ":   {flag:"🇯🇵", name:"日本"}, "JR":   {flag:"🇯🇵", name:"日本"},
  "JS":   {flag:"🇯🇵", name:"日本"},
  "7J":   {flag:"🇯🇵", name:"日本"}, "7K":   {flag:"🇯🇵", name:"日本"},
  "7L":   {flag:"🇯🇵", name:"日本"}, "7M":   {flag:"🇯🇵", name:"日本"},
  "7N":   {flag:"🇯🇵", name:"日本"},
  "8J":   {flag:"🇯🇵", name:"日本"}, "8K":   {flag:"🇯🇵", name:"日本"},
  "8L":   {flag:"🇯🇵", name:"日本"}, "8M":   {flag:"🇯🇵", name:"日本"},
  "8N":   {flag:"🇯🇵", name:"日本"},
  "HL":   {flag:"🇰🇷", name:"韩国"}, "HM":   {flag:"🇰🇷", name:"韩国"},
  "DS":   {flag:"🇰🇷", name:"韩国"}, "DT":   {flag:"🇰🇷", name:"韩国"},
  "D7":   {flag:"🇰🇷", name:"韩国"}, "D8":   {flag:"🇰🇷", name:"韩国"},
  "D9":   {flag:"🇰🇷", name:"韩国"},
  "6K":   {flag:"🇰🇷", name:"韩国"}, "6L":   {flag:"🇰🇷", name:"韩国"},
  "6M":   {flag:"🇰🇷", name:"韩国"}, "6N":   {flag:"🇰🇷", name:"韩国"},
  "VU":   {flag:"🇮🇳", name:"印度"},
  "AT":   {flag:"🇮🇳", name:"印度"}, "AU":   {flag:"🇮🇳", name:"印度"},
  "AV":   {flag:"🇮🇳", name:"印度"}, "AW":   {flag:"🇮🇳", name:"印度"},
  "VT":   {flag:"🇮🇳", name:"印度"}, "VW":   {flag:"🇮🇳", name:"印度"},
  "8T":   {flag:"🇮🇳", name:"印度"}, "8Y":   {flag:"🇮🇳", name:"印度"},
  "HS":   {flag:"🇹🇭", name:"泰国"}, "E2":   {flag:"🇹🇭", name:"泰国"},
  "YB":   {flag:"🇮🇩", name:"印度尼西亚"}, "YC":   {flag:"🇮🇩", name:"印度尼西亚"},
  "YD":   {flag:"🇮🇩", name:"印度尼西亚"}, "YE":   {flag:"🇮🇩", name:"印度尼西亚"},
  "YF":   {flag:"🇮🇩", name:"印度尼西亚"}, "YG":   {flag:"🇮🇩", name:"印度尼西亚"},
  "YH":   {flag:"🇮🇩", name:"印度尼西亚"},
  "7A":   {flag:"🇮🇩", name:"印度尼西亚"}, "7B":   {flag:"🇮🇩", name:"印度尼西亚"},
  "7C":   {flag:"🇮🇩", name:"印度尼西亚"}, "7D":   {flag:"🇮🇩", name:"印度尼西亚"},
  "7E":   {flag:"🇮🇩", name:"印度尼西亚"}, "7F":   {flag:"🇮🇩", name:"印度尼西亚"},
  "7G":   {flag:"🇮🇩", name:"印度尼西亚"}, "7H":   {flag:"🇮🇩", name:"印度尼西亚"},
  "7I":   {flag:"🇮🇩", name:"印度尼西亚"},
  "8A":   {flag:"🇮🇩", name:"印度尼西亚"}, "8B":   {flag:"🇮🇩", name:"印度尼西亚"},
  "8C":   {flag:"🇮🇩", name:"印度尼西亚"}, "8D":   {flag:"🇮🇩", name:"印度尼西亚"},
  "8E":   {flag:"🇮🇩", name:"印度尼西亚"}, "8F":   {flag:"🇮🇩", name:"印度尼西亚"},
  "8G":   {flag:"🇮🇩", name:"印度尼西亚"}, "8H":   {flag:"🇮🇩", name:"印度尼西亚"},
  "8I":   {flag:"🇮🇩", name:"印度尼西亚"},
  "9M":   {flag:"🇲🇾", name:"马来西亚"}, "9W":   {flag:"🇲🇾", name:"马来西亚"},
  "DU":   {flag:"🇵🇭", name:"菲律宾"}, "DV":   {flag:"🇵🇭", name:"菲律宾"},
  "DW":   {flag:"🇵🇭", name:"菲律宾"}, "DX":   {flag:"🇵🇭", name:"菲律宾"},
  "DY":   {flag:"🇵🇭", name:"菲律宾"}, "DZ":   {flag:"🇵🇭", name:"菲律宾"},
  "4D":   {flag:"🇵🇭", name:"菲律宾"}, "4E":   {flag:"🇵🇭", name:"菲律宾"},
  "4F":   {flag:"🇵🇭", name:"菲律宾"}, "4G":   {flag:"🇵🇭", name:"菲律宾"},
  "4H":   {flag:"🇵🇭", name:"菲律宾"}, "4I":   {flag:"🇵🇭", name:"菲律宾"},
  "XV":   {flag:"🇻🇳", name:"越南"}, "3W":   {flag:"🇻🇳", name:"越南"},
  "9V":   {flag:"🇸🇬", name:"新加坡"},
  "AP":   {flag:"🇵🇰", name:"巴基斯坦"},
  "S2":   {flag:"🇧🇩", name:"孟加拉国"}, "S3":   {flag:"🇧🇩", name:"孟加拉国"},
  "4S":   {flag:"🇱🇰", name:"斯里兰卡"},
  "9N":   {flag:"🇳🇵", name:"尼泊尔"},
  "T6":   {flag:"🇦🇫", name:"阿富汗"}, "YA":   {flag:"🇦🇫", name:"阿富汗"},
  "EP":   {flag:"🇮🇷", name:"伊朗"}, "EQ":   {flag:"🇮🇷", name:"伊朗"},
  "YI":   {flag:"🇮🇶", name:"伊拉克"}, "HN":   {flag:"🇮🇶", name:"伊拉克"},
  "HZ":   {flag:"🇸🇦", name:"沙特阿拉伯"}, "7Z":   {flag:"🇸🇦", name:"沙特阿拉伯"},
  "8Z":   {flag:"🇸🇦", name:"沙特阿拉伯"},
  "A6":   {flag:"🇦🇪", name:"阿联酋"}, "A7":   {flag:"🇶🇦", name:"卡塔尔"},
  "A9":   {flag:"🇧🇭", name:"巴林"}, "A4":   {flag:"🇴🇲", name:"阿曼"},
  "A5":   {flag:"🇧🇹", name:"不丹"},
  "9K":   {flag:"🇰🇼", name:"科威特"},
  "JY":   {flag:"🇯🇴", name:"约旦"},
  "4X":   {flag:"🇮🇱", name:"以色列"}, "4Z":   {flag:"🇮🇱", name:"以色列"},
  "TA":   {flag:"🇹🇷", name:"土耳其"}, "TB":   {flag:"🇹🇷", name:"土耳其"},
  "TC":   {flag:"🇹🇷", name:"土耳其"}, "YM":   {flag:"🇹🇷", name:"土耳其"},
  "EK":   {flag:"🇦🇲", name:"亚美尼亚"},
  "4L":   {flag:"🇬🇪", name:"格鲁吉亚"},
  "4K":   {flag:"🇦🇿", name:"阿塞拜疆"},
  "UN":   {flag:"🇰🇿", name:"哈萨克斯坦"}, "UP":   {flag:"🇰🇿", name:"哈萨克斯坦"},
  "EX":   {flag:"🇰🇬", name:"吉尔吉斯斯坦"},
  "EY":   {flag:"🇹🇯", name:"塔吉克斯坦"},
  "EZ":   {flag:"🇹🇲", name:"土库曼斯坦"},
  "UK":   {flag:"🇺🇿", name:"乌兹别克斯坦"}, "UJ":   {flag:"🇺🇿", name:"乌兹别克斯坦"},
  "UM":   {flag:"🇺🇿", name:"乌兹别克斯坦"},
  "JT":   {flag:"🇲🇳", name:"蒙古"},
  // --- 大洋洲 ---
  "VK":   {flag:"🇦🇺", name:"澳大利亚"}, "AX":   {flag:"🇦🇺", name:"澳大利亚"},
  "VH":   {flag:"🇦🇺", name:"澳大利亚"}, "VI":   {flag:"🇦🇺", name:"澳大利亚"},
  "VJ":   {flag:"🇦🇺", name:"澳大利亚"}, "VL":   {flag:"🇦🇺", name:"澳大利亚"},
  "VM":   {flag:"🇦🇺", name:"澳大利亚"}, "VN":   {flag:"🇦🇺", name:"澳大利亚"},
  "VZ":   {flag:"🇦🇺", name:"澳大利亚"},
  "ZL":   {flag:"🇳🇿", name:"新西兰"},
  "P2":   {flag:"🇵🇬", name:"巴布亚新几内亚"},
  "3D2":  {flag:"🇫🇯", name:"斐济"},
  "A3":   {flag:"🇹🇴", name:"汤加"},
  "KH":   {flag:"🇺🇸", name:"美国"}, "AH":   {flag:"🇺🇸", name:"美国"},
  // --- 欧洲 ---
  "DL":   {flag:"🇩🇪", name:"德国"}, "DA":   {flag:"🇩🇪", name:"德国"},
  "DB":   {flag:"🇩🇪", name:"德国"}, "DC":   {flag:"🇩🇪", name:"德国"},
  "DD":   {flag:"🇩🇪", name:"德国"}, "DF":   {flag:"🇩🇪", name:"德国"},
  "DG":   {flag:"🇩🇪", name:"德国"}, "DH":   {flag:"🇩🇪", name:"德国"},
  "DJ":   {flag:"🇩🇪", name:"德国"}, "DK":   {flag:"🇩🇪", name:"德国"},
  "DM":   {flag:"🇩🇪", name:"德国"}, "DP":   {flag:"🇩🇪", name:"德国"},
  "Y2":   {flag:"🇩🇪", name:"德国"}, "Y3":   {flag:"🇩🇪", name:"德国"},
  "Y4":   {flag:"🇩🇪", name:"德国"}, "Y5":   {flag:"🇩🇪", name:"德国"},
  "Y6":   {flag:"🇩🇪", name:"德国"}, "Y7":   {flag:"🇩🇪", name:"德国"},
  "Y8":   {flag:"🇩🇪", name:"德国"}, "Y9":   {flag:"🇩🇪", name:"德国"},
  "G":    {flag:"🇬🇧", name:"英国"}, "M":    {flag:"🇬🇧", name:"英国"},
  "2":    {flag:"🇬🇧", name:"英国"},
  "VS":   {flag:"🇬🇧", name:"英国"}, "ZB":   {flag:"🇬🇧", name:"英国"},
  "ZC":   {flag:"🇬🇧", name:"英国"}, "ZD":   {flag:"🇬🇧", name:"英国"},
  "ZE":   {flag:"🇬🇧", name:"英国"}, "ZF":   {flag:"🇬🇧", name:"英国"},
  "ZG":   {flag:"🇬🇧", name:"英国"}, "ZH":   {flag:"🇬🇧", name:"英国"},
  "ZI":   {flag:"🇬🇧", name:"英国"}, "ZJ":   {flag:"🇬🇧", name:"英国"},
  "ZN":   {flag:"🇬🇧", name:"英国"}, "ZO":   {flag:"🇬🇧", name:"英国"},
  "ZQ":   {flag:"🇬🇧", name:"英国"},
  "F":    {flag:"🇫🇷", name:"法国"},
  "TM":   {flag:"🇫🇷", name:"法国"}, "TK":   {flag:"🇫🇷", name:"法国"},
  "TO":   {flag:"🇫🇷", name:"法国"}, "TX":   {flag:"🇫🇷", name:"法国"},
  "HW":   {flag:"🇫🇷", name:"法国"},
  "I":    {flag:"🇮🇹", name:"意大利"},
  "IK":   {flag:"🇮🇹", name:"意大利"}, "IZ":   {flag:"🇮🇹", name:"意大利"},
  "IU":   {flag:"🇮🇹", name:"意大利"}, "IW":   {flag:"🇮🇹", name:"意大利"},
  "IN":   {flag:"🇮🇹", name:"意大利"}, "IV":   {flag:"🇮🇹", name:"意大利"},
  "IX":   {flag:"🇮🇹", name:"意大利"},
  "EA":   {flag:"🇪🇸", name:"西班牙"}, "EB":   {flag:"🇪🇸", name:"西班牙"},
  "EC":   {flag:"🇪🇸", name:"西班牙"}, "ED":   {flag:"🇪🇸", name:"西班牙"},
  "EE":   {flag:"🇪🇸", name:"西班牙"}, "EF":   {flag:"🇪🇸", name:"西班牙"},
  "EG":   {flag:"🇪🇸", name:"西班牙"}, "EH":   {flag:"🇪🇸", name:"西班牙"},
  "AM":   {flag:"🇪🇸", name:"西班牙"}, "AN":   {flag:"🇪🇸", name:"西班牙"},
  "AO":   {flag:"🇪🇸", name:"西班牙"},
  "CT":   {flag:"🇵🇹", name:"葡萄牙"}, "CR":   {flag:"🇵🇹", name:"葡萄牙"},
  "CS":   {flag:"🇵🇹", name:"葡萄牙"}, "CQ":   {flag:"🇵🇹", name:"葡萄牙"},
  "CU":   {flag:"🇵🇹", name:"葡萄牙"},
  "PA":   {flag:"🇳🇱", name:"荷兰"}, "PB":   {flag:"🇳🇱", name:"荷兰"},
  "PC":   {flag:"🇳🇱", name:"荷兰"}, "PD":   {flag:"🇳🇱", name:"荷兰"},
  "PE":   {flag:"🇳🇱", name:"荷兰"}, "PF":   {flag:"🇳🇱", name:"荷兰"},
  "PG":   {flag:"🇳🇱", name:"荷兰"}, "PH":   {flag:"🇳🇱", name:"荷兰"},
  "PI":   {flag:"🇳🇱", name:"荷兰"},
  "ON":   {flag:"🇧🇪", name:"比利时"}, "OO":   {flag:"🇧🇪", name:"比利时"},
  "OP":   {flag:"🇧🇪", name:"比利时"}, "OQ":   {flag:"🇧🇪", name:"比利时"},
  "OR":   {flag:"🇧🇪", name:"比利时"}, "OS":   {flag:"🇧🇪", name:"比利时"},
  "OT":   {flag:"🇧🇪", name:"比利时"},
  "HB":   {flag:"🇨🇭", name:"瑞士"}, "HE":   {flag:"🇨🇭", name:"瑞士"},
  "HB0":  {flag:"🇱🇮", name:"列支敦士登"},
  "OE":   {flag:"🇦🇹", name:"奥地利"},
  "SM":   {flag:"🇸🇪", name:"瑞典"}, "SA":   {flag:"🇸🇪", name:"瑞典"},
  "SB":   {flag:"🇸🇪", name:"瑞典"}, "SC":   {flag:"🇸🇪", name:"瑞典"},
  "SD":   {flag:"🇸🇪", name:"瑞典"}, "SE":   {flag:"🇸🇪", name:"瑞典"},
  "SF":   {flag:"🇸🇪", name:"瑞典"}, "SG":   {flag:"🇸🇪", name:"瑞典"},
  "SH":   {flag:"🇸🇪", name:"瑞典"}, "SI":   {flag:"🇸🇪", name:"瑞典"},
  "SJ":   {flag:"🇸🇪", name:"瑞典"}, "SK":   {flag:"🇸🇪", name:"瑞典"},
  "SL":   {flag:"🇸🇪", name:"瑞典"}, "7S":   {flag:"🇸🇪", name:"瑞典"},
  "8S":   {flag:"🇸🇪", name:"瑞典"},
  "LA":   {flag:"🇳🇴", name:"挪威"}, "LB":   {flag:"🇳🇴", name:"挪威"},
  "LC":   {flag:"🇳🇴", name:"挪威"}, "LD":   {flag:"🇳🇴", name:"挪威"},
  "LE":   {flag:"🇳🇴", name:"挪威"}, "LF":   {flag:"🇳🇴", name:"挪威"},
  "LG":   {flag:"🇳🇴", name:"挪威"}, "LH":   {flag:"🇳🇴", name:"挪威"},
  "LI":   {flag:"🇳🇴", name:"挪威"}, "LJ":   {flag:"🇳🇴", name:"挪威"},
  "LK":   {flag:"🇳🇴", name:"挪威"}, "LL":   {flag:"🇳🇴", name:"挪威"},
  "LM":   {flag:"🇳🇴", name:"挪威"}, "LN":   {flag:"🇳🇴", name:"挪威"},
  "OZ":   {flag:"🇩🇰", name:"丹麦"}, "OU":   {flag:"🇩🇰", name:"丹麦"},
  "OV":   {flag:"🇩🇰", name:"丹麦"}, "5P":   {flag:"🇩🇰", name:"丹麦"},
  "5Q":   {flag:"🇩🇰", name:"丹麦"},
  "OH":   {flag:"🇫🇮", name:"芬兰"}, "OF":   {flag:"🇫🇮", name:"芬兰"},
  "OG":   {flag:"🇫🇮", name:"芬兰"}, "OI":   {flag:"🇫🇮", name:"芬兰"},
  "OJ":   {flag:"🇫🇮", name:"芬兰"},
  "TF":   {flag:"🇮🇸", name:"冰岛"},
  "SP":   {flag:"🇵🇱", name:"波兰"}, "SQ":   {flag:"🇵🇱", name:"波兰"},
  "SN":   {flag:"🇵🇱", name:"波兰"}, "SO":   {flag:"🇵🇱", name:"波兰"},
  "SR":   {flag:"🇵🇱", name:"波兰"}, "HF":   {flag:"🇵🇱", name:"波兰"},
  "3Z":   {flag:"🇵🇱", name:"波兰"},
  "OK":   {flag:"🇨🇿", name:"捷克"}, "OL":   {flag:"🇨🇿", name:"捷克"},
  "OM":   {flag:"🇸🇰", name:"斯洛伐克"},
  "HA":   {flag:"🇭🇺", name:"匈牙利"}, "HG":   {flag:"🇭🇺", name:"匈牙利"},
  "YO":   {flag:"🇷🇴", name:"罗马尼亚"}, "YP":   {flag:"🇷🇴", name:"罗马尼亚"},
  "YQ":   {flag:"🇷🇴", name:"罗马尼亚"}, "YR":   {flag:"🇷🇴", name:"罗马尼亚"},
  "LZ":   {flag:"🇧🇬", name:"保加利亚"},
  "SV":   {flag:"🇬🇷", name:"希腊"}, "SW":   {flag:"🇬🇷", name:"希腊"},
  "SX":   {flag:"🇬🇷", name:"希腊"}, "SY":   {flag:"🇬🇷", name:"希腊"},
  "SZ":   {flag:"🇬🇷", name:"希腊"}, "J4":   {flag:"🇬🇷", name:"希腊"},
  "9A":   {flag:"🇭🇷", name:"克罗地亚"},
  "S5":   {flag:"🇸🇮", name:"斯洛文尼亚"},
  "YU":   {flag:"🇷🇸", name:"塞尔维亚"}, "YT":   {flag:"🇷🇸", name:"塞尔维亚"},
  "E7":   {flag:"🇧🇦", name:"波黑"},
  "Z3":   {flag:"🇲🇰", name:"北马其顿"},
  "ZA":   {flag:"🇦🇱", name:"阿尔巴尼亚"},
  "4O":   {flag:"🇲🇪", name:"黑山"},
  "ES":   {flag:"🇪🇪", name:"爱沙尼亚"},
  "YL":   {flag:"🇱🇻", name:"拉脱维亚"},
  "LY":   {flag:"🇱🇹", name:"立陶宛"},
  "EU":   {flag:"🇧🇾", name:"白俄罗斯"}, "EW":   {flag:"🇧🇾", name:"白俄罗斯"},
  "UR":   {flag:"🇺🇦", name:"乌克兰"}, "US":   {flag:"🇺🇦", name:"乌克兰"},
  "UT":   {flag:"🇺🇦", name:"乌克兰"}, "UU":   {flag:"🇺🇦", name:"乌克兰"},
  "UV":   {flag:"🇺🇦", name:"乌克兰"}, "UW":   {flag:"🇺🇦", name:"乌克兰"},
  "UX":   {flag:"🇺🇦", name:"乌克兰"}, "UY":   {flag:"🇺🇦", name:"乌克兰"},
  "UZ":   {flag:"🇺🇦", name:"乌克兰"}, "EM":   {flag:"🇺🇦", name:"乌克兰"},
  "EN":   {flag:"🇺🇦", name:"乌克兰"}, "EO":   {flag:"🇺🇦", name:"乌克兰"},
  "ER":   {flag:"🇲🇩", name:"摩尔多瓦"},
  "R":    {flag:"🇷🇺", name:"俄罗斯"}, "RA":   {flag:"🇷🇺", name:"俄罗斯"},
  "RK":   {flag:"🇷🇺", name:"俄罗斯"}, "RN":   {flag:"🇷🇺", name:"俄罗斯"},
  "RU":   {flag:"🇷🇺", name:"俄罗斯"}, "RV":   {flag:"🇷🇺", name:"俄罗斯"},
  "RW":   {flag:"🇷🇺", name:"俄罗斯"}, "RX":   {flag:"🇷🇺", name:"俄罗斯"},
  "RZ":   {flag:"🇷🇺", name:"俄罗斯"},
  "UA":   {flag:"🇷🇺", name:"俄罗斯"}, "UB":   {flag:"🇷🇺", name:"俄罗斯"},
  "UC":   {flag:"🇷🇺", name:"俄罗斯"}, "UD":   {flag:"🇷🇺", name:"俄罗斯"},
  "UE":   {flag:"🇷🇺", name:"俄罗斯"}, "UF":   {flag:"🇷🇺", name:"俄罗斯"},
  "UG":   {flag:"🇷🇺", name:"俄罗斯"}, "UH":   {flag:"🇷🇺", name:"俄罗斯"},
  "UI":   {flag:"🇷🇺", name:"俄罗斯"},
  // --- 美洲 ---
  "W":    {flag:"🇺🇸", name:"美国"}, "K":    {flag:"🇺🇸", name:"美国"},
  "N":    {flag:"🇺🇸", name:"美国"},
  "AA":   {flag:"🇺🇸", name:"美国"}, "AB":   {flag:"🇺🇸", name:"美国"},
  "AC":   {flag:"🇺🇸", name:"美国"}, "AD":   {flag:"🇺🇸", name:"美国"},
  "AE":   {flag:"🇺🇸", name:"美国"}, "AF":   {flag:"🇺🇸", name:"美国"},
  "AG":   {flag:"🇺🇸", name:"美国"}, "AH":   {flag:"🇺🇸", name:"美国"},
  "AI":   {flag:"🇺🇸", name:"美国"}, "AJ":   {flag:"🇺🇸", name:"美国"},
  "AK":   {flag:"🇺🇸", name:"美国"}, "AL":   {flag:"🇺🇸", name:"美国"},
  "VE":   {flag:"🇨🇦", name:"加拿大"}, "VA":   {flag:"🇨🇦", name:"加拿大"},
  "VO":   {flag:"🇨🇦", name:"加拿大"}, "VY":   {flag:"🇨🇦", name:"加拿大"},
  "VX":   {flag:"🇨🇦", name:"加拿大"},
  "CF":   {flag:"🇨🇦", name:"加拿大"}, "CG":   {flag:"🇨🇦", name:"加拿大"},
  "CH":   {flag:"🇨🇦", name:"加拿大"}, "CI":   {flag:"🇨🇦", name:"加拿大"},
  "CJ":   {flag:"🇨🇦", name:"加拿大"}, "CK":   {flag:"🇨🇦", name:"加拿大"},
  "CY":   {flag:"🇨🇦", name:"加拿大"}, "CZ":   {flag:"🇨🇦", name:"加拿大"},
  "XJ":   {flag:"🇨🇦", name:"加拿大"}, "XK":   {flag:"🇨🇦", name:"加拿大"},
  "XL":   {flag:"🇨🇦", name:"加拿大"}, "XM":   {flag:"🇨🇦", name:"加拿大"},
  "XN":   {flag:"🇨🇦", name:"加拿大"}, "XO":   {flag:"🇨🇦", name:"加拿大"},
  "XE":   {flag:"🇲🇽", name:"墨西哥"}, "XF":   {flag:"🇲🇽", name:"墨西哥"},
  "4A":   {flag:"🇲🇽", name:"墨西哥"}, "4B":   {flag:"🇲🇽", name:"墨西哥"},
  "4C":   {flag:"🇲🇽", name:"墨西哥"},
  "PY":   {flag:"🇧🇷", name:"巴西"}, "PP":   {flag:"🇧🇷", name:"巴西"},
  "PQ":   {flag:"🇧🇷", name:"巴西"}, "PR":   {flag:"🇧🇷", name:"巴西"},
  "PS":   {flag:"🇧🇷", name:"巴西"}, "PT":   {flag:"🇧🇷", name:"巴西"},
  "PU":   {flag:"🇧🇷", name:"巴西"}, "PV":   {flag:"🇧🇷", name:"巴西"},
  "PW":   {flag:"🇧🇷", name:"巴西"}, "PX":   {flag:"🇧🇷", name:"巴西"},
  "ZV":   {flag:"🇧🇷", name:"巴西"}, "ZW":   {flag:"🇧🇷", name:"巴西"},
  "ZX":   {flag:"🇧🇷", name:"巴西"}, "ZY":   {flag:"🇧🇷", name:"巴西"},
  "ZZ":   {flag:"🇧🇷", name:"巴西"},
  "LU":   {flag:"🇦🇷", name:"阿根廷"}, "LW":   {flag:"🇦🇷", name:"阿根廷"},
  "AY":   {flag:"🇦🇷", name:"阿根廷"}, "AZ":   {flag:"🇦🇷", name:"阿根廷"},
  "CE":   {flag:"🇨🇱", name:"智利"}, "CA":   {flag:"🇨🇱", name:"智利"},
  "CB":   {flag:"🇨🇱", name:"智利"}, "CC":   {flag:"🇨🇱", name:"智利"},
  "CD":   {flag:"🇨🇱", name:"智利"}, "XQ":   {flag:"🇨🇱", name:"智利"},
  "XR":   {flag:"🇨🇱", name:"智利"}, "3G":   {flag:"🇨🇱", name:"智利"},
  "OA":   {flag:"🇵🇪", name:"秘鲁"}, "OB":   {flag:"🇵🇪", name:"秘鲁"},
  "OC":   {flag:"🇵🇪", name:"秘鲁"}, "4T":   {flag:"🇵🇪", name:"秘鲁"},
  "HK":   {flag:"🇨🇴", name:"哥伦比亚"}, "HJ":   {flag:"🇨🇴", name:"哥伦比亚"},
  "5J":   {flag:"🇨🇴", name:"哥伦比亚"}, "5K":   {flag:"🇨🇴", name:"哥伦比亚"},
  "YV":   {flag:"🇻🇪", name:"委内瑞拉"}, "YW":   {flag:"🇻🇪", name:"委内瑞拉"},
  "YX":   {flag:"🇻🇪", name:"委内瑞拉"}, "YY":   {flag:"🇻🇪", name:"委内瑞拉"},
  "CX":   {flag:"🇺🇾", name:"乌拉圭"}, "CV":   {flag:"🇺🇾", name:"乌拉圭"},
  "CW":   {flag:"🇺🇾", name:"乌拉圭"},
  "ZP":   {flag:"🇵🇾", name:"巴拉圭"},
  "CP":   {flag:"🇧🇴", name:"玻利维亚"},
  "HC":   {flag:"🇪🇨", name:"厄瓜多尔"}, "HD":   {flag:"🇪🇨", name:"厄瓜多尔"},
  "8R":   {flag:"🇬🇾", name:"圭亚那"},
  "PZ":   {flag:"🇸🇷", name:"苏里南"},
  // --- 非洲 ---
  "ZS":   {flag:"🇿🇦", name:"南非"}, "ZR":   {flag:"🇿🇦", name:"南非"},
  "ZU":   {flag:"🇿🇦", name:"南非"},
  "5Z":   {flag:"🇰🇪", name:"肯尼亚"}, "5Y":   {flag:"🇰🇪", name:"肯尼亚"},
  "5N":   {flag:"🇳🇬", name:"尼日利亚"}, "5O":   {flag:"🇳🇬", name:"尼日利亚"},
  "SU":   {flag:"🇪🇬", name:"埃及"}, "SS":   {flag:"🇪🇬", name:"埃及"},
  "6A":   {flag:"🇪🇬", name:"埃及"}, "6B":   {flag:"🇪🇬", name:"埃及"},
  "CN":   {flag:"🇲🇦", name:"摩洛哥"}, "5C":   {flag:"🇲🇦", name:"摩洛哥"},
  "5D":   {flag:"🇲🇦", name:"摩洛哥"}, "5E":   {flag:"🇲🇦", name:"摩洛哥"},
  "5F":   {flag:"🇲🇦", name:"摩洛哥"}, "5G":   {flag:"🇲🇦", name:"摩洛哥"},
  "3V":   {flag:"🇹🇳", name:"突尼斯"}, "TS":   {flag:"🇹🇳", name:"突尼斯"},
  "7X":   {flag:"🇩🇿", name:"阿尔及利亚"}, "7R":   {flag:"🇩🇿", name:"阿尔及利亚"},
  "7T":   {flag:"🇩🇿", name:"阿尔及利亚"}, "7U":   {flag:"🇩🇿", name:"阿尔及利亚"},
  "7V":   {flag:"🇩🇿", name:"阿尔及利亚"}, "7W":   {flag:"🇩🇿", name:"阿尔及利亚"},
  "7Y":   {flag:"🇩🇿", name:"阿尔及利亚"},
  "5A":   {flag:"🇱🇾", name:"利比亚"},
  "ST":   {flag:"🇸🇩", name:"苏丹"},
  "ET":   {flag:"🇪🇹", name:"埃塞俄比亚"},
  "5X":   {flag:"🇺🇬", name:"乌干达"},
  "5H":   {flag:"🇹🇿", name:"坦桑尼亚"},
  "9J":   {flag:"🇿🇲", name:"赞比亚"},
  "Z2":   {flag:"🇿🇼", name:"津巴布韦"},
  "7Q":   {flag:"🇲🇼", name:"马拉维"},
  "C9":   {flag:"🇲🇿", name:"莫桑比克"},
  "D2":   {flag:"🇦🇴", name:"安哥拉"}, "D3":   {flag:"🇦🇴", name:"安哥拉"},
  "9X":   {flag:"🇷🇼", name:"卢旺达"},
  "9U":   {flag:"🇧🇮", name:"布隆迪"},
  "5R":   {flag:"🇲🇬", name:"马达加斯加"}, "5S":   {flag:"🇲🇬", name:"马达加斯加"},
  "3B":   {flag:"🇲🇺", name:"毛里求斯"},
  "D4":   {flag:"🇨🇻", name:"佛得角"},
  "D6":   {flag:"🇰🇲", name:"科摩罗"},
  "J5":   {flag:"🇬🇼", name:"几内亚比绍"},
  "3X":   {flag:"🇬🇳", name:"几内亚"},
  "9L":   {flag:"🇸🇱", name:"塞拉利昂"},
  "EL":   {flag:"🇱🇷", name:"利比里亚"},
  "TU":   {flag:"🇨🇮", name:"科特迪瓦"},
  "TZ":   {flag:"🇲🇱", name:"马里"},
  "XT":   {flag:"🇧🇫", name:"布基纳法索"},
  "5V":   {flag:"🇹🇬", name:"多哥"},
  "TY":   {flag:"🇧🇯", name:"贝宁"},
  "5T":   {flag:"🇲🇷", name:"毛里塔尼亚"},
  "6W":   {flag:"🇸🇳", name:"塞内加尔"},
  "C5":   {flag:"🇬🇲", name:"冈比亚"},
  "J3":   {flag:"🇬🇩", name:"格林纳达"},
  "9G":   {flag:"🇬🇭", name:"加纳"},
  "TR":   {flag:"🇬🇦", name:"加蓬"},
  "TJ":   {flag:"🇨🇲", name:"喀麦隆"},
  "TL":   {flag:"🇨🇫", name:"中非"},
  "TT":   {flag:"🇹🇩", name:"乍得"},
  "TN":   {flag:"🇨🇬", name:"刚果"},
  "9O":   {flag:"🇨🇩", name:"刚果民主共和国"}, "9P":   {flag:"🇨🇩", name:"刚果民主共和国"},
  "9Q":   {flag:"🇨🇩", name:"刚果民主共和国"},
  "S9":   {flag:"🇸🇹", name:"圣多美和普林西比"},
  "3C":   {flag:"🇬🇶", name:"赤道几内亚"},
};


// ============ Emoji 国旗 → ISO 3166-1 alpha-2 代码 ============
// 例如 🇨🇳 → cn，🇺🇸 → us
function emojiToIso2(emoji) {
  if (!emoji) return '';
  const cps = [...emoji].map(c => c.codePointAt(0));
  // Regional Indicator Symbol 从 U+1F1E6 开始（对应 A）
  if (cps.length < 2) return '';
  if (cps[0] < 0x1F1E6 || cps[0] > 0x1F1FF) return '';
  if (cps[1] < 0x1F1E6 || cps[1] > 0x1F1FF) return '';
  return String.fromCharCode(
    cps[0] - 0x1F1E6 + 65,
    cps[1] - 0x1F1E6 + 65
  ).toLowerCase();
}

// ============ 生成国旗 HTML（图片 + emoji fallback） ============
function flagHtml(country) {
  if (!country || !country.flag) return '';
  const iso2 = emojiToIso2(country.flag);
  if (!iso2) return `<span class="qso-flag-emoji" title="${country.name || ''}">${country.flag}</span>`;
  return `<img class="qso-flag-img" src="https://flagcdn.com/w20/${iso2}.png" srcset="https://flagcdn.com/w40/${iso2}.png 2x" alt="${country.name || ''}" title="${country.name || ''}" loading="lazy" decoding="async" onerror="this.style.display='none';this.nextElementSibling.style.display='inline';"><span class="qso-flag-emoji" style="display:none">${country.flag}</span>`;
}

// ============ 呼号解析函数 ============
// 取呼号前缀（最长匹配）查找国家
function lookupCallsignCountry(call) {
  if (!call) return null;
  const c = call.toUpperCase();
  // 跳过 "NOCALL" / "TEST" / 短号
  if (c.length < 2) return null;
  // 台湾地区呼号统一显示为“中国台湾”（使用中国国旗）
  if (/^(BM|BN|BO|BP|BQ|BU|BV|BW|BX)/.test(c)) return {flag:"🇨🇳", name:"中国台湾"};
  // 从长到短尝试匹配（支持 3H-3U、3Z、7J-7N、8J-8N 等 2-3 位前缀）
  for (let len = 3; len >= 1; len--) {
    if (c.length < len) continue;
    const prefix = c.substring(0, len);
    if (PREFIX_DB[prefix]) return PREFIX_DB[prefix];
  }
  return null;
}

/* ==================== 射频功率档位 -> TxGain 联动 ==================== */
var RF_GAIN_MAP = {'0': 10, '7': 30};      /* 小功率(0dB) / 大功率(7dB) */
function rfPowerSync(){
  var sel = $id('rfPower');
  if(!sel) return;
  if(sel.value !== '0' && sel.value !== '7') sel.value = '7';   /* 仅 0 / 7 有效，其余按大功率显示 */
}
function rfPowerChanged(){
  var sel = $id('rfPower'), tg = $id('rfTxGain');
  if(!sel) return;
  var v = String(sel.value), g = RF_GAIN_MAP[v];
  if(tg && g != null) tg.value = g;
  if(typeof toast === 'function'){
    toast('已选择' + (v === '0' ? '小功率（0 dBm / 1 mW）' : '大功率（约 +7 dBm）')
          + '，TxGain 自动设为 ' + (g == null ? '-' : g + ' dB'));
  }
}
function $id(x){return document.getElementById(x)}
function toast(msg, kind){   /* TOAST_V2 ★ 居中弹窗：淡入 → 停留 2 秒 → 渐渐淡出 */
  var t = document.getElementById('toast');
  if(!t){
    t = document.createElement('div');
    t.id = 'toast';
    document.body.appendChild(t);
  }
  var txt = String(msg == null ? '' : msg);
  if(txt.length > 300) txt = txt.substring(0, 300) + '…';
  t.textContent = txt;
  t.className = (kind === false || kind === 'error' || kind === 'bad') ? 'error'
              : ((kind === true || kind === 'ok') ? 'ok' : '');
  t.style.cssText = 'position:fixed;left:50%;bottom:24px;top:auto;' +
    'transform:translateX(-50%) translateY(10px) scale(.98);z-index:99999;' +
    'background:rgba(13,21,32,.72);border:1px solid rgba(var(--theme-rgb),.45);color:#eaf2ff;' +
    'padding:10px 16px;border-radius:12px;text-align:center;' +
    'box-shadow:0 12px 34px rgba(0,0,0,.38),0 0 18px rgba(var(--theme-rgb),.14);' +
    'backdrop-filter:blur(14px) saturate(140%);-webkit-backdrop-filter:blur(14px) saturate(140%);' +
    'font-size:14px;font-weight:600;letter-spacing:.2px;' +
    'max-width:min(420px,82vw);max-height:26vh;overflow:hidden;' +
    'word-break:break-word;white-space:pre-wrap;line-height:1.5;' +
    'display:block;opacity:0;pointer-events:none;' +
    'transition:opacity .22s ease,transform .22s ease';
  t.style.boxShadow = '0 24px 64px rgba(0,0,0,.7)' +
    (t.className === 'ok' ? ',0 0 30px rgba(16,185,129,.25)'
     : (t.className === 'error' ? ',0 0 30px rgba(239,68,68,.25)' : ''));
  clearTimeout(window.__tt1); clearTimeout(window.__tt2);
  requestAnimationFrame(function(){                  /* 淡入 */
    t.style.opacity = '1';
    t.style.transform = 'translateX(-50%) translateY(0) scale(1)';
  });
  window.__tt1 = setTimeout(function(){              /* 2 秒后开始淡出 */
    t.style.opacity = '0';
    t.style.transform = 'translateX(-50%) translateY(10px) scale(.98)';
  }, 2000);
  window.__tt2 = setTimeout(function(){              /* 淡完再摘掉，避免挡点击 */
    t.style.display = 'none'; t.style.opacity = '0';
  }, 2400);
}

/* ==================== 频率与信道（统一设置）==================== */
let FREQ_PLAN = null;
const FREQ_MODES = ['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];

function freqHint(){
  const rx = FREQ_HZ(($id('fRx')||{}).value), tx = FREQ_HZ(($id('fTx')||{}).value);
  if($id('fRxMhz')) $id('fRxMhz').textContent = rx ? ('= ' + rx + ' Hz') : '';
  if($id('fTxMhz')) $id('fTxMhz').textContent = tx ? ('= ' + tx + ' Hz') : '';
}

async function freqLoad(){
  const now = $id('freqNow'), rows = $id('fRows');
  try{
    const r = await fetch('/api/mc/plan', {credentials:'same-origin'});
    const d = await r.json();
    if(!d || d.ok === false || !d.list){
      if(now) now.innerHTML = '<div class="mc-empty">读取失败：' + String((d&&(d.error||d.out))||r.status) + '</div>';
      return;
    }
    FREQ_PLAN = d;
      mcSeqBuild(d.list || []);   /* MC_DYN_CH 建立映射 */
    const list = d.list || [];
    const chN = parseInt(d.channels || list.length || 1, 10);
    if($id('fCh')) $id('fCh').value = chN;
    if($id('fRx')) $id('fRx').value = FREQ_MHZ(d.center_rx) || '';
    if($id('fTx')) $id('fTx').value = FREQ_MHZ(d.center_tx) || '';
    if($id('fGap')) $id('fGap').value = ((parseInt(d.spacing,10)||25000)/1000);
    freqHint();
    if(now){
      now.innerHTML = list.map(function(c){
        return '<span class="freq-chip"><b>通道 ' + mcSeq(c.n) + '</b>' + String(c.mode||'NONE')
          + ' · RX ' + mcMHz(c.rx) + ' / TX ' + mcMHz(c.tx) + '</span>';
      }).join('') || '<div class="mc-empty">暂无通道</div>';
    }
    if(rows){
      rows.innerHTML = list.map(function(c){
        const opts = FREQ_MODES.map(function(m){
          return '<option value="' + m + '"' + (m === c.mode ? ' selected' : '') + '>' + m + '</option>';
        }).join('');
        return '<div class="freq-row">'
          + '<span class="fr-n">通道 ' + c.n + '</span>'
          + '<select id="fMode' + c.n + '">' + opts + '</select>'
          + '<input id="fR' + c.n + '" type="text" inputmode="decimal" value="' + (FREQ_MHZ(c.rx)||'') + '" placeholder="RX MHz">'
          + '<input id="fT' + c.n + '" type="text" inputmode="decimal" value="' + (FREQ_MHZ(c.tx)||'') + '" placeholder="TX MHz">'
          + '<button class="btn" onclick="freqCalc(' + c.n + ')">按间距算</button>'
          + '</div>';
      }).join('');
    }
  }catch(e){
    if(now) now.innerHTML = '<div class="mc-empty">读取失败：' + e + '</div>';
  }
}

function freqCalc(n){
  const rx = parseInt(($id('fRx')||{}).value,10);
  const tx = parseInt(($id('fTx')||{}).value,10);
  const gap = parseInt(($id('fGap')||{}).value,10) || 0;
  var st=chGridStep(n);   /* RF 页拿不到采样率时按 250k 网格推导 */
  if(isFinite(rx) && $id('fR'+n)) $id('fR'+n).value = rx + st*gap;
  if(isFinite(tx) && $id('fT'+n)) $id('fT'+n).value = tx + st*gap;
}

async function freqSave(onlySave){
  const nCh = Math.max(1, Math.min(7, parseInt(($id('fCh')||{}).value,10) || 2));
  const cRx = parseInt(($id('fRx')||{}).value,10) || 0;
  const cTx = parseInt(($id('fTx')||{}).value,10) || 0;
  const gap = parseInt(($id('fGap')||{}).value,10) || 0;
  if(!cRx || !cTx){ toast('请先填写中心 RX/TX 频率', 'error'); return; }
  if(gap < 12500){ toast('通道间距太小（需 ≥ 12500 Hz）', 'error'); return; }
  const plan = {
    channels: nCh, center_rx: cRx, center_tx: cTx, spacing: gap,
    sample_rate: (FREQ_PLAN && FREQ_PLAN.sample_rate) || 150000,
    callsign: (FREQ_PLAN && FREQ_PLAN.callsign) || '',
    dmr_id: (FREQ_PLAN && FREQ_PLAN.dmr_id) || '',
    list: []
  };
  for(let i = 1; i <= nCh; i++){
    const el = $id('fR' + i), tl = $id('fT' + i), md = $id('fMode' + i);
    plan.list.push({
      n: i,
      mode: md ? md.value : 'NONE',
      rx: (el && parseInt(el.value,10)) || (cRx + (i-1)*gap),
      tx: (tl && parseInt(tl.value,10)) || (cTx + (i-1)*gap)
    });
  }
  const LEGAL = '请遵守所在国家或地区关于无线电发射设备、频率使用和操作资格的法律法规。因违规使用造成的后果由操作者承担，与本软件及作者无关。';
  if(!onlySave && !confirm('将写入 MMDVM-Multi.ini 与各通道配置，并重启受影响的通道。\n大约需要 20-60 秒。\n\n'+LEGAL+'\n\n继续？')) return;
  toast(onlySave ? '正在保存频率规划...' : '正在应用频率规划（约 20-60 秒）...');
  try{
    const r = await fetch('/api/mc/plan-' + (onlySave ? 'save' : 'apply'), {
      method:'POST', credentials:'same-origin',
      headers:{'Content-Type':'application/json'}, body: JSON.stringify(plan)});
    const d = await r.json();
    const msg = String((d && (d.out || d.error)) || '').trim();
    if(d && d.ok) toast(((msg.slice(0, 260) || '已应用') + ' ｜ ' + LEGAL), 'ok');
    else toast('失败：' + (msg || r.status), 'error');
  }catch(e){ toast('失败：' + e, 'error'); }
  setTimeout(function(){ freqLoad(); if(typeof mcRefresh === 'function') mcRefresh(); }, 3000);
}

/* 各协议页顶部的「各通道频率」条 + 本页横幅（多信道：按各自通道显示） */
const PAGE_MODE = {dmr:'DMR', ysf:'YSF', p25:'P25', nxdn:'NXDN', dstar:'D-STAR',
                   fm:'FM', pocsag:'POCSAG', rf:'DMR'};
async function renderFreqStrips(){
  try{
    const d = await (await fetch('/api/mc/channels?light=1', {credentials:'same-origin'})).json();
    const chs = (d && d.channels) || [];
    mcSeqBuild(chs);   /* MC_SEQ_HELPER */
    const txt = chs.map(function(c){
      return mcSeq(c.n) + '.' + String(c.mode||'NONE') + ' ' + mcMHz(c.rx) + '/' + mcMHz(c.tx);
    }).join('　|　') || '（未配置通道）';
    document.querySelectorAll('.freq-strip').forEach(function(e){ e.textContent = txt; });

    // 当前页横幅（RX/TX/差频）显示"本页协议所在通道"的频率，避免与多信道实际值不一致
    const page = document.body.dataset.theme || 'dashboard';
    const mode = PAGE_MODE[page];
    let pick = chs.filter(function(c){ return mode && String(c.mode) === mode; });
    if(!pick.length) pick = chs.slice(0, 1);
    const cur = pick[0];
    const sec = document.getElementById('page-' + page);
    if(cur && sec){
      const rx = parseInt(cur.rx,10) || 0, tx = parseInt(cur.tx,10) || 0;
      sec.querySelectorAll('.dyn-rx').forEach(function(e){ e.textContent = mcMHz(rx).replace(' MHz',''); });
      sec.querySelectorAll('.dyn-tx').forEach(function(e){ e.textContent = mcMHz(tx).replace(' MHz',''); });
      sec.querySelectorAll('.dyn-off').forEach(function(e){
        e.textContent = (rx && tx) ? ((tx - rx) >= 0 ? '+' : '') + ((tx-rx)/1e6).toFixed(3) : '--';
      });
    }
  }catch(e){}
}

/* ==================== 首页多信道状态卡（多路并行，模式在 Multi 页配置）==================== */
const MC_MODES = ['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];

/* ==================== ★ 全站统一频率约定（以后新增页面也照这个来）====================
   输入：按 MHz 小数习惯填写
        439.500  -> 439500000 Hz    （25 kHz 步进）
        439.5125 -> 439512500 Hz    （12.5 kHz 步进）
        也兼容直接输入 Hz（>= 100000 的整数按 Hz 处理）
   显示：439.500 / 439.5125（25k 用 3 位小数，12.5k 用 4 位）
   用法：FREQ_HZ(输入框的值) 得到 Hz；FREQ_MHZ(hz) 得到显示串        */
function FREQ_HZ(v){
  if(v===null||v===undefined) return 0;
  var s=String(v).trim().replace(/[^0-9.]/g,'');
  if(s==='') return 0;
  var f=parseFloat(s);
  if(!isFinite(f)||f<=0) return 0;
  var isMhz=(s.indexOf('.')>=0 || f<100000);
  return Math.round(isMhz ? f*1000000 : f);
}
function FREQ_MHZ(hz){
  hz=parseInt(hz,10);
  if(!isFinite(hz)||hz<=0) return '';
  var d=(hz%12500===0 && hz%25000!==0) ? 4 : 3;
  return (hz/1000000).toFixed(d);
}
function FREQ_TXT(hz){ var s=FREQ_MHZ(hz); return s? (s+' MHz') : '-'; }
if(typeof window!=='undefined'){ window.FREQ_HZ=FREQ_HZ; window.FREQ_MHZ=FREQ_MHZ; window.FREQ_TXT=FREQ_TXT; }

/* POCSAG 寻呼发射频率：界面填 MHz，提交时换算成 Hz（隐藏域 name=freq） */
function pocFreqTyped(){
  var s=(document.getElementById('pocMhz')||{}).value;
  var hz=FREQ_HZ(s), hid=document.getElementById('pocFreq');
  if(hid && hz) hid.value=String(hz);
  var t=document.getElementById('pocMhzHz');
  if(t) t.textContent = hz? ('= '+hz+' Hz') : '请填 MHz，例如 439.500 / 439.5125';
}

function mcMHz(v){ v = parseInt(v,10); return (isFinite(v) && v > 0) ? FREQ_TXT(v) : '-'; }
/* ★ 射频通道 1~5 各用一种鲜艳颜色（暗底上辨识度高） */
var MC_CH_COLORS = ['#22d3ee', '#4ade80', '#fbbf24', '#c084fc', '#fb7185'];
function mcChColor(n){
  n = parseInt(n, 10) || 1;
  return MC_CH_COLORS[((n - 1) % MC_CH_COLORS.length + MC_CH_COLORS.length) % MC_CH_COLORS.length];
}
/* MC_SEQ_HELPER ★ 统一通道编号：物理通道号 → 显示序号（1..N，跳过 NONE）
   —— 首页「频率模式列表」、通联列表、Multi 看板、日志选择器全部用这一个映射，
      避免各处编号不一致（历史上出现过 CH4=FM 却显示通道3 的错位）。 */
var MC_SEQ_MAP = {};      /* 物理号 -> 显示序号 */
var MC_SEQ_CHS = [];      /* 最近一次 /api/mc/channels 的通道表 */
function mcSeqBuild(chs){
  MC_SEQ_MAP = {};
  MC_SEQ_CHS = chs || [];
  var k = 0;
  (chs || []).forEach(function(c){
    if(String(c.mode || 'NONE').toUpperCase() === 'NONE') return;
    k++;
    MC_SEQ_MAP[Number(c.n)] = k;
  });
  return MC_SEQ_MAP;
}
function mcSeq(n){          /* 取显示序号；没映射时回退到原号 */
  var v = MC_SEQ_MAP[Number(n)];
  return v ? v : n;
}
function MC_DYN_CH(){      /* MC_DYN_CH ★ 规划页可用通道数：优先用 plan 的，其次数通道表 */
  var n = parseInt((PLN && PLN.channels) || 0, 10);
  if(n > 0) return n;
  return Math.max(1, (MC_SEQ_CHS || []).length);
}
function mcModeOf(n){       /* 物理号 -> 模式名 */
  var c = MC_SEQ_CHS.filter(function(x){ return Number(x.n) === Number(n); })[0] || {};
  return String(c.mode || '').toUpperCase();
}

/* ★★★ 模式图标（统一 40×40，圆角方牌，风格一致）★★★ */
var MC_ICON_COLORS = {
  'DMR':'#0ea5e9','D-STAR':'#8b5cf6','YSF':'#f97316','NXDN':'#2563eb',
  'P25':'#e11d48','FM':'#10b981','POCSAG':'#f59e0b','NONE':'#64748b'
};
function mcIconSvg(mode){
  var m = String(mode||'').toUpperCase(), col = MC_ICON_COLORS[m] || '#64748b';
  var g = '';
  if(m === 'DMR'){
    g = '<rect x="9" y="14" width="22" height="4" rx="2" fill="#fff"/>'
      + '<rect x="9" y="22" width="14" height="4" rx="2" fill="#fff" opacity=".75"/>';
  } else if(m === 'D-STAR'){
    g = '<path d="M20 10l2.6 6.1 6.4.6-4.9 4.3 1.5 6.4L20 24l-5.6 3.4 1.5-6.4-4.9-4.3 6.4-.6z" fill="#fff"/>';
  } else if(m === 'YSF'){
    g = '<path d="M20 10l8 10-8 10-8-10z" fill="#fff"/>';
  } else if(m === 'NXDN'){
    g = '<path d="M13 27V13h4.2l5.6 8V13H27v14h-4.2l-5.6-8v8z" fill="#fff"/>';
  } else if(m === 'P25'){
    g = '<path d="M20 10l9 3.4v7.1c0 5-3.7 8.2-9 9.5-5.3-1.3-9-4.5-9-9.5v-7.1z" fill="#fff"/>';
  } else if(m === 'FM'){
    g = '<path d="M10 20c2.5-6 5-6 7.5 0s5 6 7.5 0 5-6 5 0" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>';
  } else if(m === 'POCSAG'){
    g = '<rect x="13" y="11" width="14" height="18" rx="3" fill="#fff"/>'
      + '<rect x="16" y="14" width="8" height="6" rx="1" fill="' + col + '"/>';
  } else {
    g = '<circle cx="20" cy="20" r="6" fill="#fff" opacity=".7"/>';
  }
  return '<svg class="mc-icon" viewBox="0 0 40 40" width="40" height="40" '
       + 'xmlns="http://www.w3.org/2000/svg" role="img" aria-label="' + m + '">'
       + '<rect x="2" y="2" width="36" height="36" rx="9" fill="' + col + '"/>'
       + g + '</svg>';
}
/* ★ 在跑：图标 + 模式名；未在跑：只显示模式名 */
function mcModeCell(mode, running){
  if(running){
    return '<span style="display:flex;align-items:center;gap:8px" data-mode="'+mode+'">'
         + mcIconSvg(mode) + '<span class="mc-mode-txt">' + mode + '</span></span>';
  }
  return '<span class="mc-mode-txt">' + mode + '</span>';
}
/* ★ 不在本表显示：TDR Link 独占的载波（频率相同） */
var MC_HIDE_TX = {};
async function mcLoadTetraHide(){
  try{
    const r = await fetch('/api/tetra/status', {credentials:'same-origin'});
    const d = await r.json();
    const c = (d && d.carrier) || {};
    if(c.tx) MC_HIDE_TX[String(c.tx)] = 1;
    if(d && d.cfg && d.cfg.dual_carrier && d.secondary && d.secondary.tx)
      MC_HIDE_TX[String(d.secondary.tx)] = 1;
  }catch(e){}
}

async function mcRefresh(){
  const box = $id('mcMatrix');
  if(!box) return;
  try{
    const r = await fetch('/api/mc/channels?light=1', {credentials:'same-origin'});
    const d = await r.json();
    if(!d || d.ok === false){
      box.innerHTML = '<div class="mc-empty">读取失败：'
        + String((d && (d.error || d.out)) || r.status) + '</div>';
      return;
    }
    const chs = d.channels || [];
    if($id('mcCount')){
      $id('mcCount').textContent =
        chs.filter(function(c){ return c.mode && String(c.mode).toUpperCase() !== 'NONE'; }).length + ' 路';
    }
    if(!chs.length){ box.innerHTML = '<div class="mc-empty">没有已配置的通道</div>'; return; }
    /* ★★★ 只显示「已启用模式」的通道（隔离频点不列出）★★★
       并按 1..N 顺序重新编号，不出现跳号 */
    const MC_SEQ_NUM = true;
    const list = chs.filter(function(c){
      if(!c.mode || String(c.mode).toUpperCase() === 'NONE') return false;
      return true;
    });
    if(!list.length){ box.innerHTML = '<div class="mc-empty">没有使用中的通道</div>'; return; }
    box.innerHTML = list.map(function(c, i){
      const idx  = String(i + 1);                                     /* ★ 显示序号 1..N */
      const has  = true;                                              /* 列表已过滤，都是启用中 */
      const run  = !!c.iq && !!c.host;                                /* 确实在跑 */
      const dotc = run ? '#10b981' : (has ? '#f59e0b' : '#64748b');
      const right = has ? mcModeCell(String(c.mode), run)
                        : '<span class="mc-off">未启用</span>';
      const freq = has
        ? 'RX '+mcMHz(c.rx)+' ｜ TX '+mcMHz(c.tx)
        : '<span style="color:#7d93b2">— 该频点留作隔离，不承载业务 —</span>';
      return '<div class="mc-card'+(has ? ' on' : ' off')+'">'
        + '<div class="mc-hd"><span><span class="mc-dot" style="background:'+dotc+'"></span>'
        + '<b style="color:'+mcChColor(c.n)+';font-weight:800">射频通道 '+idx+'</b></span>'
        + right + '</div>'
        + '<div class="mc-freq">'+freq+'</div>'
        + '</div>';
    }).join('');
  }catch(e){
    box.innerHTML = '<div class="mc-empty">读取失败：'+e+'</div>';
  }
}
async function mcPost(url, body, okMsg){
  try{
    const r = await fetch(url, {method:'POST', credentials:'same-origin',
      headers:{'Content-Type':'application/json'}, body: JSON.stringify(body || {})});
    const d = await r.json();
    const ok = !!(d && d.ok);
    toast(ok ? okMsg : ('失败：'+String((d && (d.error || d.out)) || r.status)), ok ? 'ok' : 'error');
  }catch(e){ toast('失败：'+e, 'error'); }
  setTimeout(mcRefresh, 1600);
}
/* QSO_SYNC_FIX ★ 切换模式/保存规划后，强制丢弃通道表缓存，让通联列表立刻跟随 */
function mcInvalidateChannels(){
  try{ if(typeof QSO_CH_CACHE === 'object'){ QSO_CH_CACHE.t = 0; QSO_CH_CACHE.list = []; } }catch(e){}
  try{ if(typeof FREQ_PLAN === 'object'){ /* 保留 */ } }catch(e){}
  setTimeout(function(){
    if(typeof mcRefresh === 'function') mcRefresh();
    if(typeof qsoRefresh === 'function') qsoRefresh();
  }, 600);
}

function mcRestart(n){
  var d = mcSeq(n) + (mcModeOf(n) ? ' (' + mcModeOf(n) + ')' : '');
  if(!confirm('重启通道 ' + d + ' 的基带与主机？')) return;
  toast('通道 ' + d + ' 正在重启 ...');
  mcPost('/api/mc/restart', {ch:n}, '通道 ' + d + ' 已重启');
}
function mcRestartAll(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？\n大约需要 20-40 秒。')) return;
  toast('正在重启全部通道 ...');
  mcPost('/api/mc/multi-restart', {}, 'MMDVM-Multi 与全部通道已重启');
}

/* ==================== BrandMeister 会话状态 ==================== */
async function bmCheck(){
  const bar = $id('bmBar');
  if(!bar) return;
  bar.className = 'bm-bar';
  bar.textContent = '正在检测 BrandMeister 会话...';
  try{
    const r = await fetch('/api/bm-status', {credentials:'same-origin'});
    const d = await r.json();
    const map = {online:['ok','● 在线'], closed:['bad','● 被 Master 拒绝'],
                 auth:['bad','● 密码错误'], timeout:['bad','● 连接超时'],
                 dns:['bad','● 域名解析失败'], unknown:['bad','● 状态未知']};
    const m = map[d.state] || map.unknown;
    bar.className = 'bm-bar ' + m[0];
    bar.innerHTML = '<b>' + m[1] + '</b> ' + String(d.reason || '')
      + '　<span style="color:var(--dim);font-family:var(--mono);font-size:12px">'
      + 'Master=' + String(d.host || '-') + ' ｜ Id=' + String(d.id || '-')
      + ' ｜ 配置报文呼号=' + String(d.callsign || '-') + '</span>';
  }catch(e){
    bar.className = 'bm-bar bad';
    bar.textContent = '检测失败：' + e;
  }
}

// 页面切换
function switchPage(name){
    if(name === 'rfmode'){ var _f = $id('rfmFrame'); if(_f && !_f.src) _f.src = _f.getAttribute('data-src'); }
  document.body.dataset.theme = name;
  document.querySelectorAll('.page').forEach(p=>p.style.display='none');
  let el = $id('page-'+name);
  if(!el && name !== 'dashboard'){          // 兼容旧缓存的半截页面：回退到首页并提示
    el = $id('page-dashboard');
    name = 'dashboard';
    if(typeof toast === 'function') toast('页面已更新，请按 Ctrl+F5 强制刷新浏览器缓存', 'error');
  }
  if(el) el.style.display = 'block';
  else if(name !== 'dashboard'){                     // 找不到区块：多为浏览器缓存了半截页面
    el = $id('page-dashboard');
    if(el) el.style.display = 'block';
    if(typeof toast === 'function') toast('页面不完整，请按 Ctrl+F5 强制刷新', 'error');
    try{ window.__repErr && window.__repErr('nav', 'missing page-' + name, location.href, '', ''); }catch(e){}
    return;
  }
  if(el && !el.children.length){                     // 区块在但没有内容 -> 自动重载一次拿新页面
    try{
      if(!sessionStorage.getItem('_heal')){
        sessionStorage.setItem('_heal', '1');
        if(typeof toast === 'function') toast('页面内容缺失，正在自动重新加载…');
        setTimeout(function(){ location.reload(true); }, 600);
        return;
      }
    }catch(e){}
  }
  document.querySelectorAll('.sb-item').forEach(x=>{
    x.classList.toggle('active', x.dataset.page === name);
  });
  if(name === 'ports') fetchPorts();
  if(name === 'sys' && typeof sysLoad === 'function') sysLoad();
  if(name === 'calls' && typeof callsLoad === 'function'){ callsLoad(); if(typeof callsApplyAuto === 'function') callsApplyAuto(); }
  if(name !== 'calls' && typeof callsStopAuto === 'function') callsStopAuto();
  if(name === 'dashboard'){ fetchStatus(); mcRefresh(); }
  /* ★ 卡片已移到 rf 页：rf 和 freq 都要触发加载 */
    if((name === 'rf' || name === 'freq') && typeof freqLoad === 'function') freqLoad();
  if(typeof renderFreqStrips === 'function') setTimeout(renderFreqStrips, 400);
  if(name === 'multi'){
    const f = $id('multiFrame');
    if(f && String(f.src || '').indexOf('/multi') < 0) f.src = '/multi?embed=1';
  }
  const PMAP = {rf:'RF', dmr:'DMR', ysf:'YSF', p25:'P25', nxdn:'NXDN',
                dstar:'D-STAR', fm:'FM', pocsag:'POCSAG'};
  if(PMAP[name] && typeof prefill === 'function') prefill(PMAP[name]);
  if(name === 'dmr') setTimeout(bmCheck, 300);
  if(name === 'rf' && typeof rfPowerSync === 'function') setTimeout(rfPowerSync, 500);
  // ★ 探针：把该页在浏览器里的真实内容长度与尺寸上报（远程排查用）
  try{
    if(window.__repErr && el){
      var _r = el.getBoundingClientRect();
      var _cs = window.getComputedStyle ? getComputedStyle(el) : null;
      window.__repErr('nav', 'page=' + name
        + ' kids=' + el.children.length
        + ' html=' + ((el.innerHTML || '').length)
        + ' rect=' + Math.round(_r.width) + 'x' + Math.round(_r.height)
        + ' disp=' + (_cs ? _cs.display : '?')
        + ' vis=' + (_cs ? _cs.visibility : '?')
        + ' op=' + (_cs ? _cs.opacity : '?')
        + ' bodytheme=' + (document.body.dataset ? document.body.dataset.theme : '?'), location.href, '', '');
    }
  }catch(e){}
}
document.querySelectorAll('.sb-item').forEach(el=>{
  el.addEventListener('click', ()=>{
    var p = el.dataset.page;
    if(p === 'fmo'){ var th=document.body.classList.contains('light')?'light':'dark'; window.location.href='/fmo?theme='+th; return; }
    if(p === 'tetra-external'){ if(typeof openTetraConfig === 'function') openTetraConfig(); return; }
    switchPage(p);
  });
});
// 频率输入实时换算 MHz 提示
['fRx','fTx'].forEach(function(id){
  const el = $id(id);
  if(el) el.addEventListener('input', function(){ if(typeof freqHint === 'function') freqHint(); });
});

// 模式切换
document.querySelectorAll('.mode-pill').forEach(b=>{
  b.addEventListener('click', async ()=>{
    const m = b.dataset.mode;
    if(!confirm('确认切换到 ' + m + ' 模式？\n\n系统会停止当前协议并启动目标网关，可能需要 10-30 秒。')) return;
    toast('切换热点模式至 ' + m + '...');

    try {
      const r = await fetch('/api/switch/' + encodeURIComponent(m), {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        credentials: 'same-origin'
      });

      if (!r.ok) {
        // 尝试读错误消息，但也限制长度
        let errMsg = 'HTTP ' + r.status;
        try {
          const ct = r.headers.get('content-type') || '';
          if (ct.includes('application/json')) {
            const d = await r.json();
            errMsg = d.message || errMsg;
          }
        } catch (e) {}
        throw new Error(String(errMsg).substring(0, 200));
      }

      // 只解析 JSON；非 JSON 不解析
      let d = {};
      const ct = r.headers.get('content-type') || '';
      if (ct.includes('application/json')) {
        try { d = await r.json(); } catch (e) {}
      }

      toast(d.message || ('已切换到 ' + m));
      setTimeout(fetchStatus, 1500);
    } catch (e) {
      toast('切换失败: ' + (e.message || e).toString().substring(0, 200));
    }
  });
});

// 主状态刷新

// ============ BM 服务器列表加载 ============
const BM_FLAG = {
  "CN": "🇨🇳", "AU": "🇦🇺", "AT": "🇦🇹", "BE": "🇧🇪", "BG": "🇧🇬",
  "BR": "🇧🇷", "CA": "🇨🇦", "CH": "🇨🇭", "CL": "🇨🇱", "CZ": "🇨🇿",
  "DE": "🇩🇪", "DK": "🇩🇰", "ES": "🇪🇸", "FI": "🇫🇮", "FR": "🇫🇷",
  "GB": "🇬🇧", "GR": "🇬🇷", "HU": "🇭🇺", "IE": "🇮🇪", "IL": "🇮🇱",
  "IT": "🇮🇹", "KR": "🇰🇷", "MX": "🇲🇽", "MY": "🇲🇾", "NL": "🇳🇱",
  "NO": "🇳🇴", "PH": "🇵🇭", "PL": "🇵🇱", "PT": "🇵🇹", "RO": "🇷🇴",
  "RU": "🇷🇺", "SE": "🇸🇪", "SI": "🇸🇮", "US": "🇺🇸", "ZA": "🇿🇦",
};

async function loadBmListOld_unused(forceReload) {
  const sel = document.getElementById('dmrBmHost');
  if (!sel) return;
  const currentVal = sel.value || '';
  try {
    const r = await fetch('/api/bm-list', {
      headers: { 'Accept': 'application/json' },
      redirect: 'manual'
    });
    const ct = r.headers.get('content-type') || '';
    if (!ct.includes('application/json')) {
      // 返回了 HTML（可能被重定向到登录页）
      if (r.status === 0 || r.type === 'opaqueredirect') {
        if (forceReload) toast('需要登录才能加载 BM 列表');
      } else {
        const text = await r.text();
        if (forceReload) toast('BM 列表加载失败 (HTTP ' + r.status + ')');
        console.error('bm-list response:', text.substring(0, 300));
      }
      return;
    }
    const d = await r.json();
    const masters = d.masters || [];
    if (!Array.isArray(masters) || masters.length === 0) {
      if (forceReload) toast('BM 服务器列表为空');
      return;
    }

    // 按国家分组
    const byCountry = {};
    masters.forEach(m => {
      const c = m.country || 'XX';
      if (!byCountry[c]) byCountry[c] = [];
      byCountry[c].push(m);
    });

    // 排序：CN 排最前，其余按字母
    const countries = Object.keys(byCountry).sort((a, b) => {
      if (a === 'CN') return -1;
      if (b === 'CN') return 1;
      return a.localeCompare(b);
    });

    // 生成 option
    sel.innerHTML = countries.map(c => {
      const flag = BM_FLAG[c] || '🌐';
      const group = byCountry[c];
      return group.map(m => {
        const label = `${flag} ${m.region || c} · BM ${m.id} · ${m.host}`;
        return `<option value="${m.host}">${label}</option>`;
      }).join('');
    }).join('');

    // 恢复选中
    if ([...sel.options].some(o => o.value === currentVal)) {
      sel.value = currentVal;
    }
    if (forceReload) {
      toast(`BM 服务器列表已加载（共 ${masters.length} 个）`);
    }
  } catch (e) {
    if (forceReload) toast('加载 BM 列表失败: ' + e.message);
  }
}

function setTxt(id, val){
  const el = $id(id);
  if(el) el.textContent = (val === undefined || val === null) ? '' : String(val);
  return el;
}

async function fetchStatus(){
  try{
    const r = await fetch('/api/status?light=1');
    const d = await r.json();
    const sys = d.system || {};
    const profile = d.profile || '未知';

    // 顶部 pill（★ 一律走 setTxt 空值保护：缺元素不能影响后面的 QSO 渲染）
    setTxt('topMode', profile);
    setTxt('curMode', profile);

    // 模式按钮高亮
    document.querySelectorAll('.mode-pill').forEach(b=>{
      const on = b.dataset.mode === profile;
      b.classList.toggle('active', on);
      const st = b.querySelector('.st');
      if(st) st.textContent = on ? '运行中' : '切换';
    });

    // 侧边栏呼号
    setTxt('sbCall', sys.callsign || 'NOCALL');

    // 统计卡
    setTxt('stCpu', sys.cpu_temp ? (sys.cpu_temp + ' °C') : '--');
    setTxt('stMem', sys.mem || '--');
    setTxt('stUptime', sys.uptime || '--');
    // ★ CPU 实时占用率（颜色分级）
    const cu = $id('stCpuUse');
    if(cu){
      const raw = String(sys.cpu_usage || '').trim();
      const pct = parseFloat(raw);
      cu.textContent = raw || '--';
      cu.title = '负载(1/5/15 分钟)：' + (sys.load || 'N/A') + '　（0.25 秒瞬时采样）';
      if(isNaN(pct)) cu.style.color = '';
      else if(pct >= 85) cu.style.color = '#f87171';
      else if(pct >= 60) cu.style.color = '#fbbf24';
      else cu.style.color = 'var(--green2)';
    }
    const sx = $id('stSx');
    if(sx && sys.sx_probe === 'OK'){
      sx.textContent = 'SoapySX OK';
      sx.style.color = 'var(--green2)';
    } else if(sx){
      sx.textContent = '未探测到';
      sx.style.color = '#fbbf24';
    }

    // DB 统计
    setTxt('dbStat', '收录 ' + (sys.bm_count||'540+') + ' BM 主机，'
      + (sys.ysf_count||'1,840+') + ' YSF 房间，' + (sys.p25_count||'320+') + ' P25 组');

    // RF 页的频率显示
    const rxHz = sys.rx_hz || 0;
    const txHz = sys.tx_hz || 0;
    const offHz = txHz - rxHz;
    if($id('rfCurRx')) $id('rfCurRx').textContent = rxHz ? fmtMhz(rxHz) : '--';
    if($id('rfCurTx')) $id('rfCurTx').textContent = txHz ? fmtMhz(txHz) : '--';
    if($id('rfCurOff')){
      const sign = offHz >= 0 ? '+' : '';
      $id('rfCurOff').textContent = (rxHz && txHz) ? (sign + (offHz/1e6).toFixed(3)) : '--';
    }

    // QSO：轻量状态不返回 qso，避免每 3 秒把实时通话列表清空重绘
    if(Array.isArray(d.qso)) renderQSO(d.qso);
  }catch(e){
    // 连接失败
    const pill = document.querySelector('.status-pill');
    if(pill) pill.innerHTML = '<span class="dot" style="background:#ef4444"></span>连接中断';
  }
}

function fmtMhz(hz){
  const n = parseFloat(hz);
  if(!isFinite(n)) return '--';
  return (n/1e6).toFixed(4);
}

// 12 色渐变主题池（每条 QSO 循环使用）
const QSO_PALETTE = [
  ['#10b981','#34d399','16,185,129'],   // 翠绿
  ['#06b6d4','#22d3ee','6,182,212'],    // 青蓝
  ['#3b82f6','#60a5fa','59,130,246'],   // 蓝
  ['#8b5cf6','#a78bfa','139,92,246'],   // 紫罗兰
  ['#d946ef','#e879f9','217,70,239'],   // 品红
  ['#ec4899','#f472b6','236,72,153'],   // 玫瑰
  ['#f97316','#fb923c','249,115,22'],   // 橙红
  ['#f59e0b','#fbbf24','245,158,11'],   // 琥珀
  ['#84cc16','#a3e635','132,204,22'],   // 黄绿
  ['#14b8a6','#5eead4','20,184,166'],   // 翡翠
  ['#6366f1','#818cf8','99,102,241'],   // 靛蓝
  ['#f43f5e','#fb7185','244,63,94'],    // 深粉
];

/* ==================== Live QSO（多信道：每通道一行 × 4 宫格）==================== */
let QSO_CH_CACHE = {t:0, list:[]};
async function qsoChannels(){
  const now = Date.now();
  if(QSO_CH_CACHE.list.length && (now - QSO_CH_CACHE.t) < 3000) return QSO_CH_CACHE.list;   /* QSO_SYNC_FIX 8s→3s */
  try{
    const d = await (await fetch('/api/mc/channels?light=1', {credentials:'same-origin'})).json();
    QSO_CH_CACHE = {t:now, list:((d && d.channels) || [])};
  }catch(e){}
  return QSO_CH_CACHE.list;
}
function qsoCard(q, idx){
  const berNum = parseFloat(q.ber);
  let berCls = 'good';
  if(berNum >= 5) berCls = 'bad';
  else if(berNum >= 2) berCls = 'warn';
  const srcCls = 'src-'+String(q.src||'').toLowerCase();
  const [c1, c2, rgb] = QSO_PALETTE[idx % QSO_PALETTE.length];
  const style = `--qso-c1:${c1};--qso-c2:${c2};--qso-rgb:${rgb}`;
  const country = lookupCallsignCountry(q.call);
  const countryHtml = country
    ? `<span class="qso-country">${flagHtml(country)}<span class="qso-country-name">${country.name}</span></span>`
    : '';
  const idHtml = q.id ? `<span class="qso-id" title="DMR ID">${q.id}</span>` : '';
  const live = (String((q && q.duration) || '') === '通话中');
  const _liveCell = live ? ' qso-live' : '';
  const durHtml = live
    ? '<span class="qso-live-badge">通话中</span>'
    : ('时长 <b>' + _sysEsc(q.duration || '-') + '</b>');
  return `<div class="qso-item${_liveCell}" style="${style}">
      <div class="glow"></div>
      <div class="qso-row1">
        <span class="qso-call">${q.call}</span>
        ${countryHtml}
        ${idHtml}
        <span class="qso-tag mode">${q.mode}</span>
        <span class="qso-tag ${srcCls}">${q.src}</span>
        <span class="qso-time">${q.time}</span>
      </div>
      <div class="qso-row2">
        <span>目标：<b>${q.target}</b></span>
        <span>${durHtml}</span>
        <span>BER: <span class="ber ${berCls}">${q.ber}</span></span>
      </div>
    </div>`;
}
function qsoEmptyCell(){
  return '<div class="qso-cell-empty"><span class="dot"></span>守候中</div>';
}
// ★ 把一个槽位/通道的通话列表拆成：active=正在通话，last=最近一条已结束
function qsoSplit(list){
  const arr = list || [];
  let active = null, last = null;
  for(let i = 0; i < arr.length; i++){
    const q = arr[i];
    const live = String((q && q.duration) || '') === '通话中';
    if(live && !active) active = q;
    else if(!live && !last) last = q;
    if(active && last) break;
  }
  return { active: active, last: last };
}
// ★ 一个「格」：槽位头(名称+状态点) + 主体(活动/最近) + 底部「上次」
function qsoSlotCell(label, list, idx){
  const sp = qsoSplit(list);
  const live = !!sp.active;
  const stateCls = live ? 'live' : 'idle';
  const stateTxt = live ? '通话中' : '空闲';
  let body;
  if(sp.active){
    body = qsoCard(sp.active, idx);
  } else if(sp.last){
    body = qsoCard(sp.last, idx);
  } else {
    body = qsoEmptyCell();
  }
  let prevHtml = '';
  if(sp.active && sp.last){
    prevHtml = '<div class="qso-prev">上次：<b>' + _sysEsc(sp.last.call || '-') + '</b>　' + _sysEsc(sp.last.duration || '') + '</div>';
  }
  return '<div class="qso-cell">'
    + '<div class="qso-slot-lbl">'
    + '<span class="qso-slot-dot ' + stateCls + '"></span>'
    + label
    + '<span class="qso-slot-state ' + stateCls + '">' + stateTxt + '</span>'
    + '</div>'
    + body
    + prevHtml
    + '</div>';
}
let QSO_RENDER_SIG = '';
async function renderQSO(items){
  const box = $id('qsoList');
  if(!box) return;
  items = items || [];
  try{ renderQsoMap(items); }catch(e){  }
  const sig = JSON.stringify(items.map(function(q){
    return [q && q.ch, q && q.time, q && q.mode, q && q.call, q && q.id,
            q && q.target, q && q.src, q && q.duration, q && q.ber];
  }));
  if(sig === QSO_RENDER_SIG && box.children.length) return;
  QSO_RENDER_SIG = sig;

  // 1) 当前开启（在跑）的通道 —— 没开启的通道不显示
  let chs = [];
  try{ chs = await qsoChannels(); }catch(e){}
  let nums = chs.filter(function(c){
    return String(c.mode||'NONE') !== 'NONE';
  }).map(function(c){ return Number(c.n); });
  if(!nums.length) nums = chs.map(function(c){ return Number(c.n); });

  // 2) 按通道分组（后端每条都带 [CHn] 标记）
  const byCh = {};
  items.forEach(function(q){
    const m = /^CH(\d)$/.exec(String(q.ch||''));
    const k = m ? Number(m[1]) : 0;
    (byCh[k] = byCh[k] || []).push(q);
  });
  if(!nums.length){
    nums = Object.keys(byCh).map(Number).filter(function(x){ return x > 0; }).sort();
  }
  /* ★ 物理通道号 → 显示序号（与首页「频率模式列表」一致） */
  mcSeqBuild(chs);      /* MC_SEQ_HELPER 统一编号 */
  if(!nums.length && byCh[0] && byCh[0].length) nums = [0];
  if(!nums.length){
    box.innerHTML = '<div class="qso-empty"><span class="dot"></span>当前信道守候中...</div>';
    return;
  }

  // ★ 新布局：DMR 占 2 格（时隙1/时隙2 各一格，各显示最新一条）；
  //   其它模式每模式一格；每行 2 个；DMR 排第一行；只显示开启的通道
  const modeOf = function(n){
    const c = chs.filter(function(x){ return Number(x.n) === n; })[0] || {};
    return String(c.mode || '').toUpperCase();
  };
  const ordered = nums.slice().sort(function(a, b){
    const da = (/^DMR/.test(modeOf(a))) ? 1 : 0;
    const db = (/^DMR/.test(modeOf(b))) ? 1 : 0;
    if(da !== db) return db - da;          // DMR 排最前
    return a - b;
  });
  const cells = [];
  let idx = 0;
  ordered.forEach(function(n){
    const currentMode = modeOf(n);
    const rawAll = byCh[n] || [];
    /* 频道和模式可能被用户随时调换：只保留当前通道实际模式的历史记录，
       并把非 DMR 记录的模式统一回当前配置，避免旧模式串到新频道。 */
    const all = rawAll.filter(function(q){
      const qm = String(q.mode || '').toUpperCase();
      if(!currentMode) return true;
      if(currentMode.indexOf('DMR') === 0) return qm.indexOf('DMR') === 0;
      if(currentMode.indexOf('D-STAR') === 0 || currentMode === 'DSTAR')
        return qm.indexOf('D-STAR') >= 0 || qm.indexOf('DSTAR') >= 0;
      return qm === currentMode;
    }).map(function(q){
      const qm = String(q.mode || '').toUpperCase();
      if(currentMode.indexOf('DMR') === 0) return q;
      return Object.assign({}, q, {mode: currentMode});
    });
    const isDmr = /^DMR/.test(currentMode);
    if(isDmr){
      const s1 = all.filter(function(q){ return !/s2/i.test(String(q.mode || '')); });
      const s2 = all.filter(function(q){ return /s2/i.test(String(q.mode || '')); });
      cells.push(qsoSlotCell('时隙 1 (TS1)', s1, idx++));
      cells.push(qsoSlotCell('时隙 2 (TS2)', s2, idx++));
    } else {
      cells.push(qsoSlotCell(currentMode + ' 通道 ' + mcSeq(n), all, idx++));
    }
  });
  box.innerHTML = cells.length
    ? '<div class="qso-grid">' + cells.join('') + '</div>'
    : '<div class="qso-empty"><span class="dot"></span>当前信道守候中...</div>';
}

// 表单加载/保存
const FIELD_MAP = {
  rf:{callsign:'Callsign',id:'Id',rxfreq:'RXFrequency',txfreq:'TXFrequency',duplex:'Duplex',power:'Power',rxgain:'RxGain',txgain:'TxGain'},
  dmr:{callsign:'Callsign',id:'Id',essid:'ESSID',cc:'ColorCode',bm_host:'Address',bm_port:'Port'},
  ysf:{callsign:'Callsign',suffix:'Suffix',startup:'Startup',wiresx:'WiresXCommandPassthrough'},
  p25:{callsign:'Callsign',nac:'NAC',static:'Static'},
  nxdn:{callsign:'Callsign',ran:'RAN',protocol:'RptProtocol',static:'Static'},
  dstar:{gw_call:'gatewayCallsign',rep_call:'repeaterCall1',irc_user:'ircddbUsername'},
  fm:{callsign:'Callsign',ctcss:'CTCSSFrequency'},
  pocsag:{freq:'Frequency'},
};

async function loadForm(page){
  const form = $id('form-'+page);
  if(!form) return;
  try{
    const r = await fetch('/api/config/' + page.toUpperCase());
    const data = await r.json();
    // 展平
    const flat = {};
    Object.keys(data).forEach(sec=>{ Object.assign(flat, data[sec]); });
    const m = FIELD_MAP[page] || {};
    Object.keys(m).forEach(name=>{
      const inp = form.querySelector('[name="'+name+'"]');
      const val = flat[m[name]];
      if(inp && val !== undefined && inp.type !== 'password'){
        inp.value = val;
      }
    });
  }catch(e){}
}

/* ===== 页面脚本错误可见化：任何 JS 异常都会在页面底部弹出红条 ===== */
window.__jsErrors = [];
function __showJsError(msg){
  try{
    window.__jsErrors.push(String(msg));
    var b = $id('jsErrBar');
    if(!b){
      b = document.createElement('div');
      b.id = 'jsErrBar';
      b.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);z-index:99999;background:rgba(13,21,32,.97);border:1px solid #2b3f57;color:#eaf2ff;padding:20px 34px;border-radius:14px;box-shadow:0 24px 64px rgba(0,0,0,.7),0 0 0 1px rgba(255,255,255,.05) inset;font-size:16px;font-weight:600;letter-spacing:.3px;text-align:center;max-width:min(560px,86vw);max-height:40vh;overflow-y:auto;word-break:break-word;white-space:pre-wrap;line-height:1.6;opacity:0;transition:opacity .3s ease,transform .3s cubic-bezier(.2,.9,.3,1.25);display:none' +
        'color:#fee2e2;font:13px/1.5 Consolas,monospace;padding:6px 10px;max-height:32vh;' +
        'overflow:auto;white-space:pre-wrap';
      document.body.appendChild(b);
    }
    b.textContent = '⚠ 页面脚本错误（请把这段反馈给开发者）：\n' +
      window.__jsErrors.slice(-6).join('\n');
  }catch(e){}
}
window.__showJsError = __showJsError;
window.addEventListener('error', function(e){
  __showJsError('Error: ' + (e.message || e.error) + '  @line ' + (e.lineno || '?'));
});
window.addEventListener('unhandledrejection', function(e){
  var r = e.reason || {};
  __showJsError('Promise: ' + (r.message || r));
});

/* ===== 内置自检：浏览器打开  http://<热点IP>/?selftest=1  会依次走一遍全部模式的保存 ===== */
async function __selfTest(){
  window.__selftestNoSwitch = true;
  var pages = ['rf','dmr','ysf','p25','nxdn','dstar','fm','pocsag'];
  var out = [];
  var box = document.createElement('pre');
  box.id = 'selftestOut';
  box.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);z-index:99999;background:rgba(13,21,32,.97);border:1px solid #2b3f57;color:#eaf2ff;padding:20px 34px;border-radius:14px;box-shadow:0 24px 64px rgba(0,0,0,.7),0 0 0 1px rgba(255,255,255,.05) inset;font-size:16px;font-weight:600;letter-spacing:.3px;text-align:center;max-width:min(560px,86vw);max-height:40vh;overflow-y:auto;word-break:break-word;white-space:pre-wrap;line-height:1.6;opacity:0;transition:opacity .3s ease,transform .3s cubic-bezier(.2,.9,.3,1.25);display:none' +
    'color:#bbf7d0;font:13px/1.5 Consolas,monospace;padding:8px;max-height:55vh;overflow:auto;' +
    'white-space:pre-wrap';
  document.body.appendChild(box);
  for(var i=0;i<pages.length;i++){
    var p = pages[i], t0 = Date.now(), msg = '';
    try{
      await saveForm({preventDefault:function(){}}, p);
      msg = (($id('toast') || {}).textContent || '').trim();
    }catch(e){ msg = 'THROW ' + (e && e.message); }
    out.push(p.toUpperCase() + ' -> ' + msg + '  (' + (Date.now()-t0) + 'ms)');
    box.textContent = out.join('\n');
  }
  out.push('JS 错误数: ' + ((window.__jsErrors || []).length));
  box.textContent = out.join('\n');
}
if(location.search.indexOf('selftest=1') >= 0){
  window.addEventListener('load', function(){ setTimeout(__selfTest, 1200); });
}

/* 兼容旧代码：loadStatus 实际不存在，统一指向 fetchStatus，避免抛异常 */
async function loadStatus(){
  try { if (typeof fetchStatus === 'function') await fetchStatus(); } catch (e) {}
}
window.loadStatus = loadStatus;

async function saveForm(ev, page){
  ev.preventDefault();
  const form = document.getElementById('form-' + page);
  if (!form) {
    toast('找不到表单', 'error');
    return;
  }

  const fd = new FormData(form);
  const body = {};
  fd.forEach((v, k) => { body[k] = v; });

  const modeMap = {
    'rf': 'RF', 'dmr': 'DMR', 'ysf': 'YSF', 'p25': 'P25',
    'nxdn': 'NXDN', 'dstar': 'D-STAR', 'fm': 'FM', 'pocsag': 'POCSAG',
  };
  const modeName = modeMap[page] || page.toUpperCase();

  if (modeName === 'RF' && (!body.rxfreq || !body.txfreq)) {
    // RF 表单不再包含频率以外的台站 ID；频率缺失时从当前状态兜底，避免保存报错
    const st = (window.__lastStatus || {});
    body.rxfreq = body.rxfreq || String(st.rx || '') || '';
    body.txfreq = body.txfreq || String(st.tx || '') || '';
    body.id = body.id || String(st.radio_id || '') || '';
    body.duplex = body.duplex || '1';
  }
  if (modeName === 'DMR') {
    // ESSID 开关关闭时不要提交 essid，否则会被拼成 9 位 ID
    const on = $id('essidOn'), ev = $id('essidVal');
    if (on && !on.checked) { delete body.essid; }
    else if (on && ev && !String(body.essid || '').trim()) { body.essid = ev.value || '01'; }
    // DMR 表单没有频率字段 -> 从当前状态兜底，修复「点保存报错」
    if (!body.rxfreq || !body.txfreq) {
      const st = (window.__lastStatus || {});
      body.rxfreq = String(st.rx || '');
      body.txfreq = String(st.tx || '');
    }
    if (!body.callsign) body.callsign = ($id('sbCall') || {}).textContent || '';
  }
  if (modeName === 'YSF' && (!body.rxfreq || !body.txfreq)) {
    const st = (window.__lastStatus || {});
    body.rxfreq = String(st.rx || '');
    body.txfreq = String(st.tx || '');
  }
  if (modeName === 'D-STAR') {
    /* DSTAR_MODULE_FIX:
       隐藏域的 name 是 reflector1（id=dstar_reflector），
       旧代码去读 body.dstar_reflector 永远是 undefined，
       于是 body.reflector1 被清成空串，后端看到空值就跳过不写，
       反射器模块就永远改不了（一直卡在原来的字母）。
       这里改为：反射器名取真实字段，模块字母取下拉框，重新拼。 */
    const _mdEl = $id('spDstarMod');
    let _mod = String((_mdEl && _mdEl.value) || body.dstar_module || 'A').trim().toUpperCase().slice(0, 1);
    if (!/^[A-Z]$/.test(_mod)) _mod = 'A';
    let _refl = String(body.reflector1 || '').trim().split(/\s+/)[0] || '';
    if (!_refl) {
      const _cf = $id('dstar_reflector');
      if (_cf) _refl = String(_cf.value || '').trim().split(/\s+/)[0] || '';
    }
    body.reflector1 = _refl ? (_refl + ' ' + _mod) : '';
    body.dstar_module = _mod;
    const su = $id('spDstarStartup');
    body.atStartup1 = (su && su.value === '0') ? '0'
                    : (String(body.dstar_startup || '1') === '0' ? '0' : '1');
    const ih = $id('dstarIrcHost');
    if (ih && ih.value) body.ircddb_host = ih.value;
    body.gw_call = body.gw_call || body.callsign || '';
    body.rep_call = body.rep_call || body.callsign || '';
    const rt = document.getElementById('dsRouting'); if (rt) body.callsign_routing = rt.value;
    const tm = document.getElementById('dsTime');    if (tm) body.time_announce   = tm.value;
    const xf = document.getElementById('dsXrf');     if (xf) body.use_dplus_xrf   = xf.value;
  }
  if (modeName === 'YSF') {
    const up = document.querySelector('[name="uppermode"]');
    if (up) body.uppermode = up.value;
  }

  toast('正在保存配置...');

  try {
    const r1 = await fetch('/api/save/' + modeName, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRFToken': (document.querySelector('meta[name="csrf-token"]') || {}).content || ''
      },
      body: JSON.stringify(body),
      credentials: 'same-origin'
    });

    if (!r1.ok) {
      // 后端失败时返回 500 + JSON，这里把真实原因原样显示给用户
      let em = '';
      try {
        const ctE = r1.headers.get('content-type') || '';
        if (ctE.includes('application/json')) {
          const dj = await r1.json();
          em = dj.message || dj.error || '';
        } else {
          em = (await r1.text()).substring(0, 200);
        }
      } catch (e) {}
      throw new Error(em || ('HTTP ' + r1.status));
    }

    let d1 = {};
    const ct1 = r1.headers.get('content-type') || '';
    if (ct1.includes('application/json')) {
      try { d1 = await r1.json(); } catch (e) {}
    }
    if (d1 && (d1.success === false || d1.ok === false)) {
      throw new Error(String(d1.message || d1.error || '后端拒绝保存').substring(0, 200));
    }
    const saveMsg = (d1 && d1.message) ? String(d1.message).substring(0, 80) : '配置已保存';
    toast('✓ ' + saveMsg, 'ok');

    // ★ 多信道设备：保存设置只保存，绝不自动切换模式。
    //   以前这里会调 /api/switch/<MODE>（只操作通道1），导致
    //   「保存 D-STAR → 通道1 变 D-STAR，DMR 停」「保存 DMR → D-STAR 停」。
    //   4 路射频必须能同时跑 4 个不同模式，模式归属只在 Multi 页/通道级切换里改。
    if (modeName !== 'NONE' && modeName !== 'RF') {
      toast('✓ ' + modeName + ' 设置已保存（不影响其它通道的运行模式）', 'ok');
    }

    await fetchStatus();
    if (typeof prefill === 'function' && modeName !== 'RF') {
      await prefill(modeName);
    }
  } catch (e) {
    toast('保存失败: ' + e.message.substring(0, 150), 'error');
  }
}

function updateHint(rx, tx){
  const rn = parseInt(rx)/1e6, tn = parseInt(tx)/1e6;
  const h1 = $id('hint-rx');
  const h2 = $id('hint-tx');
  if(h1) h1.textContent = '= ' + rn.toFixed(4) + ' MHz';
  if(h2) h2.textContent = '= ' + tn.toFixed(4) + ' MHz（差频 ' + (tn-rn >= 0 ? '+' : '') + (tn-rn).toFixed(3) + ' MHz）';
}
document.addEventListener('input', e=>{
  if(e.target.name === 'rxfreq' || e.target.name === 'txfreq'){
    const form = e.target.closest('form');
    // ★ 空值保护：RF 页的频率输入已移除，不能再无脑取 .value
    const rxEl = form ? form.querySelector('[name="rxfreq"]') : null;
    const txEl = form ? form.querySelector('[name="txfreq"]') : null;
    if(rxEl && txEl && typeof updateHint === 'function') updateHint(rxEl.value, txEl.value);
  }
});

function onMasterChange(){ /* v2：已由智能选择器接管 */ }

// 端口
async function fetchPorts(){
  try{
    const r = await fetch('/api/ports');
    const data = await r.json();
    const box = $id('portList');
    const META = {
      '3334':['MMDVM-IQ','Modem IQ 符号基带流'],
      '3335':['MMDVM-Host','热点控制器核心通信'],
      '62031':['DMRGateway','BrandMeister 动态路由'],
      '62032':['DMR-Sub','MMDVM-Host DMR 套接字'],
      '3200':['MMDVM-Host','YSF 网络本地'],
      '4200':['YSFGateway','Yaesu System Fusion 端口'],
      '32010':['MMDVM-Host','P25 本地端口'],
      '42020':['P25Gateway','APCO Phase 1 专用网关'],
      '14020':['NXDNGateway','IDAS / Nexedge 网关'],
      '14021':['MMDVM-Host','NXDN 本地端口'],
      '20010':['MMDVM-Host','D-STAR 网络本地'],
      '20011':['ircDDBGateway','D-STAR ircDDB 全球路由'],
    };
    const keys = Object.keys(data).sort((a,b)=>+a-+b);
    box.innerHTML = keys.map(p=>{
      const it = data[p];
      const isL = it.state === 'LISTEN';
      const m = META[p] || ['--','--'];
      return `<div class="port-card">
        <div class="port-num">UDP ${p}</div>
        <div class="port-info">
          <b>${m[0]}</b>
          <span>${m[1]}</span>
        </div>
        <span class="port-badge ${isL?'listen':'closed'}">${isL?'LISTEN':'CLOSED'}</span>
      </div>`;
    }).join('');
  }catch(e){
    $id('portList').innerHTML = '<div class="qso-empty">加载失败</div>';
  }
}

async function updateDb(t){
  const c = $id('dbConsole');
  if(c) c.textContent = '[执行中] 正在更新 ' + t + ' ...';
  toast('正在更新 ' + t + '...');
  try{
    const r = await fetch('/api/update-db/' + t, {method:'POST'});
    const d = await r.json();
    toast(d.message || '更新完成');
    if(c) c.textContent = '[完成] ' + (d.message || '同步完成');
    try {
      if(t === 'ysfhosts' && typeof spRefresh === 'function') spRefresh('ysf');
      if((t === 'p25hosts' || t === 'nxdnhosts') && typeof fillHostPresets === 'function') fillHostPresets();
    } catch(e) {}
  }catch(e){ toast('异常: '+e); }
}

async function restartSvc(name){
  toast('正在重启 ' + name + '...');
  try{
    await fetch('/api/restart/' + name, {method:'POST'});
    toast(name + ' 已重启');
  }catch(e){ toast('失败: '+e); }
}

// 启动（延迟到整段脚本执行完毕后再跑：否则会踩到后面 const SP/PRE_MAP 的暂时性死区）
function __boot(){
  document.body.dataset.theme = 'dashboard';
  fetchStatus();
  if (typeof mcRefresh === 'function') setTimeout(mcRefresh, 50);   // 多信道矩阵优先加载
  setTimeout(function(){ loadBmList(false); loadForm('rf'); }, 2500);
  setInterval(()=>{
    if($id('page-dashboard').style.display !== 'none') fetchStatus();
  }, 5000);
  // ★ Live QSO 看板：1 秒级刷新（停止发射后马上消失"通话中"与淡红高亮）
  setInterval(async ()=>{
    const p = $id('page-dashboard');
    if(!p || p.style.display === 'none') return;
    if($id('qsoPanel') && $id('qsoPanel').style.display === 'none') return;
    try{
      const r = await fetch('/api/qso', {credentials:'same-origin'});
      const d = await r.json();
      if(d && d.ok && typeof renderQSO === 'function') renderQSO(d.qso || []);
    }catch(e){}
  }, 500);
  setInterval(()=>{
    if($id('page-dashboard') && $id('page-dashboard').style.display !== 'none'
       && typeof mcRefresh === 'function') mcRefresh();                // 通道状态跟着刷新
  }, 12000);
}
window.__boot = __boot;
if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', __boot);
} else {
  setTimeout(__boot, 0);
}

// ============================================================
// ★ 所有下拉框从 API 动态填充
// ============================================================

// 按关键字找到 preset-box 内的 select
function findSelByKeyword(keyword) {
  // 优先按 preset-box-lbl 匹配
  const boxes = document.querySelectorAll('.preset-box');
  for (const box of boxes) {
    const lbl = box.querySelector('.preset-box-lbl');
    if (lbl && lbl.textContent.includes(keyword)) {
      const sel = box.querySelector('select');
      if (sel) return sel;
    }
  }
  // 回退：按 label 匹配
  const flds = document.querySelectorAll('.fld');
  for (const fld of flds) {
    const lbl = fld.querySelector('label');
    if (lbl && lbl.textContent.includes(keyword)) {
      const sel = fld.querySelector('select');
      if (sel) return sel;
    }
  }
  return null;
}

// 通用填充函数
async function fillSel(keyword, apiUrl, formatFn) {
  const sel = findSelByKeyword(keyword);
  if (!sel) {
    console.debug('[fillSel] 未找到:', keyword);
    return;
  }
  const curVal = sel.value;
  try {
    const r = await fetch(apiUrl, {credentials: 'same-origin'});
    const d = await r.json();
    const items = d.items || d.masters || (Array.isArray(d) ? d : []);
    if (!items.length) {
      console.debug('[fillSel] 空数据:', keyword, apiUrl);
      return;
    }
    sel.innerHTML = items.map(formatFn).join('');
    // 恢复原选中值；找不到就用第一个
    if ([...sel.options].some(o => o.value === curVal)) {
      sel.value = curVal;
    } else if (sel.options.length) {
      sel.selectedIndex = 0;
    }
    console.log(`[fillSel] ${keyword}: ${items.length} items`);
  } catch (e) {
    console.warn('[fillSel] 失败:', keyword, e);
  }
}

// ★ 按 id 精确填充（不再依赖标签文字匹配 —— 之前 YSF 房间列表就是因为关键词匹配不上而从未生效）
async function fillSelById(id, apiUrl, formatFn, keepFirst) {
  const sel = document.getElementById(id);
  if (!sel) { console.debug('[fillSelById] 未找到:', id); return; }
  const curVal = sel.value;
  try {
    const r = await fetch(apiUrl, {credentials: 'same-origin'});
    const d = await r.json();
    const items = d.items || d.masters || (Array.isArray(d) ? d : []);
    if (!items.length) { console.debug('[fillSelById] 空数据:', id); return; }
    const head = (keepFirst && sel.options.length) ? sel.options[0].outerHTML : '';
    sel.innerHTML = head + items.map(formatFn).join('');
    if ([...sel.options].some(o => o.value === curVal)) sel.value = curVal;
    console.log(`[fillSelById] ${id}: ${items.length} items`);
  } catch (e) { console.warn('[fillSelById] 失败:', id, e); }
}

// D-STAR 特殊处理（带 optgroup 分组）
async function fillDstarSelOld_unused() {
  const sel = document.getElementById('dstarReflector')
        || findSelByKeyword('Default Reflector')
        || findSelByKeyword('默认连接反射器');
  if (!sel) { console.debug('[fillDstar] 未找到 select'); return; }
  const curVal = sel.value;
  try {
    const r = await fetch('/api/dstar-reflectors', {credentials: 'same-origin'});
    const d = await r.json();
    let html = '';
    // 中国/亚洲区优先
    const asiaKeys = ['REF', 'XRF', 'DCS', 'XLX'];
    // 提取可能匹配亚洲/中国的
    const isAsia = (name, desc) => {
      const t = (name + ' ' + (desc||'')).toUpperCase();
      return t.includes('HKG') || t.includes('CN') || t.includes('CHINA') 
          || t.includes('JAPAN') || t.includes('KOREA') 
          || t.includes('SINGAPORE') || t.includes('TAIWAN')
          || t.includes('ASIA') || t.includes('076') || t.includes('082')
          || t.includes('083') || t.includes('084') || t.includes('085');
    };
    // 中国/亚洲
    const asiaGroups = [];
    for (const key of asiaKeys) {
      const arr = d[key] || [];
      const asia = arr.filter(x => isAsia(x.name, x.ip));
      if (asia.length) {
        asiaGroups.push(`<optgroup label="🇨🇳 中国/亚洲 · ${key}">` +
          asia.slice(0, 200).map(x => 
            `<option value="${x.name}">${x.name} — ${x.ip||''}</option>`
          ).join('') + '</optgroup>');
      }
    }
    html += asiaGroups.join('');
    // 国际
    for (const key of asiaKeys) {
      const arr = d[key] || [];
      const intl = arr.filter(x => !isAsia(x.name, x.ip));
      if (intl.length) {
        html += `<optgroup label="🌍 国际 · ${key} (${intl.length})">` +
          intl.map(x => 
            `<option value="${x.name}">${x.name} — ${x.ip||''}</option>`
          ).join('') + '</optgroup>';
      }
    }
    if (html) {
      sel.innerHTML = html;
      if ([...sel.options].some(o => o.value === curVal)) {
        sel.value = curVal;
      }
      console.log('[fillDstar] total options:', sel.options.length);
    }
  } catch (e) {
    console.warn('[fillDstar] 失败:', e);
  }
}

// 全量填充
async function fillAllSelects() {
  // DMR BM Master
  await fillSel('DMR Master', '/api/bm-list', m => {
    const region = m.region ? `${m.region}` : (m.country || '');
    const id = m.id || '';
    const host = m.host || m;
    return `<option value="${host}">BM_${id}_${region} (${host})</option>`;
  });

  // P25 Startup Host
  await fillSel('P25 Startup Host', '/api/p25-hosts', p => {
    const name = p.name || '';
    const host = p.host || '';
    return `<option value="${name}">${name} (${host})</option>`;
  });

  // NXDN Host
  await fillSel('NXDN Host', '/api/nxdn-hosts', n => {
    const id = n.id || '';
    const host = n.host || '';
    return `<option value="${id}">${id} (${host})</option>`;
  });

  // D-STAR 反射器
  await fillDstarSel();

  // FCS Startup Room
  await fillSel('FCS Startup Room', '/api/fcs-hosts', f => {
    const id = f.id || '';
    const name = f.name || '';
    return `<option value="${id}">${id} - ${name}</option>`;
  });
}

// 页面加载后 500ms 填充（等 DOM 稳定）
document.addEventListener('DOMContentLoaded', () => {
  setTimeout(fillAllSelects, 500);
});

// 切页时也填充（覆盖安全）
const __orig_switchPage = typeof switchPage === 'function' ? switchPage : null;
if (__orig_switchPage) {
  window.switchPage = function(name) {
    __orig_switchPage(name);
    setTimeout(fillAllSelects, 200);
  };
}

// 暴露到全局方便调试
window.fillAllSelects = fillAllSelects;


/* ==================== ★ 表单回填 / 预设（保存后能看到当前值） ==================== */
const PRE_MAP = {RF:'form-rf', DMR:'form-dmr', YSF:'form-ysf', P25:'form-p25',
                 NXDN:'form-nxdn', 'D-STAR':'form-dstar', FM:'form-fm', POCSAG:'form-pocsag'};

function stripCallSuffix(v){
  return String(v == null ? '' : v).replace(/\s+/g, ' ').trim().toUpperCase().replace(/\s+[A-Z]$/, '');
}

/* 静态通话组预设：点选 P25/NXDN 预设时写入 Static 字段 */
function applyStaticPreset(t, v){
  const el = $id(t === 'p25' ? 'p25Static' : 'nxdnStatic');
  if (!el || !v) return;
  const cur = String(el.value || '').split(',').map(s => s.trim()).filter(Boolean);
  if (cur.indexOf(v) < 0) cur.unshift(v);
  el.value = cur.join(', ');
  toast('已把通话组 ' + v + ' 加入静态列表');
}

/* 用 Pi-Star P25Hosts / NXDNHosts 补全预设下拉 */
async function fillHostPresets(){
  try {
    const r = await fetch('/api/p25-hosts', {credentials:'same-origin'});
    const d = await r.json();
    const items = d.items || [];
    const sel = $id('p25Preset');
    if (sel && items.length) {
      items.forEach(function (it) {   // 全量（Pi-Star P25Hosts 全部条目）
        const tg = String(it.id || '').replace(/^P25[-_]/i, '');
        if (!tg || !/^\d+$/.test(tg)) return;
        const o = document.createElement('option');
        o.value = tg; o.textContent = 'P25-' + tg + '  ' + (it.name || '');
        sel.appendChild(o);
      });
      const h = $id('p25HostHint');
      if (h) h.textContent = '已载入 Pi-Star P25Hosts 共 ' + items.length + ' 条（点选即写入静态通话组）';
    }
  } catch (e) {}
  try {
    const r2 = await fetch('/api/nxdn-hosts', {credentials:'same-origin'});
    const d2 = await r2.json();
    const items2 = d2.items || [];
    const sel2 = $id('nxdnPreset');
    if (sel2 && items2.length) {
      items2.forEach(function (it) {  // 全量（Pi-Star NXDNHosts 全部条目）
        const tg = String(it.id || '').replace(/^NXDN[-_]/i, '');
        if (!tg || !/^\d+$/.test(tg)) return;
        const o = document.createElement('option');
        o.value = tg; o.textContent = 'NXDN-' + tg + '  ' + (it.name || '');
        sel2.appendChild(o);
      });
      const h2 = $id('nxdnHostHint');
      if (h2) h2.textContent = '已载入 Pi-Star NXDNHosts 共 ' + items2.length + ' 条（点选即写入静态通话组）';
    }
  } catch (e) {}
}

/* D-STAR 三个网关开关的当前状态（标记文件类） */
async function fetchDstarFlags(){
  try {
    const r = await fetch('/api/dstar-flags', {credentials:'same-origin'});
    const d = await r.json();
    const rt = $id('dsRouting'); if (rt && d.callsign_routing != null) rt.value = String(d.callsign_routing);
    const t  = $id('dsTime');    if (t  && d.time_announce   != null) t.value  = String(d.time_announce);
    const x  = $id('dsXrf');     if (x  && d.use_dplus_xrf   != null) x.value  = String(d.use_dplus_xrf);
  } catch (e) {}
}

/* 从 /api/config/<mode> 回填表单，保证保存后能看到真实生效值 */
async function prefill(mode){
  const form = $id(PRE_MAP[mode] || '');
  if (!form) return;
  let flat = {};
  try {
    const r = await fetch('/api/config/' + encodeURIComponent(mode), {credentials:'same-origin'});
    const d = await r.json();
    Object.keys(d || {}).forEach(function (k) { Object.assign(flat, d[k] || {}); });
  } catch (e) { return; }
  const set = function (n, v) {
    const el = form.querySelector('[name="' + n + '"]');
    if (el && v != null && String(v) !== '') el.value = v;
  };

  if (mode === 'RF') {
    set('callsign', flat.Callsign);
    set('rxfreq', flat.RXFrequency); set('txfreq', flat.TXFrequency);
    set('rxgain', flat.RxGain); set('txgain', flat.TxGain);
    if (flat.RXFrequency && flat.TXFrequency) updateHint(flat.RXFrequency, flat.TXFrequency);
  } else if (mode === 'DMR') {
    set('callsign', flat.Callsign); set('cc', flat.ColorCode);
    set('bm_host', flat.Address); set('bm_port', flat.Port); set('bm_pw', flat.Password);
    const on = $id('essidOn'), ev = $id('essidVal');
    const idv = String(flat.Id || '').replace(/[^0-9]/g, '');
    if (idv) {
      if (idv.length >= 9) {
        set('id', idv.slice(0, 7));
        if (ev) ev.value = idv.slice(7);
        if (on) { on.checked = true; on.dispatchEvent(new Event('change')); }
      } else {
        set('id', idv);
        if (ev) ev.value = '01';
        if (on) { on.checked = false; on.dispatchEvent(new Event('change')); }
      }
    }
    const h = $id('dmrBmHost'); if (h && flat.Address) h.value = flat.Address;
    const s = $id('spDmrSel');
    if (s && flat.Address) s.textContent = flat.Address + ':' + (flat.Port || '62031');
  } else if (mode === 'YSF') {
    set('callsign', flat.Callsign); set('startup', flat.Startup);
    if (flat.WiresXCommandPassthrough != null) set('wiresx', String(flat.WiresXCommandPassthrough));
    if (flat.WiresXMakeUpper != null) set('uppermode', String(flat.WiresXMakeUpper));
  } else if (mode === 'P25') {
    set('callsign', flat.Callsign); set('nac', flat.NAC); set('static', flat.Static);
  } else if (mode === 'NXDN') {
    set('callsign', flat.Callsign); set('id', flat.Id); set('ran', flat.RAN);
    set('protocol', flat.Protocol || flat.RptProtocol); set('static', flat.Static);
  } else if (mode === 'D-STAR') {
    set('gw_call', stripCallSuffix(flat.gatewayCallsign || flat.Callsign));
    set('rep_call', stripCallSuffix(flat.repeaterCall1));
    set('irc_user', flat.ircddbUsername);
    const rt = $id('dsRouting');
    if (rt && flat.ircddbEnabled != null) rt.value = String(flat.ircddbEnabled);
    const ih = $id('dstarIrcHost');
    if (ih && flat.ircddbHostname) {
      let hit = false;
      Array.prototype.forEach.call(ih.options, function (o) { if (o.value === flat.ircddbHostname) hit = true; });
      if (!hit) {
        const o = document.createElement('option');
        o.value = flat.ircddbHostname; o.textContent = flat.ircddbHostname + '（当前配置）';
        ih.appendChild(o);
      }
      ih.value = flat.ircddbHostname;
    }
    if (flat.reflector1) {
      const p = String(flat.reflector1).trim().split(/\s+/);
      set('dstar_reflector', p[0]);
      set('dstar_module', (p[1] || 'B').toUpperCase());
      const md = $id('spDstarMod'); if (md && p[1]) md.value = p[1].toUpperCase();
      const s2 = $id('spDstarSel'); if (s2) s2.textContent = String(flat.reflector1).trim();
    }
    const su = $id('spDstarStartup');
    if (su && flat.atStartup1 != null) su.value = String(flat.atStartup1) === '1' ? '1' : '0';
    await fetchDstarFlags();
  } else if (mode === 'FM') {
    set('callsign', flat.Callsign); set('ctcss', flat.CTCSSFrequency);
  } else if (mode === 'POCSAG') {
    set('freq', flat.Frequency);
  }
}
/* ==================== ★ v2 智能选择器（DMR 主机 / D-STAR 反射器） ==================== */
const SP = {
  dmr:   { url: '/api/bm-list',          net: 'BM', q: '', rows: null, cur: '', max: 0 },
  ysf:   { url: '/api/ysf-hosts',        net: '', q: '', rows: null, cur: '', max: 0 },
  dstar: { url: '/api/dstar-reflectors', net: '', q: '', rows: null, cur: '', max: 0 },
};
const SP_IDS = { dmr: 'spDmr', ysf: 'spYsf', dstar: 'spDstar' };

function spEl(t, suf) { return document.getElementById(SP_IDS[t] + suf); }

function spEsc(s) {
  return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;')
    .replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

async function spEnsure(t) {
  const S = SP[t];
  if (S.rows) return S.rows;
  const r = await fetch(S.url, { credentials: 'same-origin' });
  const d = await r.json();
  if (t === 'dmr') {
    S.rows = (d.masters || []).map(m => ({
      key: m.host, typ: (m.net || 'BM').toUpperCase(),
      name: m.label || ((m.net || 'BM') + (m.id || '')),
      ip: m.host, port: m.port, pw: m.pw || '', country: m.country || '',
    }));
  } else if (t === 'ysf') {
    /* YSF / WIRES-X 房间：key=房间号（写入 YSFGateway 的 Startup），名称带编号便于搜索辨认 */
    S.rows = (d.items || []).map(y => {
      const id = String(y.id || '');
      const nm = String(y.name || '');
      const ds = String(y.desc || '');
      return {
        key: id, typ: 'YSF', raw: nm,
        name: (id ? id + ' · ' : '') + nm + (ds ? ('（' + ds + '）') : ''),
        ip: y.host || '', port: y.port || '', pw: '', country: '',
      };
    });
    S.rows.sort((a, b) => String(a.key).localeCompare(String(b.key)));
  } else {
    const out = [];
    ['REF', 'XRF', 'DCS', 'CCS', 'XLX', 'FCS'].forEach(k => {
      (d[k] || []).forEach(x => out.push({
        key: x.name, typ: k, name: x.name, ip: x.ip || '', port: '', pw: '',
      }));
    });
    out.sort((a, b) => a.typ.localeCompare(b.typ) || a.name.localeCompare(b.name));
    S.rows = out;
  }
  return S.rows;
}

function spFiltered(t) {
  const S = SP[t];
  if (!S.rows) return [];
  const q = S.q.trim().toLowerCase();
  return S.rows.filter(x => {
    if (S.net && x.typ !== S.net) return false;
    if (!q) return true;
    return (x.name + ' ' + x.ip + ' ' + x.typ + ' ' + (x.country || '') + ' '
            + (x.key || '') + ' ' + (x.raw || '')).toLowerCase().includes(q);
  });
}

function spRenderChips(t) {
  const S = SP[t], box = spEl(t, 'Chips');
  if (!box || !S.rows) return;
  const cnt = {};
  S.rows.forEach(x => { cnt[x.typ] = (cnt[x.typ] || 0) + 1; });
  const keys = Object.keys(cnt).sort((a, b) => cnt[b] - cnt[a]);
  let h = '<span class="sp-chip' + (S.net === '' ? ' on' : '') + '" data-net="">全部<b>'
        + S.rows.length + '</b></span>';
  keys.forEach(k => {
    h += '<span class="sp-chip' + (S.net === k ? ' on' : '') + '" data-net="' + spEsc(k) + '">'
       + spEsc(k) + '<b>' + cnt[k] + '</b></span>';
  });
  box.innerHTML = h;
  box.querySelectorAll('.sp-chip').forEach(c => {
    c.addEventListener('click', () => {
      S.net = c.dataset.net || ''; spRenderChips(t); spRenderList(t);
    });
  });
}

function spRenderList(t) {
  const S = SP[t], box = spEl(t, 'List'), cntEl = spEl(t, 'Cnt');
  if (!box) return;
  const rows = spFiltered(t);
  if (cntEl) cntEl.textContent = rows.length + ' / ' + (S.rows ? S.rows.length : 0) + ' 个';
  if (!rows.length) {
    box.innerHTML = '<div class="sp-empty">没有匹配项 —— 试试关键字 '
      + (t === 'dmr' ? 'BM / HB / China / 4602'
                     : (t === 'ysf' ? '46087 / CN / China / XLX / JP'
                                    : 'REF / XRF / DCS / XLX / FCS')) + '</div>';
    return;
  }
  box.innerHTML = (S.max ? rows.slice(0, S.max) : rows).map(x => {
    const on = (x.key === S.cur) ? ' on' : '';
    const right = x.port ? ('<span class="pt">:' + spEsc(x.port) + '</span>') : '';
    return '<div class="sp-row' + on + '" data-key="' + spEsc(x.key) + '">'
      + '<span class="nm">' + spEsc(x.name) + '</span>'
      + '<span class="nt">' + spEsc(x.typ) + '</span>'
      + '<span class="hs">' + spEsc(x.ip) + '</span>' + right + '</div>';
  }).join('') + (S.max && rows.length > S.max
      ? '<div class="sp-empty">… 还有 ' + (rows.length - S.max) + ' 条，请继续输入关键字缩小范围</div>'
      : '');
  if (!box._spBound) {
    box.addEventListener('click', function(e) {
      var row = e.target.closest('.sp-row');
      if (row && row.dataset.key) spPick(t, row.dataset.key);
    });
    box._spBound = true;
  }
}

function spFind(t, key) { return (SP[t].rows || []).find(x => x.key === key); }

function spPick(t, key) {
  const S = SP[t], item = spFind(t, key);
  if (!item) return;
  S.cur = key;
  const sel = spEl(t, 'Sel');
  if (sel) sel.textContent = item.name + (item.port ? (':' + item.port) : '') + '  →  ' + item.ip;
  if (t === 'dmr') {
    const h = $id('dmrBmHost'); if (h) h.value = item.ip;
    const p = document.querySelector('#form-dmr [name="bm_port"]');
    if (p && item.port) p.value = item.port;
    const pw = document.querySelector('#form-dmr [name="bm_pw"]');
    if (pw && item.pw) pw.value = item.pw;
  } else if (t === 'ysf') {
    /* ★ 本机 YSFGateway 的 Startup 是〈反射器名称〉（官方示例 Startup=Alabama-Link），
       不是房间号；所以这里填 Pi-Star 的完整名（如 CN-YSF-TG46020）才能连上。 */
    const h = $id('ysfStartup'); if (h) h.value = (item.raw || item.key);
    const src = spEl('ysf', 'Src');
    if (src) src.textContent = '已选 ' + item.name + ' → ' + (item.ip || '')
      + '（写入 ' + (item.raw || item.key) + '）';
  } else {
    const h = $id('dstar_reflector'); if (h) h.value = item.name;
    const hr = $id('dstar_reflector_full');
    const mod = ($id('spDstarMod') || {}).value || 'C';
    if (hr) hr.value = item.name + ' ' + mod;
    const src = spEl('dstar', 'Src');
    if (src) src.textContent = '已选 ' + item.name + ' → ' + (item.ip || '');
  }
  spRenderList(t);
  if (typeof toast === 'function') toast('已选择 ' + item.name, 'ok');
}

async function spLoad(t, force) {
  const S = SP[t];
  if (force) S.rows = null;
  try {
    await spEnsure(t);
    spRenderChips(t);
    spRenderList(t);
  } catch (e) {
    const box = spEl(t, 'List');
    if (box) box.innerHTML = '<div class="sp-empty">主机库读取失败：' + spEsc(e.message) + '</div>';
  }
}

function spRefresh(t) { spLoad(t, true); }

async function spLoadCurrent(t) {
  try {
    const mode = (t === 'dmr') ? 'DMR' : (t === 'ysf' ? 'YSF' : 'D-STAR');
    const r = await fetch('/api/config/' + mode, { credentials: 'same-origin' });
    const data = await r.json();
    const flat = {};
    Object.keys(data).forEach(sec => Object.assign(flat, data[sec]));
    await spLoad(t);
    if (t === 'dmr') {
      const host = flat.Address || '';
      const pw = document.querySelector('#form-dmr [name="bm_pw"]');
      if (pw && flat.Password) pw.value = flat.Password;
      const pt = document.querySelector('#form-dmr [name="bm_port"]');
      if (pt && flat.Port) pt.value = flat.Port;
      /* 把已保存的 Id 拆显为 7 位 DMR ID + 2 位 ESSID（并同步开关状态） */
      if (typeof window.loadEssidFromConfig === 'function') window.loadEssidFromConfig(flat);
      if (host) {
        const it = (SP.dmr.rows || []).find(x => x.ip === host);
        if (it) { spPick('dmr', it.key); }
        else {
          const h = $id('dmrBmHost'); if (h) h.value = host;
          const s = spEl('dmr', 'Sel'); if (s) s.textContent = host + '（库中未列出）';
        }
      }
    } else if (t === 'ysf') {
      const st = String(flat.startup == null ? '' : flat.startup).trim();
      if (st) {
        const it = (SP.ysf.rows || []).find(x => String(x.key) === st || String(x.raw) === st);
        if (it) { spPick('ysf', it.key); }
        else {
          const h = $id('ysfStartup'); if (h) h.value = st;
          const s = spEl('ysf', 'Sel'); if (s) s.textContent = st + '（库中未列出）';
        }
      }
    } else {
      const refl = (flat.reflector1 || '').trim();
      const su = $id('spDstarStartup');
      if (su) su.value = String(flat.atStartup1 || '1') === '1' ? '1' : '0';
      if (refl) {
        const p2 = refl.split(/\s+/);
        const mod = $id('spDstarMod');
        if (mod && p2[1]) mod.value = p2[1].toUpperCase();
        const it = (SP.dstar.rows || []).find(x => x.name === p2[0]);
        if (it) { spPick('dstar', it.key); }
        else {
          const s = spEl('dstar', 'Sel'); if (s) s.textContent = refl + '（库中未列出）';
          const h = $id('dstar_reflector'); if (h) h.value = p2[0];
        }
      }
    }
  } catch (e) { /* 忽略 */ }
}

function spBind(t) {
  const inp = spEl(t, 'In');
  if (inp) {
    var _spTimer = null;
    inp.addEventListener('input', () => {
      SP[t].q = inp.value;
      if (!SP[t].rows) { spLoad(t); return; }
      clearTimeout(_spTimer);
      _spTimer = setTimeout(() => spRenderList(t), 120);
    });
    inp.addEventListener('keydown', e => {
      if (e.key === 'Enter') {
        e.preventDefault();
        const rows = spFiltered(t);
        if (rows.length) spPick(t, rows[0].key);
      }
    });
  }
  const mod = spEl(t, 'Mod');
  if (mod) {
    mod.addEventListener('change', () => {
      const hr = $id('dstar_reflector_full');
      const cf = $id('dstar_reflector');
      if (hr && cf && cf.value) hr.value = cf.value + ' ' + mod.value;
    });
  }
  const st = spEl(t, 'Startup');
  if (st) {
    st.addEventListener('change', () => {
      const hs = $id('dstar_atstartup');
      if (hs) hs.value = st.value;
      document.querySelectorAll('input[name="dstar_startup"]').forEach(r => {
        r.checked = (r.value === st.value);
      });
    });
  }
  spLoad(t);
}
spBind('dmr');
spBind('ysf');
spBind('dstar');
fillHostPresets();
prefill('DMR');

/* ESSID 开关：取消勾选 -> 禁用输入框（disabled 的控件不进入 FormData，等于不启用） */
(function () {
  var cb = document.getElementById('essidOn'), iv = document.getElementById('essidVal');
  if (!cb || !iv) return;
  var sync = function () {
    iv.disabled = !cb.checked;
    iv.style.opacity = cb.checked ? '1' : '.45';
  };
  cb.addEventListener('change', sync);
  sync();
  /* 从现有配置回填：Id 为 9 位时拆成 7 位 + 2 位 ESSID */
  window.loadEssidFromConfig = function (flat) {
    var v = String(flat && flat.Id ? flat.Id : '').replace(/[^0-9]/g, '');
    if (v.length >= 9) {
      var base = v.slice(0, 7), es = v.slice(-2);
      var idEl = document.querySelector('#form-dmr [name="id"]');
      if (idEl) idEl.value = base;
      cb.checked = true;
      iv.value = /^(0[1-9]|[1-9][0-9])$/.test(es) ? es : '01';
      iv.disabled = false;
      iv.style.opacity = '1';
    }
  };
})();

window.spRefresh = spRefresh;
window.spLoadCurrent = spLoadCurrent;

function loadBmList(force) { spLoad('dmr', !!force); }
function fillDstarSel() { return spLoad('dstar'); }
window.loadBmList = loadBmList;
window.fillDstarSel = fillDstarSel;

/* 从 Pi-Star 重建主机库（本地 .txt -> JSON） */
async function rebuildHosts() {
  if (!confirm('将从 /usr/local/etc/hosts 的主机文件重建全部 JSON 索引？')) return;
  toast('正在重建主机库…');
  try {
    const r = await fetch('/api/hosts-rebuild', { method: 'POST' });
    const d = await r.json();
    toast(d.ok ? '主机库已重建' : ('重建失败: ' + (d.message || '')), d.ok ? 'ok' : 'error');
    await spLoad('dmr', true);
    await spLoad('dstar', true);
  } catch (e) {
    toast('重建失败: ' + e.message, 'error');
  }
}
window.rebuildHosts = rebuildHosts;

/* ==================== 系统设置（WiFi / 系统信息 / 电源）==================== */
function _sysEsc(s){
  return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
async function sysLoad(){
  const info = $id('sysInfo'), net = $id('sysNet');
  try{
    const d = await (await fetch('/api/sys/info', {credentials:'same-origin'})).json();
    if(info){
      // ★ 简洁版：设备 / 时间 / 运行 / 状态 共 4 行
      const mdl = String(d.MODEL || '-')
        .replace(/^Raspberry Pi\s*/i, 'Pi ').replace(/\s*Model\s*/i, ' ').replace(/\s*Rev.*$/i, '').trim();
      info.innerHTML =
        '<span class="kvw">\u8bbe\u5907</span><b>' + _sysEsc(d.HOSTNAME || '-') + '</b>　' + _sysEsc(mdl || '-') + '<br>'
        + '<span class="kvw">\u65f6\u95f4</span><b>' + _sysEsc(d.TIME || '-') + '</b>　' + _sysEsc(d.TZ || '-') + '<br>'
        + '<span class="kvw">\u8fd0\u884c</span>' + _sysEsc(d.UPTIME || '-') + '<br>'
        + '<span class="kvw">\u72b6\u6001</span>\u6e29\u5ea6 <b>' + _sysEsc(d.TEMP || '-') + '</b>　\u5185\u5b58 '
        + _sysEsc(d.MEM || '-') + '　\u78c1\u76d8 ' + _sysEsc(d.DISK || '-');
    }
  }catch(e){ if(info) info.textContent = '系统信息读取失败：' + e; }
  try{
    const t = await (await fetch('/api/sys/net', {credentials:'same-origin'})).text();
    if(net) net.innerHTML = _netRender(t);
  }catch(e){ if(net) net.textContent = '网络信息读取失败：' + e; }

  /* ★ 网络状态：把后端的键值渲染成简洁的 3 行 */
  function _netRender(txt){
    const g = function(k){ const m = new RegExp('^' + k + '=(.*)$', 'm').exec(txt || ''); return m ? m[1].trim() : ''; };
    const one = function(lbl, pre, icon){
      if(!g(pre + '_TYPE')) return '';
      const st = g(pre + '_STATE'), ip = g(pre + '_IP'), gw = g(pre + '_GW');
      const ok = (st === 'connected');
      let val = (ok && ip && ip !== '-') ? ip : '未连接';
      let meta = '';
      if(ok && gw && gw !== '-') meta += '<span class="netmeta-k">网关</span>' + _sysEsc(gw);
      if(pre === 'WLAN0' && g('WIFI_SSID') && g('WIFI_SSID') !== '-'){
        meta += (meta ? '<span class="netsep">·</span>' : '') + '<span class="netmeta-k">SSID</span>' + _sysEsc(g('WIFI_SSID'));
      }
      return '<div class="netrow">'
        + '<span class="netico">' + icon + '</span>'
        + '<span class="netlab">' + lbl + '</span>'
        + '<span class="netdot ' + (ok ? 'ok' : 'off') + '"></span>'
        + '<span class="netval' + (ok ? '' : ' off') + '">' + _sysEsc(val) + '</span>'
        + (meta ? '<span class="netmeta">' + meta + '</span>' : '')
        + '</div>';
    };
    const dns = (g('DNS') || '').split(',')
      .filter(function(x){ return x && x.indexOf('fe80:') !== 0 && x.indexOf('%') < 0; });
    return one('\u6709\u7ebf', 'ETH0', '🔌')
         + one('\u65e0\u7ebf', 'WLAN0', '📶')
         + '<div class="netrow"><span class="netico">🌐</span><span class="netlab">DNS</span>'
         + '<span class="netdot netdot-none"></span><span class="netval mono">'
         + _sysEsc(dns.length ? dns.join('  \u00b7  ') : '-') + '</span></div>';
  }
  try{ if(typeof sysTimeLoad === 'function') sysTimeLoad(); }catch(e){ console.warn('sysTimeLoad', e); }
  try{ if(typeof wifiStatusShow === 'function') wifiStatusShow(); }catch(e){ console.warn('wifiStatusShow', e); }
  try{ if(typeof aptStatLoad === 'function') aptStatLoad(); }catch(e){ console.warn('aptStatLoad', e); }
  try{ if(typeof fwInfoLoad === 'function') fwInfoLoad(); }catch(e){ console.warn('fwInfoLoad', e); }
  try{
    const _sec = $id('page-sys');
    if(window.__repErr) window.__repErr('sys', 'secKids=' + (_sec ? _sec.children.length : -1)
      + ' infoLen=' + (($id('sysInfo') || {}).innerHTML || '').length
      + ' wifiLen=' + (($id('wifiStatus') || {}).innerHTML || '').length
      + ' aptLen=' + (($id('aptStat') || {}).innerHTML || '').length
      + ' fwLen=' + (($id('fwStat') || {}).innerHTML || '').length, location.href, '', '');
  }catch(e2){}
}
/* ---------- WiFi（双后端：NetworkManager / wpa_supplicant）---------- */
function _wifiFill(ssid){ const el = $id('wifiSsid'); if(el) el.value = ssid; }
function _bandTag(b){
  const c = (b === '5G') ? '#a78bfa' : ((b === '2.4G') ? '#34d399' : 'var(--dim)');
  return '<span style="color:' + c + ';font-size:12px;font-weight:700;min-width:38px">' + _sysEsc(b || '-') + '</span>';
}
function _sigBar(n){
  n = Number(n) || 0;
  const c = n >= 70 ? '#34d399' : (n >= 45 ? '#fbbf24' : '#f87171');
  return '<span style="display:inline-block;min-width:104px"><span style="display:inline-block;height:7px;width:'
    + Math.max(6, Math.min(100, n)) + '%;background:' + c + ';border-radius:4px;vertical-align:middle"></span> '
    + n + '%</span>';
}
async function wifiStatusShow(){
  const box = $id('wifiStatus'), badge = $id('wifiBackend');
  const d = await _sysAct('wifi-status', []);
  const t = d.message || '';
  const g = function(k){ const m = new RegExp('^' + k + '=(.*)$', 'm').exec(t); return m ? m[1].trim() : ''; };
  const be = g('BACKEND'), radio = g('RADIO');
  if(badge) badge.textContent = (be === 'nm') ? 'NetworkManager' : ((be === 'wpa') ? 'wpa_supplicant' : (be || 'WIFI'));
  const rb = $id('wifiRadioBtn');
  if(rb) rb.textContent = (radio === 'disabled') ? '📶 打开 WiFi' : '📴 关闭 WiFi';
  let cc = '';
  if(box){
    const ssid = g('SSID'), ip = g('IP'), sig = g('SIGNAL'), band = g('BAND'), freq = g('FREQ'),
          rate = g('RATE'), sec = g('WIFISEC') || g('KEYMGMT'), gw = g('GW'),
          saved = g('SAVED'), bands = g('BANDS'), iface = g('IFACE');
    cc = g('COUNTRY');
    box.innerHTML =
      '状态：' + ((radio === 'disabled') ? '<b style="color:#fbbf24">射频已关闭</b>'
                : (ssid ? '<b style="color:#34d399">已连接</b>' : '<span style="color:#fbbf24">未连接（网线仍可用）</span>'))
      + '　SSID：<b>' + _sysEsc(ssid || '-') + '</b>'
      + (band ? '　频段：' + _bandTag(band) : '')
      + (freq && freq !== '-' ? '　频率：' + _sysEsc(freq) : '')
      + (sig ? '　信号：' + _sysEsc(sig) : '')
      + (rate ? '　速率：' + _sysEsc(rate) : '') + '<br>'
      + 'IP：<b>' + _sysEsc(ip || '-') + '</b>　网关：' + _sysEsc(gw || '-')
      + '　加密：' + _sysEsc(sec || '-')
      + (bands !== '' ? '　信道数：' + _sysEsc(bands) : '')
      + '　已保存网络：' + _sysEsc(saved || '0')
      + (iface && iface !== '-' ? '　网卡：' + _sysEsc(iface) : '');
  }
  try{ if(window.__repErr) window.__repErr('wifi', 'statusLen=' + (($id('wifiStatus') || {}).innerHTML || '').length
        + ' scanLen=' + (($id('wifiList') || {}).innerHTML || '').length, location.href, '', ''); }catch(e){}
}
async function wifiScan(){
  const box = $id('wifiList'), msg = $id('wifiMsg');
  if(msg) msg.textContent = '扫描中…（约 8-15 秒）';
  if(box) box.innerHTML = '正在扫描附近 WiFi…';
  const d = await _sysAct('wifi-scan', []);
  const lines = (d.message || '').split('\n').filter(function(x){ return x.indexOf('|') >= 0; });
  if(!box) return;
  if(!lines.length){
    box.innerHTML = _sysEsc(String(d.message || '').trim() || '未发现无线网络');
    if(msg) msg.textContent = '';
    return;
  }
  box.innerHTML = lines.map(function(ln){
    const f = ln.split('|');
    const use = String(f[0] || '').trim() === '*';
    const ssid = String(f[1] || '').trim(), sig = String(f[2] || '').trim(), sec = String(f[3] || '').trim(),
          ch = String(f[4] || '').trim(), fq = String(f[5] || '').trim(), band = String(f[6] || '').trim();
    const safe = ssid.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
    return '<div style="display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--border);padding:4px 2px;flex-wrap:wrap">'
      + '<span style="min-width:14px">' + (use ? '✅' : '') + '</span>'
      + '<b style="min-width:160px;color:#fff">' + _sysEsc(ssid) + '</b>'
      + _bandTag(band) + _sigBar(sig)
      + '<span style="color:var(--muted);min-width:110px">' + _sysEsc(sec || '开放') + '</span>'
      + '<span style="color:var(--dim)">信道 ' + _sysEsc(ch) + ((fq && fq !== '-') ? (' / ' + _sysEsc(fq) + ' MHz') : '') + '</span>'
      + '<a href="#" style="margin-left:auto" onclick="_wifiFill(\'' + safe + '\');return false;">用它 →</a></div>';
  }).join('');
  if(msg) msg.textContent = '共 ' + lines.length + ' 个网络（按信号强弱排序）';
}
async function wifiSaved(){
  const box = $id('wifiSavedBox'), msg = $id('wifiMsg');
  if(msg) msg.textContent = '读取已保存网络…';
  const d = await _sysAct('wifi-saved', []);
  const lines = (d.message || '').split('\n').filter(function(x){ return x.indexOf('|') >= 0; });
  if(!box) return;
  if(!lines.length){
    box.innerHTML = '';
    if(msg) msg.textContent = '';
    return;
  }
  box.innerHTML = '<div class="hint" style="margin-bottom:6px">已保存的 WiFi（重启后按“自动连接 / 优先级”选择接入）</div>'
    + lines.map(function(ln){
        const f = ln.split('|');
        const name = String(f[0] || '').trim(), ssid = String(f[1] || '').trim(),
              ac = String(f[2] || '').trim(), pr = String(f[3] || '').trim(), km = String(f[4] || '').trim();
        const n1 = name.replace(/'/g, "\\'"), s1 = ssid.replace(/'/g, "\\'");
        return '<div style="display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--border);padding:5px 2px;flex-wrap:wrap">'
          + '<b style="min-width:140px;color:#fff">' + _sysEsc(ssid) + '</b>'
          + '<span class="hint" style="min-width:170px">连接名：' + _sysEsc(name) + '</span>'
          + '<span class="hint">' + _sysEsc(km || '-') + '　优先级 ' + _sysEsc(pr || '0') + '　自动连接：<b>' + (ac === 'yes' ? '开' : '关') + '</b></span>'
          + '<span style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap">'
          + '<button class="btn btn-sm" onclick="wifiReconnect(\'' + n1 + '\')">连接</button>'
          + '<button class="btn btn-sm" onclick="wifiAutoconnect(\'' + n1 + '\',\'' + (ac === 'yes' ? 'no' : 'yes') + '\')">' + (ac === 'yes' ? '关自启' : '开自启') + '</button>'
          + '<button class="btn btn-sm" onclick="wifiPriority(\'' + n1 + '\')">优先级</button>'
          + '<button class="btn btn-sm" style="background:#7f1d1d;color:#fee2e2" onclick="wifiForget(\'' + n1 + '\',\'' + s1 + '\')">忘记</button>'
          + '</span></div>';
      }).join('');
  if(msg) msg.textContent = '共 ' + lines.length + ' 个已保存网络';
}
function sysUse(ssid){ _wifiFill(ssid); }
async function wifiConnect(){
  const ssid = (($id('wifiSsid') || {}).value || '').trim();
  const pw = (($id('wifiPw') || {}).value || '');
  const hb = $id('wifiHidden');
  const hid = (hb && hb.checked) ? '1' : '0';
  if(!ssid){ toast('请先填写 SSID', 'error'); return; }
  if(!confirm('连接 WiFi「' + ssid + '」？\n如果你正通过当前 WiFi 访问本页，切换时可能短暂断线。')) return;
  const m = $id('wifiConnMsg');
  if(m) m.textContent = '连接中…（最多 45 秒）';
  const fd = new FormData();
  fd.append('ssid', ssid); fd.append('password', pw); fd.append('hidden', hid);
  try{
    const d = await (await fetch('/api/sys/wifi/connect', {method:'POST', body:fd, credentials:'same-origin'})).json();
    if(m) m.textContent = d.message || '';
    toast(d.ok ? ('已连接 ' + ssid) : ('连接失败：' + (d.message || '')), d.ok ? 'ok' : 'error');
    setTimeout(function(){ wifiStatusShow(); wifiSaved(); }, 2500);
  }catch(e){ if(m) m.textContent = '请求失败：' + e; toast('连接失败：' + e, 'error'); }
}
async function wifiDisconnect(){
  if(!confirm('断开当前 WiFi？（若你正通过 WiFi 访问本页面会立即断线）')) return;
  const d = await _sysAct('wifi-disconnect', []);
  toast(d.message || '已断开', d.ok ? 'ok' : 'error');
  setTimeout(wifiStatusShow, 2000);
}
async function wifiRadioToggle(){
  const rb = $id('wifiRadioBtn');
  const isOff = rb && /打开/.test(rb.textContent);
  if(!isOff && !confirm('关闭 WiFi 射频？（之后只能通过网线访问本页面）')) return;
  const d = await _sysAct('wifi-radio', [isOff ? 'on' : 'off']);
  toast(d.message || '已执行', d.ok ? 'ok' : 'error');
  setTimeout(wifiStatusShow, 2500);
}
async function wifiForget(name, ssid){
  if(!confirm('忘记 WiFi「' + (ssid || name) + '」？\n删除已保存的配置（当前连接会保留到重启）')) return;
  const d = await _sysAct('wifi-forget', [name]);
  toast(d.message || '已删除', d.ok ? 'ok' : 'error');
  setTimeout(function(){ wifiSaved(); wifiStatusShow(); }, 1500);
}
async function wifiReconnect(name){
  toast('正在连接 ' + name + ' …');
  const d = await _sysAct('wifi-reconnect', [name]);
  toast(d.message || '已下发', d.ok ? 'ok' : 'error');
  setTimeout(wifiStatusShow, 3000);
}
async function wifiAutoconnect(name, v){
  const d = await _sysAct('wifi-autoconnect', [name, v]);
  toast(d.message || '已设置', d.ok ? 'ok' : 'error');
  setTimeout(wifiSaved, 1200);
}
async function wifiPriority(name){
  const v = prompt('设置「' + name + '」自动连接优先级（数字越大越优先，如 10）', '10');
  if(v === null) return;
  const d = await _sysAct('wifi-priority', [name, String(v).trim()]);
  toast(d.message || '已设置', d.ok ? 'ok' : 'error');
  setTimeout(wifiSaved, 1200);
}
function sysWifiConnect(){ wifiConnect(); }
function sysWifiDisconnect(){ wifiDisconnect(); }
async function sysAction(a){
  if(a === 'reboot' && !confirm('确定重启设备？')) return;
  if(a === 'shutdown' && !confirm('确定关机？需要手动上电才能恢复。')) return;
  if(a === 'restart-hotspot' && !confirm('重启全部热点服务（MMDVM-Multi + 各通道 + 网关，约 30 秒）？')) return;
  toast('正在执行…');
  try{
    const d = await (await fetch('/api/sys/action', {method:'POST', credentials:'same-origin',
      headers:{'Content-Type':'application/json'}, body: JSON.stringify({action:a})})).json();
    toast(d.message || (d.ok ? '已执行' : '执行失败'), d.ok ? 'ok' : 'error');
  }catch(e){ toast('执行失败：' + e, 'error'); }
}
/* ==================== 系统更新（apt）==================== */
function _aptOut(t){
  const o = $id('aptOut');
  if(!o) return;
  o.textContent = t || '';
  o.style.display = 'block';
  o.scrollTop = o.scrollHeight;
}
async function aptStatLoad(){
  const box = $id('aptStat');
  const d = await _sysAct('sys-upgrade-count', []);
  const t = d.message || '';
  const m = /^UPGRADABLE=(\d+)/m.exec(t);
  const cnt = m ? Number(m[1]) : -1;
  if(!box) return;
  const inst = t.split('\n').filter(function(x){ return /^\s*Inst /.test(x); }).slice(0, 6)
    .map(function(x){ return x.trim().split(' ')[1]; }).join('、');
  box.innerHTML = '可升级软件包：<b style="color:' + ((cnt > 0) ? '#fbbf24' : '#34d399') + '">'
    + (cnt >= 0 ? cnt : '?') + '</b> 个'
    + ((cnt === 0) ? '（系统已是最新）' : '');
  try{ if(window.__repErr) window.__repErr('apt', 'len=' + (box.innerHTML || '').length + ' text=' + (box.textContent || '').slice(0, 60), location.href, '', ''); }catch(e){}
}
async function aptCheck(){
  const msg = $id('aptMsg');
  if(msg) msg.textContent = '正在 apt-get update …（10-60 秒，请稍等）';
  const d = await _sysAct('sys-update-check', []);
  _aptOut(d.message || '(无输出)');
  if(msg) msg.textContent = '检查完成';
  aptStatLoad();
}
async function aptUpgrade(){
  if(!confirm('开始系统更新（apt upgrade）？\n· 后台执行，可以关闭网页\n· 通常 5-20 分钟\n· 更新期间热点服务照常工作\n· 完成后建议重启设备')) return;
  const d = await _sysAct('sys-update', []);
  toast(d.message || (d.ok ? '已开始' : '启动失败'), d.ok ? 'ok' : 'error');
  setTimeout(aptStatus, 3000);
}
async function aptClean(){
  if(!confirm('清理无用依赖与软件源缓存？（安全操作，可释放磁盘）')) return;
  const d = await _sysAct('sys-update-clean', []);
  toast(d.message || '已开始', d.ok ? 'ok' : 'error');
  setTimeout(aptStatus, 2500);
}
async function aptStatus(){
  const d = await _sysAct('sys-update-status', []);
  _aptOut(d.message || '(无输出)');
  aptStatLoad();
}
window.aptStatLoad = aptStatLoad; window.aptCheck = aptCheck; window.aptUpgrade = aptUpgrade;
window.aptClean = aptClean; window.aptStatus = aptStatus;

/* ==================== 固件更新（引擎 MMDVM-Multi / 引导固件）==================== */
function _fwOut(t){
  const o = $id('fwOut');
  if(!o) return;
  o.textContent = t || '';
  o.style.display = 'block';
  o.scrollTop = o.scrollHeight;
}
function _kvGet(t, k){ const m = new RegExp('^' + k + '=(.*)$', 'm').exec(t || ''); return m ? m[1].trim() : ''; }
function _mb(n){ n = Number(n) || 0; return n > 0 ? ((n / 1048576).toFixed(2) + ' MB') : '-'; }
async function fwInfoLoad(){
  const box = $id('fwStat'), inp = $id('fwUrl');
  const d = await _sysAct('fw-info', []);
  const t = d.message || '';
  if(inp && !inp.value) inp.value = _kvGet(t, 'URL');
  if(!box) return;
  const svc = _kvGet(t, 'ENGINE_SVC');
  box.innerHTML = '引擎：<b>' + _sysEsc(_kvGet(t, 'ENGINE_DATE') || '-') + '</b>'
    + '　大小 ' + _sysEsc(_mb(_kvGet(t, 'ENGINE_SIZE')))
    + '　服务 <b style="color:' + ((svc === 'active') ? '#34d399' : '#f87171') + '">' + _sysEsc(svc || '-') + '</b><br>'
    + 'MD5：<span style="font-family:Consolas,monospace;color:var(--dim)">' + _sysEsc(_kvGet(t, 'ENGINE_MD5') || '-') + '</span><br>'
    + '配置：版本 ' + _sysEsc(_kvGet(t, 'CFG_VERSION') || '-')
    + '　通道 ' + _sysEsc(_kvGet(t, 'CFG_CHANNELS') || '-')
    + '　模式 ' + _sysEsc(_kvGet(t, 'CFG_MODE') || '-')
    + '<br>发布页：<span style="color:#60a5fa;word-break:break-all">' + _sysEsc(_kvGet(t, 'URL') || '-') + '</span>'

  try{ if(window.__repErr) window.__repErr('fw', 'len=' + (box.innerHTML || '').length + ' svc=' + (_kvGet(t, 'ENGINE_SVC') || '-'), location.href, '', ''); }catch(e){}
}
async function fwSaveUrl(){
  const u = (($id('fwUrl') || {}).value || '').trim();
  if(!/^https?:\/\//i.test(u)){ toast('地址必须以 http:// 或 https:// 开头', 'error'); return; }
  const d = await _sysAct('fw-url', [u]);
  toast(d.message || '已保存', d.ok ? 'ok' : 'error');
  fwInfoLoad();
}
async function fwCheck(){
  const msg = $id('fwMsg');
  if(msg) msg.textContent = '正在检查发布页…';
  const d = await _sysAct('fw-check', []);
  _fwOut(d.message || '(无输出)');
  if(msg) msg.textContent = '检查完成';
}
async function fwDownload(){
  const u = (($id('fwDirect') || {}).value || '').trim();
  if(!/^https?:\/\//i.test(u)){ toast('请先粘贴安装包直链', 'error'); return; }
  if(!confirm('从下面的地址下载安装包到设备 /tmp/mmdvm-fw.pkg ？\n' + u)) return;
  const d = await _sysAct('fw-download', [u]);
  toast(d.message || '已开始下载', d.ok ? 'ok' : 'error');
  setTimeout(fwLog, 5000);
}
async function fwInstallPkg(){
  if(!confirm('安装 /tmp/mmdvm-fw.pkg ？\n会先备份旧引擎，然后替换并自动重启 MMDVM-Multi')) return;
  const d = await _sysAct('fw-install', ['/tmp/mmdvm-fw.pkg']);
  toast(d.message || '已开始安装', d.ok ? 'ok' : 'error');
  setTimeout(fwLog, 5000);
}
async function fwUpload(){
  const inp = $id('fwFile');
  if(!inp || !inp.files || !inp.files.length){ toast('请先选择安装包文件', 'error'); return; }
  const f = inp.files[0];
  if(!confirm('上传安装包到设备？\n文件：' + f.name + '（' + _mb(f.size) + '）\n保存为 /tmp/mmdvm-fw.pkg，之后点「安装已下载/已上传的包」')) return;
  const msg = $id('fwMsg');
  if(msg) msg.textContent = '上传中…（大文件请耐心等待）';
  const fd = new FormData(); fd.append('file', f);
  try{
    const d = await (await fetch('/api/sys/fw/upload', {method:'POST', body:fd, credentials:'same-origin'})).json();
    if(msg) msg.textContent = d.message || '';
    toast(d.ok ? '上传完成' : ('上传失败：' + (d.message || '')), d.ok ? 'ok' : 'error');
  }catch(e){ if(msg) msg.textContent = '上传失败：' + e; toast('上传失败：' + e, 'error'); }
}
async function bootFwStatus(){
  const msg = $id('fwMsg');
  if(msg) msg.textContent = '读取引导固件状态…';
  const d = await _sysAct('boot-fw-status', []);
  _fwOut(d.message || '(无输出)');
  if(msg) msg.textContent = '';
}
async function bootFwUpdate(){
  if(!confirm('更新树莓派引导固件（EEPROM / VL805）？\n· 新固件下次重启后生效\n· 更新过程不要断电')) return;
  const d = await _sysAct('boot-fw-update', []);
  toast(d.message || '已开始', d.ok ? 'ok' : 'error');
  setTimeout(fwLog, 5000);
}
async function fwOnlineCheck(){
  const msg = $id('fwOnlineMsg');
  if(msg) msg.textContent = '正在读取在线固件清单…';
  const d = await _sysAct('fw-check', []);
  _fwOut(d.message || '(无输出)');
  const t = d.message || '';
  const up = _kvGet(t, 'UPDATE_AVAILABLE');
  const latest = _kvGet(t, 'LATEST_VERSION') || '-';
  if(msg) msg.textContent = '检查完成：在线版本 ' + latest + '，' + (up === 'yes' ? '可升级' : (up === 'no' ? '已是最新' : '状态未知'));
  fwInfoLoad();
}
async function fwOnlineUpgrade(){
  if(!confirm('开始在线固件升级？\n· 从 ARCH 站点下载 MMDVM-Multi 固件\n· 自动校验 SHA-256\n· 自动备份旧引擎并重启')) return;
  const msg = $id('fwOnlineMsg');
  if(msg) msg.textContent = '已下发在线升级，正在后台执行…';
  const d = await _sysAct('fw-online-update', []);
  toast(d.message || (d.ok ? '已开始在线升级' : '启动失败'), d.ok ? 'ok' : 'error');
  setTimeout(fwLog, 4000);
}
async function fwLog(){
  const d = await _sysAct('fw-log', ['80']);
  _fwOut(d.message || '(暂无日志)');
  if(typeof fwInfoLoad === 'function' && $id('fwStat')) fwInfoLoad();
}
window.fwInfoLoad = fwInfoLoad; window.fwSaveUrl = fwSaveUrl; window.fwCheck = fwCheck;
window.fwDownload = fwDownload; window.fwInstallPkg = fwInstallPkg; window.fwUpload = fwUpload;
window.bootFwStatus = bootFwStatus; window.bootFwUpdate = bootFwUpdate; window.fwLog = fwLog;

window.sysLoad = sysLoad; window.sysUse = sysUse; window.sysAction = sysAction;
window.wifiStatusShow = wifiStatusShow; window.wifiScan = wifiScan; window.wifiSaved = wifiSaved;
window.wifiConnect = wifiConnect; window.wifiDisconnect = wifiDisconnect;
window.wifiRadioToggle = wifiRadioToggle;
window.wifiForget = wifiForget; window.wifiReconnect = wifiReconnect;
window.wifiAutoconnect = wifiAutoconnect; window.wifiPriority = wifiPriority;
window.netParamsLoad = netParamsLoad; window.netApply = netApply;

/* ==================== 网络参数（IP / 掩码 / 网关 / DNS）==================== */
async function netParamsLoad(){
  const ifc = ($id('netIface') || {}).value || 'eth0';
  const msg = $id('netMsg');
  if(msg) msg.textContent = '读取中…';
  const d = await _sysAct('net-info', [ifc]);
  const t = d.message || '';
  const g = function(k){ const m = new RegExp('^' + k + '=(.*)$', 'm').exec(t); return m ? m[1].trim() : ''; };
  const set = function(id, v){ const e = $id(id); if(e) e.value = v || ''; };
  set('netIp', g('IP'));
  set('netMask', g('MASK') || g('PREFIX'));
  set('netGw', g('GW'));
  set('netDns', (g('DNS') || '').replace(/ /g, ','));
  const m = $id('netMode');
  if(m) m.value = (g('METHOD') === 'auto') ? 'dhcp' : 'static';
  if(msg) msg.textContent = g('CURIP') ? ('当前生效：' + g('CURIP')) : (g('CONN') ? '' : '该网卡暂无配置');
}
async function netApply(){
  const ifc = ($id('netIface') || {}).value || 'eth0';
  const mode = ($id('netMode') || {}).value || 'static';
  const ip = (($id('netIp') || {}).value || '').trim();
  const mask = (($id('netMask') || {}).value || '').trim();
  const gw = (($id('netGw') || {}).value || '').trim();
  const dns = (($id('netDns') || {}).value || '').trim().replace(/\s+/g, '');
  if(mode === 'static' && !ip){ toast('请先填写 IP 地址', 'error'); return; }
  const tip = (mode === 'dhcp')
    ? ('把 ' + ifc + ' 改为「自动获取 (DHCP)」？')
    : ('把 ' + ifc + ' 设为静态地址 ' + ip + ' / ' + (mask || '?') + '，网关 ' + gw + (dns ? ('，DNS ' + dns) : '') + ' ？'
       + '\n\n⚠ 若新地址与你正在使用的电脑不在同一网段，本页面会立即断开，需要用新地址重新访问。');
  if(!confirm(tip)) return;
  const msg = $id('netMsg');
  if(msg) msg.textContent = '应用中…（约 5 秒）';
  const d = await _sysAct('net-set', [ifc, ip, mask, gw, dns, mode]);
  if(msg) msg.textContent = d.ok ? '已应用' : '应用失败';
  alert(d.message || (d.ok ? '已应用' : '失败'));
  setTimeout(netParamsLoad, 2500);
}

window.rfPowerChanged = rfPowerChanged; window.rfPowerSync = rfPowerSync;
window._wifiFill = _wifiFill; window.sysWifiScan = wifiScan; window.sysWifiListConns = wifiSaved;
window.sysWifiConnect = wifiConnect; window.sysWifiDisconnect = wifiDisconnect;

/* ==================== 时间与同步 / 定时重启 / 维护诊断 ==================== */
function _sysOut(t, show){
  const o = $id('sysOut');
  if(!o) return;
  o.textContent = t || '';
  o.style.display = (show === false) ? 'none' : 'block';
  o.scrollTop = 0;
}
function sysOutClear(){ _sysOut('', false); }
async function _sysAct(action, args){
  try{
    const d = await (await fetch('/api/sys/action', {method:'POST', credentials:'same-origin',
      headers:{'Content-Type':'application/json'},
      body: JSON.stringify({action: action, args: args || []})})).json();
    return d || {};
  }catch(e){ return {ok:false, message: '请求失败：' + (e && e.message ? e.message : e)}; }
}
async function sysTimeLoad(){
  const box = $id('sysTime'), sb = $id('sysSched');
  const d = await _sysAct('time-status', []);
  const t = d.message || '';
  const g = function(k){
    const m = new RegExp('^' + k + '=(.*)$', 'm').exec(t);
    return m ? m[1].trim() : '';
  };
  const ntp = g('NTP'), sy = g('SYNCED'), tz = g('TZ'), now = g('NOW');
  if(box){
    box.innerHTML = '当前时间：<b>' + _sysEsc(now || '-') + '</b>　时区：<b>' + _sysEsc(tz || '-') + '</b><br>'
      + 'NTP 自动校时：' + (ntp === 'yes' ? '<b style="color:#34d399">已开启</b>' : '<b style="color:#fbbf24">已关闭</b>')
      + '　同步状态：' + (sy === 'yes' ? '<b style="color:#34d399">已同步</b>' : '<span style="color:#fbbf24">未同步</span>');
  }
  const ti = $id('sysTzInput');
  if(ti && !ti.value && tz) ti.value = tz;
  if(sb){
    const on = (g('SCHED') === 'on');
    sb.innerHTML = on
      ? ('<b style="color:#34d399">已开启</b>　计划：' + _sysEsc(g('SCHED_CAL') || '-')
         + (g('SCHED_NEXT') ? ('　下次：' + _sysEsc(g('SCHED_NEXT'))) : ''))
      : '<span style="color:var(--dim)">未开启（保存后会写入 systemd timer）</span>';
  }
}
async function sysNtp(on){
  const d = await _sysAct('ntp', [on ? 'on' : 'off']);
  toast(d.ok ? ('NTP 已' + (on ? '开启' : '关闭')) : ('操作失败：' + (d.message || '')), d.ok ? 'ok' : 'error');
  sysTimeLoad();
}
async function sysSetTz(){
  const z = (($id('sysTzInput') || {}).value || '').trim();
  if(!z){ toast('请填写时区，例如 Asia/Shanghai', 'error'); return; }
  const d = await _sysAct('tz', [z]);
  toast(d.ok ? ('时区已设为 ' + z) : ('设置失败：' + (d.message || '')), d.ok ? 'ok' : 'error');
  _sysOut(d.message); sysTimeLoad();
}
async function sysTimeSync(){
  toast('正在同步时间…');
  const d = await _sysAct('timesync', []);
  _sysOut(d.message);
  toast(d.ok ? '已请求重新同步' : '同步失败', d.ok ? 'ok' : 'error');
  sysTimeLoad();
}
async function sysSchedSave(){
  const hh = (($id('sysSchedTime') || {}).value || '').trim();
  const md = (($id('sysSchedMode') || {}).value || 'daily');
  if(!/^([01]?\d|2[0-3]):[0-5]\d$/.test(hh)){ toast('时刻格式应为 HH:MM（24 小时制），如 04:00', 'error'); return; }
  const d = await _sysAct('sched-reboot', ['on', hh, md]);
  toast(d.ok ? ('已开启定时重启：' + (md === 'daily' ? '每天 ' : '每周一 ') + hh)
             : ('设置失败：' + (d.message || '')), d.ok ? 'ok' : 'error');
  _sysOut(d.message); sysTimeLoad();
}
async function sysSchedOff(){
  if(!confirm('关闭定时重启？')) return;
  const d = await _sysAct('sched-reboot', ['off']);
  toast(d.ok ? '定时重启已关闭' : ('关闭失败：' + (d.message || '')), d.ok ? 'ok' : 'error');
  _sysOut(d.message); sysTimeLoad();
}
async function sysBackup(){
  _sysOut('正在备份配置（打包 /home/pi 下配置与服务文件）…');
  const d = await _sysAct('backup', []);
  _sysOut(d.message || '(无输出)');
  toast(d.ok ? '备份完成（见输出里的文件路径）' : '备份失败', d.ok ? 'ok' : 'error');
}
async function sysLogs(){
  const svc = (($id('sysLogSvc') || {}).value || 'mmdvm-multi');
  const cnt = String((($id('sysLogN') || {}).value || '60')).replace(/[^0-9]/g, '') || '60';
  _sysOut('正在读取 ' + svc + ' 日志…');
  const d = await _sysAct('logs', [svc, cnt]);
  _sysOut('====== ' + svc + ' 最近 ' + cnt + ' 行 ======\n' + (d.message || '(无输出)'));
}
async function sysPing(){
  const h = (($id('sysPingHost') || {}).value || '').trim() || '223.5.5.5';
  _sysOut('正在 ping ' + h + ' …');
  const d = await _sysAct('ping', [h]);
  _sysOut('ping ' + h + '\n' + (d.message || '(无输出)'));
}
window.sysTimeLoad = sysTimeLoad; window.sysNtp = sysNtp; window.sysSetTz = sysSetTz;
window.sysTimeSync = sysTimeSync; window.sysSchedSave = sysSchedSave; window.sysSchedOff = sysSchedOff;
window.sysBackup = sysBackup; window.sysLogs = sysLogs; window.sysPing = sysPing;
window.sysOutClear = sysOutClear;

/* ==================== 通话记录（各通道最近 20 条）==================== */
function _flagCell(call){
  try{
    var c = (typeof lookupCallsignCountry === 'function') ? lookupCallsignCountry(call) : null;
    if(!c) return '<span style="color:var(--dim)">-</span>';
    return '<span title="' + _sysEsc(c.name) + '">' + c.flag + ' ' + _sysEsc(c.name) + '</span>';
  }catch(e){ return '<span style="color:var(--dim)">-</span>'; }
}
function _berCell(r){
  var b = String(r.ber == null ? '' : r.ber).trim();
  if(b && b !== '-'){
    var v = parseFloat(b), cls = 'good';
    if(v >= 5) cls = 'bad'; else if(v >= 2) cls = 'warn';
    return '<span class="ber ' + cls + '">' + _sysEsc(b) + '</span>';
  }
  var l = String(r.loss == null ? '' : r.loss).trim();
  if(l) return '<span style="color:var(--dim);font-size:11.5px" title="该模式日志没有 BER，只有丢包率">丢包 ' + _sysEsc(l) + '</span>';
  return '<span style="color:var(--dim)">-</span>';
}
function _durCell(r){
  var d = String(r.duration == null ? '' : r.duration).trim();
  if(d && d !== '通话中') return '<span style="font-family:Consolas,monospace">' + _sysEsc(d) + '</span>';
  var el = Number(r.elapsed || 0);
  if(el > 0) return '<span style="font-family:Consolas,monospace;color:#34d399" title="进行中，已用时长">▸ ' + el + 's</span>';
  return '<span style="color:var(--dim)">-</span>';
}
function _callRow(r, fresh){
  const srcColor = (r.src === 'RF') ? '#34d399' : '#60a5fa';
  const mm = /(S[12])$/.exec(String(r.mode || ''));
  const slot = mm ? ('<span class="qso-slot">' + mm[1] + '</span>') : '';
  const rssi = String(r.rssi || '').trim();
  return '<tr' + (fresh ? ' class="new"' : '') + '>'
    + '<td class="mono">' + _sysEsc(r.time || '--:--:--') + '</td>'
    + '<td><b>' + _sysEsc(r.call || '-') + '</b>' + slot + '</td>'
    + '<td>' + _flagCell(r.call) + '</td>'
    + '<td style="color:var(--muted)">' + _sysEsc(r.target || '-') + '</td>'
    + '<td>' + _durCell(r) + '</td>'
    + '<td>' + _berCell(r) + '</td>'
    + '<td style="color:' + srcColor + ';font-weight:800"' + (rssi ? (' title="RSSI ' + _sysEsc(rssi) + '"') : '') + '>'
    + _sysEsc(r.src || '-') + '</td>'
    + '</tr>';
}
var _callsSeen = {};      // 各通道上次看到的最新时间，用于标 NEW
var _callsTimer = null;   // 自动刷新定时器
var _callsAuto = true;
async function callsLoad(quiet){
  const box = $id('callsBox'), msg = $id('callsMsg');
  if(msg && !quiet) msg.textContent = '读取中…';
  try{
    const [d, q] = await Promise.all([
      fetch('/api/mc/channels?light=1', {credentials:'same-origin'}).then(function(r){ return r.json(); }),
      fetch('/api/qso', {credentials:'same-origin'}).then(function(r){ return r.json(); })
    ]);
    const chs = d.channels || [];
    const byCh = {};
    ((q && q.qso) || []).forEach(function(item){
      var m = /^CH(\d+)$/.exec(String(item.ch || ''));
      if(!m) return;
      var n = Number(m[1]);
      (byCh[n] = byCh[n] || []).push(item);
    });
    if(!box) return;
    if(!chs.length){
      box.innerHTML = '<div class="card"><div class="qso-empty">没有启用的通道</div></div>';
      return;
    }
    box.innerHTML = chs.map(function(c){
      const recs = (byCh[Number(c.n)] || []).slice(0, 30);
      const seen = _callsSeen[c.n] || '';
      _callsSeen[c.n] = recs.length ? String(recs[0].time || '') : '';
      const head = '通道 ' + c.n + (c.mode ? '　' + c.mode : '')
        + (c.rx ? '　' + (Number(c.rx) / 1e6).toFixed(4) + ' MHz' : '');
      const rows = recs.length
        ? ('<table class="qso-table"><thead><tr>'
           + '<th>时间</th><th>呼号</th><th>国家/地区</th><th>目标</th>'
           + '<th>时长</th><th>误码率</th><th>来源</th></tr></thead><tbody>'
           + recs.map(function(r){ return _callRow(r, !!seen && String(r.time || '') > seen); }).join('')
           + '</tbody></table>')
        : '<div class="qso-empty">暂无通联记录</div>';
      return '<div class="card" style="padding-bottom:0">'
        + '<div class="card-head" style="margin-bottom:10px"><div class="card-head-left"><div>'
        + '<div class="card-title">' + head + '</div>'
        + '<div class="card-sub">最近 ' + recs.length + ' 条（新 → 旧 · 时间=本机时区 · 误码率仅射频接收时有效）</div>'
        + '</div></div></div><div class="qso-wrap">' + rows + '</div></div>';
    }).join('');
    if(msg) msg.textContent = '更新于 ' + new Date().toLocaleTimeString();
    try{
      if(window.__repErr) window.__repErr('calls', 'channels=' + chs.length
        + ' boxlen=' + ((box && box.innerHTML) ? box.innerHTML.length : 0)
        + ' rows=' + ((box && box.querySelectorAll) ? box.querySelectorAll('div').length : -1),
        location.href, '', '');
    }catch(e2){}
  }catch(e){
    if(box) box.innerHTML = '<div class="card"><div class="qso-empty">读取失败：' + _sysEsc(e.message || e)
      + '<br><br>请按 Ctrl+F5 强制刷新；若仍失败，检查 mmdvm-web 服务状态。</div></div>';
    if(msg) msg.textContent = '';
  }
}
window.callsLoad = callsLoad;
function callsApplyAuto(){
  if(_callsTimer){ clearInterval(_callsTimer); _callsTimer = null; }
  const b = $id('callsAutoBtn');
  if(b) b.textContent = _callsAuto ? '⏱ 自动刷新：开' : '⏱ 自动刷新：关';
  if(!_callsAuto) return;
  _callsTimer = setInterval(function(){
    const p = $id('page-calls');
    if(p && p.style.display !== 'none') callsLoad(true);
  }, 10000);
}
function callsStopAuto(){ if(_callsTimer){ clearInterval(_callsTimer); _callsTimer = null; } }
function callsToggleAuto(){
  _callsAuto = !_callsAuto;
  callsApplyAuto();
  if(_callsAuto) callsLoad(true);
  toast(_callsAuto ? '已开启自动刷新（每 10 秒）' : '已关闭自动刷新', 'ok');
}
window.callsApplyAuto = callsApplyAuto; window.callsStopAuto = callsStopAuto;
window.callsToggleAuto = callsToggleAuto;

/* 模块 / 开机 联动到表单隐藏域 */
(function () {
  var mod = document.getElementById('spDstarMod');
  if (mod) {
    mod.addEventListener('change', function () {
      var hm = document.getElementById('dstar_module'); if (hm) hm.value = mod.value;
      var hr = document.getElementById('dstar_reflector_full');
      var cr = document.getElementById('dstar_reflector');
      if (hr && cr && cr.value) hr.value = cr.value + ' ' + mod.value;
    });
  }
  var st = document.getElementById('spDstarStartup');
  if (st) {
    st.addEventListener('change', function () {
      var hs = document.getElementById('dstar_atstartup'); if (hs) hs.value = st.value;
      document.querySelectorAll('input[name="dstar_startup"]').forEach(function (r) {
        r.checked = (r.value === st.value);
      });
      var hr = document.getElementById('dstar_reflector_full');
      var cr = document.getElementById('dstar_reflector');
      var md = document.getElementById('spDstarMod');
      if (hr && cr && cr.value && md) hr.value = cr.value + ' ' + md.value;
    });
  }
})();

</script>



<!-- ===== 射频模式配置 页面脚本（独立块，隔离） ===== -->
<script>

/* ==================== 频率输入习惯（与主控制台同一套） ====================
   输入 439.500 -> 439500000 Hz ；输入 439.5125 -> 439512500 Hz
   显示 439.500 / 439.5125。主控制台已有 window.FREQ_HZ / FREQ_MHZ，这里做兜底。 */
var RFM_HZ = (typeof window !== 'undefined' && window.FREQ_HZ) ? window.FREQ_HZ : function(v){
  if(v===null||v===undefined) return 0;
  var s=String(v).trim().replace(/[^0-9.]/g,'');
  if(s==='') return 0;
  var f=parseFloat(s); if(!isFinite(f)||f<=0) return 0;
  var mhz=(s.indexOf('.')>=0||f<100000);
  return Math.round(mhz? f*1000000 : f);
};
var RFM_MHZ = (typeof window !== 'undefined' && window.FREQ_MHZ) ? window.FREQ_MHZ : function(hz){
  hz=parseInt(hz,10); if(!isFinite(hz)||hz<=0) return '';
  var d=(hz%12500===0 && hz%25000!==0)?4:3;
  return (hz/1000000).toFixed(d);
};

var RFM_MODES = ['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];
var RFM_COL = {'DMR':'#0ea5e9','D-STAR':'#8b5cf6','YSF':'#f97316','NXDN':'#2563eb',
               'P25':'#e11d48','FM':'#10b981','POCSAG':'#f59e0b','NONE':'#64748b'};
var RFM_MAX = 6;          /* ★ 只用 6 路通道（第 7 路用不上，已去掉） */
var RFM_PLAN = null;      /* 最近一次 /api/mc/plan 的结果 */
var RFM_CHS = [];         /* 最近一次 /api/mc/channels 的通道表 */
var RFM_HW = {};          /* 当前设备硬件型号与通道上限 */
var RFM_ID = '';          /* 台站 DMR Id（保存 RF 时要回传，避免被清空） */
var RFM_RX = '', RFM_TX = '';   /* MMDVM-Host 里的频率原值（回传，不改动） */

function rfmQ(id){ return document.getElementById(id); }
function rfmSvg(m){
  var M=String(m||'').toUpperCase(), col=RFM_COL[M]||'#64748b', g='';
  if(M==='DMR') g='<rect x="9" y="15" width="22" height="4" rx="2" fill="#fff"/><rect x="9" y="23" width="14" height="4" rx="2" fill="#fff" opacity=".75"/>';
  else if(M==='D-STAR') g='<path d="M20 10l2.6 6.1 6.4.6-4.9 4.3 1.5 6.4L20 24l-5.6 3.4 1.5-6.4-4.9-4.3 6.4-.6z" fill="#fff"/>';
  else if(M==='YSF') g='<path d="M20 10l8 10-8 10-8-10z" fill="#fff"/>';
  else if(M==='NXDN') g='<path d="M13 27V13h4.2l5.6 8V13H27v14h-4.2l-5.6-8v8z" fill="#fff"/>';
  else if(M==='P25') g='<path d="M20 10l9 3.4v7.1c0 5-3.7 8.2-9 9.5-5.3-1.3-9-4.5-9-9.5v-7.1z" fill="#fff"/>';
  else if(M==='FM') g='<path d="M9 20c2.6-6 5.2-6 7.8 0s5.2 6 7.8 0 5.2-6 5.4 0" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>';
  else g='<circle cx="20" cy="20" r="6" fill="#fff" opacity=".7"/>';
  return '<svg viewBox="0 0 40 40" width="28" height="28" xmlns="http://www.w3.org/2000/svg" style="flex:0 0 28px"><rect x="2" y="2" width="36" height="36" rx="9" fill="'+col+'"/>'+g+'</svg>';
}
function rfmPost(url, body, cb){
  fetch(url, {method:'POST', credentials:'same-origin',
    headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})})
    .then(function(r){ return r.json(); })
    .then(function(d){ cb(d); })
    .catch(function(e){ if(typeof toast==='function') toast('失败：'+e,false); });
}
function rfmMsg(id, txt, ok){
  var e=rfmQ(id); if(e) e.textContent=txt||'';
  if(txt && typeof toast==='function') toast(txt, ok);
}
/* ==================== 硬件网格（与后端 channel-setup.py 一致） ====================
   ch1=中心, ch2=+25k, ch3=-25k, ch4=-50k, ch5=+75k …（SampleRate/25000 = PFB bin 数） */
function rfmGridStep(i, sr){
  sr = parseInt(sr,10) || parseInt((RFM_PLAN&&RFM_PLAN.sample_rate)||0,10) || 300000;
  var n = Math.max(1, Math.round(sr/25000));
  var mf = Math.max(1, Math.min(Math.floor(n/2)-1, 4));
  var p = i-1, b = (p<mf) ? p : (n-1)-(p-mf);
  return (b*2<=n) ? b : b-n;
}
function rfmSpacingHz(){
  var v = parseFloat((rfmQ('rfmGap')||{}).value);
  return isFinite(v)&&v>0 ? Math.round(v*1000) : 25000;
}
function rfmCenterRx(){ return RFM_HZ((rfmQ('rfmCrx')||{}).value); }
function rfmCenterTx(){ return RFM_HZ((rfmQ('rfmCtx')||{}).value); }
function rfmChanRx(i){ return rfmCenterRx() + rfmGridStep(i)*rfmSpacingHz(); }
function rfmChanTx(i){ return rfmCenterTx() + rfmGridStep(i)*rfmSpacingHz(); }
function rfmNChSel(){
  var v = parseInt((rfmQ('rfmNCh')||{}).value,10);
  return (isFinite(v)&&v>=1) ? Math.min(RFM_MAX, v) : 6;
}

/* ==================== 台站参数（呼号 / 功率 / 增益） ==================== */
function rfmFlat(d){
  var flat={};
  Object.keys(d||{}).forEach(function(k){
    var v=d[k];
    if(v && typeof v==='object'){ Object.keys(v).forEach(function(k2){ flat[k2]=v[k2]; flat[String(k2).toLowerCase()]=v[k2]; }); }
    else { flat[k]=v; }
  });
  return flat;
}
function rfmPick(flat){
  for(var i=1;i<arguments.length;i++){
    var k=arguments[i], lk=String(k).toLowerCase();
    if(flat[k]!==undefined && flat[k]!==null && String(flat[k])!=='') return flat[k];
    if(flat[lk]!==undefined && flat[lk]!==null && String(flat[lk])!=='') return flat[lk];
  }
  return '';
}
function rfmLoadStation(){
  fetch('/api/config/RF', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    var f=rfmFlat(d);
    var c=rfmPick(f,'Callsign','callsign');
    if(c && c!=='NOCALL' && rfmQ('rfmCall')) rfmQ('rfmCall').value=String(c).toUpperCase();
    var p=rfmPick(f,'Power','power');
    if(p!=='' && rfmQ('rfmPower')) rfmQ('rfmPower').value=(String(p).trim()==='0'?'0':'7');
    var rx=rfmPick(f,'RxGain','rxgain');
    if(rx!=='' && rfmQ('rfmRxGain')) rfmQ('rfmRxGain').value=String(rx);
    var tx=rfmPick(f,'TxGain','txgain');
    if(tx!=='' && rfmQ('rfmTxGain')) rfmQ('rfmTxGain').value=String(tx);
    /* 缓存台站 Id 与 MMDVM-Host 频率原值：保存时原样回传，不动无关配置 */
    RFM_ID = String(rfmPick(f,'Id','id')||'');
    RFM_RX = String(rfmPick(f,'RXFrequency','rxfreq')||'');
    RFM_TX = String(rfmPick(f,'TXFrequency','txfreq')||'');
  }).catch(function(){});
}
function rfmPowerChanged(){
  var sel=rfmQ('rfmPower'), tg=rfmQ('rfmTxGain');
  if(!sel||!tg) return;
  tg.value = (String(sel.value).trim()==='0') ? 10 : 30;
}
function rfmSaveStation(){
  var call=String((rfmQ('rfmCall')||{}).value||'').trim().toUpperCase();
  var p=(rfmQ('rfmPower')||{}).value, rx=(rfmQ('rfmRxGain')||{}).value, tx=(rfmQ('rfmTxGain')||{}).value;
  if(!call){ rfmMsg('rfmStMsg','请先填写本机呼号', false); return; }
  if(call==='NOCALL'){ rfmMsg('rfmStMsg','呼号不能是 NOCALL', false); return; }
  rfmMsg('rfmStMsg','正在保存…');
  /* ★ 接口是 /api/save/RF（大写！），字段名与页面表单一致：
     callsign / id / rxfreq / txfreq / duplex / power / rxgain / txgain
     —— id 与频率用读回来的原值回传，不改动无关配置 */
  rfmPost('/api/save/RF',{callsign:call,
                          id: RFM_ID || String((RFM_PLAN&&RFM_PLAN.dmr_id)||''),
                          rxfreq: RFM_RX, txfreq: RFM_TX, duplex:'1',
                          power:String(p), rxgain:String(rx), txgain:String(tx)}, function(d){
    var ok=!!(d&&d.ok!==false);
    var t=String((d&&(d.message||d.out||d.error))||'');
    if(!ok){ rfmMsg('rfmStMsg','❌ '+t.slice(0,80), false); return; }
    var body=rfmPlanPayload(call);      /* 呼号同时同步到各网关（仅保存，不重启） */
    if(!body){ rfmMsg('rfmStMsg','✅ 呼号/功率/增益已保存'); return; }
    rfmPost('/api/mc/plan-save', body, function(r2){
      var ok2=!!(r2&&r2.ok!==false);
      rfmMsg('rfmStMsg', ok2? '✅ 已保存，并同步到各网关'
                            : '⚠ 已保存，但网关呼号同步失败：'+String((r2&&(r2.out||r2.error))||'').slice(0,60), ok2);
      if(RFM_PLAN) RFM_PLAN.callsign=call;
    });
  });
}
/* sampling: 采样率随通道数自动切 —— 1-5 路 150k / 6-11 路 300k（硬件只有这两档）*/
/* ==================== 信道规划：读取 / 渲染 ==================== */
function rfmFillNCh(n){
  var sel=rfmQ('rfmNCh'); if(!sel) return;
  var h='';
  for(var i=1;i<=RFM_MAX;i++){
    h+='<option value="'+i+'"'+(i===n?' selected':'')+'>'+i+' 路通道'+(i===1?'（单通道）':'（同时收发）')+'</option>';
  }
  sel.innerHTML=h;
}
function rfmPlanLoad(){
  fetch('/api/mc/plan', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false){
      var bx=rfmQ('rfmRows');
      if(bx) bx.innerHTML='<div class="sp-empty">读取规划失败：'+String((d&&(d.out||d.error))||'')+'</div>';
      return;
    }
    RFM_PLAN=d;
    var n=parseInt(d.channels||1,10)||1;
    rfmFillNCh(n);
    if(rfmQ('rfmCrx')) rfmQ('rfmCrx').value=RFM_MHZ(d.center_rx)||RFM_MHZ(434450000);
    if(rfmQ('rfmCtx')) rfmQ('rfmCtx').value=RFM_MHZ(d.center_tx)||RFM_MHZ(439450000);
    if(rfmQ('rfmGap')){
      var sp=parseInt(d.spacing||25000,10);
      rfmQ('rfmGap').value=(sp<20000)?'12.5':'25';
    }
    if(rfmQ('rfmCall') && d.callsign && !rfmQ('rfmCall').value) rfmQ('rfmCall').value=String(d.callsign).toUpperCase();
    rfmRenderRows();
    var m=rfmQ('rfmPlanMsg'); if(m) m.textContent='';
  }).catch(function(e){ if(typeof toast==='function') toast('读取规划失败：'+e,false); });
}
function rfmRenderRows(){
  var box=rfmQ('rfmRows'); if(!box) return;
  var n=rfmNChSel();
  var plan={}; ((RFM_PLAN&&RFM_PLAN.list)||[]).forEach(function(c){ plan[parseInt(c.n,10)]=c; });
  var h='<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px">';
  for(var i=1;i<=RFM_MAX;i++){
    var c=plan[i]||{n:i,mode:'NONE'};
    var on=(i<=n);
    var opts=RFM_MODES.map(function(m){
      return '<option value="'+m+'"'+(String(c.mode||'NONE').toUpperCase()===m?' selected':'')+'>'+m+'</option>';
    }).join('');
    h+='<div class="fld" data-n="'+i+'" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2);'+(on?'':'opacity:.45')+'">'
      +   '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">'
      +     '<span style="width:8px;height:8px;border-radius:9px;flex:0 0 8px;background:'+(on?'var(--green2)':'#64748b')+'"></span>'
      +     '<b style="font-size:13.5px;color:#7dd3fc">通道 '+i+'</b>'
      +     (on?''+'<span class="hint">已启用</span>':'<span class="hint">未启用</span>')
      +     '<span style="margin-left:auto;display:flex;align-items:center;gap:7px;color:#fff;font-weight:800;font-size:13.5px">'
      +     rfmSvg(c.mode)+String(c.mode||'NONE')+'</span>'
      +   '</div>'
      +   '<div style="display:flex;gap:7px;align-items:center;margin-bottom:7px">'
      +     '<span class="hint" style="flex:0 0 26px">RX</span>'
      +     '<input class="rfm-rx" type="text" inputmode="decimal" value="'+RFM_MHZ(rfmChanRx(i))+'" style="flex:1;min-width:0" onchange="rfmRowEdited('+i+')">'
      +     '<span class="rfm-fq hint" style="flex:0 0 96px;text-align:right">'+RFM_MHZ(rfmChanRx(i))+'</span>'
      +   '</div>'
      +   '<div style="display:flex;gap:7px;align-items:center;margin-bottom:9px">'
      +     '<span class="hint" style="flex:0 0 26px">TX</span>'
      +     '<input class="rfm-tx" type="text" inputmode="decimal" value="'+RFM_MHZ(rfmChanTx(i))+'" style="flex:1;min-width:0" onchange="rfmRowEdited('+i+')">'
      +     '<span class="rfm-fq hint" style="flex:0 0 96px;text-align:right">'+RFM_MHZ(rfmChanTx(i))+'</span>'
      +   '</div>'
      +   '<div style="display:flex;gap:6px;align-items:center">'
      +     '<select class="rfm-mode" style="flex:1;min-width:0"'+(on?'':' disabled')+'>'+opts+'</select>'
      +     '<button class="btn btn-ghost btn-sm" onclick="rfmRowGrid('+i+')" title="按中心频率 + 硬件网格重算">按间距算</button>'
      +   '</div>'
      + '</div>';
  }
  h+='</div>';
  box.innerHTML=h;
  rfmPlanFreqs();
}
function rfmPlanFreqs(){
  var box=rfmQ('rfmRows'); if(!box) return;
  var n=rfmNChSel();
  var cards=box.querySelectorAll('.fld[data-n]');
  Array.prototype.forEach.call(cards,function(cd){
    var i=parseInt(cd.getAttribute('data-n'),10), on=(i<=n);
    cd.style.opacity = on? '' : '.45';
    var r=cd.querySelector('.rfm-rx'), t=cd.querySelector('.rfm-tx');
    var md=cd.querySelector('.rfm-mode'); if(md) md.disabled=!on;
    var fq=cd.querySelectorAll('.rfm-fq');
    if(r) r.value=RFM_MHZ(rfmChanRx(i));
    if(t) t.value=RFM_MHZ(rfmChanTx(i));
    if(fq.length>=2){ fq[0].textContent=RFM_MHZ(rfmChanRx(i)); fq[1].textContent=RFM_MHZ(rfmChanTx(i)); }
  });
  if(rfmQ('rfmCrxHz')) rfmQ('rfmCrxHz').textContent='= '+rfmCenterRx()+' Hz';
  if(rfmQ('rfmCtxHz')) rfmQ('rfmCtxHz').textContent='= '+rfmCenterTx()+' Hz';
  rfmBanner();
}
function rfmCenterTyped(){ rfmPlanFreqs(); }
function rfmNChChanged(){ rfmRenderRows(); }
function rfmRowGrid(i){
  var cd=document.querySelector('#rfmRows .fld[data-n="'+i+'"]'); if(!cd) return;
  var r=cd.querySelector('.rfm-rx'), t=cd.querySelector('.rfm-tx');
  if(r) r.value=RFM_MHZ(rfmChanRx(i));
  if(t) t.value=RFM_MHZ(rfmChanTx(i));
  rfmPlanFreqs();
}
/* 用户改某一路 -> 以该路为准反推中心频率，其它通道按网格跟着走（与后端算法一致） */
function rfmRowEdited(i){
  var cd=document.querySelector('#rfmRows .fld[data-n="'+i+'"]'); if(!cd) return;
  var st=rfmGridStep(i), sp=rfmSpacingHz();
  var r=RFM_HZ((cd.querySelector('.rfm-rx')||{}).value);
  var t=RFM_HZ((cd.querySelector('.rfm-tx')||{}).value);
  if(r>0 && rfmQ('rfmCrx')) rfmQ('rfmCrx').value=RFM_MHZ(r-st*sp);
  if(t>0 && rfmQ('rfmCtx')) rfmQ('rfmCtx').value=RFM_MHZ(t-st*sp);
  rfmPlanFreqs();
}
/* ==================== 提交规划（必须带 center/spacing/sample_rate，后端按它们算每路频率） ==================== */
function rfmPlanPayload(callOverride){
  var cx=rfmCenterRx(), ct=rfmCenterTx();
  if(!cx||!ct) return null;
  var n=rfmNChSel(), sp=rfmSpacingHz();
  var old={}; ((RFM_PLAN&&RFM_PLAN.list)||[]).forEach(function(c){ old[parseInt(c.n,10)]=c; });
  var list=[];
  for(var i=1;i<=RFM_MAX;i++){
    var sel=document.querySelector('#rfmRows .fld[data-n="'+i+'"] .rfm-mode');
    var mode = sel ? sel.value : String((old[i]&&old[i].mode)||'NONE').toUpperCase();
    /* ★ 超出通道数的通道：模式照原样提交（后端只启停服务，不动保存的模式）
       —— 这样「通道数量」调小再调大时，各通道原来的协议还在，可以完整恢复多通道 */
    list.push({n:i, mode:mode, rx:cx+rfmGridStep(i)*sp, tx:ct+rfmGridStep(i)*sp});
  }
  var call = (callOverride!==undefined) ? callOverride
             : String((rfmQ('rfmCall')||{}).value||'').trim().toUpperCase();
  return {channels:n, center_rx:cx, center_tx:ct, spacing:sp,
          sample_rate:(n<=5?150000:300000),   /* ★ 自适应：≤5 路 150k / ≥6 路 300k */
          callsign: call || String((RFM_PLAN&&RFM_PLAN.callsign)||''),
          dmr_id: String((RFM_PLAN&&RFM_PLAN.dmr_id)||''),
          list:list};
}
function rfmApplyConfirm(body, onlySave){
  if(onlySave) return true;
  var legal = '请遵守所在国家或地区关于无线电发射设备、频率使用和操作资格的法律法规。因违规使用造成的后果由操作者承担，与本软件及作者无关。';
  var hw=RFM_HW||{};
  var model=String(hw.model||'').toLowerCase();
  var n=parseInt((body&&body.channels)||0,10)||0;
  var max=parseInt(hw.max_channels||0,10)||4;
  if(model.indexOf('raspberry pi 3')>=0){
    if(n>max){
      alert('检测到当前设备为树莓派 3B。\n\n本机最多支持 '+max+' 路射频，请先减少到 '+max+' 路以内再保存。');
      return false;
    }
    return confirm('检测到当前设备为树莓派 3B。\n\n为了保证语音稳定和系统余量，建议同时启用 2 路射频；确实需要时可以开启最多 4 路，不建议超过 4 路。\n\n当前计划：'+n+' 路射频。\n\n'+legal+'\n\n点击“确定”后将保存并应用该配置。');
  }
  return confirm('将写入 MMDVM-Multi.ini 与各通道配置，并重启受影响的通道。\n大约需要 20-60 秒。\n\n'+legal+'\n\n继续？');
}
function rfmPlanApply(onlySave){
  var body=rfmPlanPayload();
  if(!body){ rfmMsg('rfmPlanMsg','请先填写中心频率（MHz）', false); return; }
  if(!rfmApplyConfirm(body, onlySave)) return;
  rfmMsg('rfmPlanMsg', onlySave?'正在保存规划…':'正在应用规划（约 20-60 秒）…');
  rfmPost('/api/mc/plan-'+(onlySave?'save':'apply'), body, function(d){
    var ok=!!(d&&d.ok!==false);
    var t=String((d&&(d.out||d.message||d.error))||'').trim();
    var legal = '{legal}';
    rfmMsg('rfmPlanMsg',(ok?'✅ ':'❌ ')+t.slice(0,120)+(ok?' ｜ '+legal:''), ok);
    if(ok) setTimeout(function(){ rfmPlanLoad(); rfmLoad(); rfmModeStatus(); }, 2500);
  });
}
/* ==================== 运行模式：常规模式 <-> TDR Link 模式 ====================
   常规模式 = MMDVM 多通道射频收发；TDR Link 模式 = TDR Link基站（互斥，同一块 SX1255）
   后端接口：/api/tetra/status 与 /api/tetra/switch {what:'mmdvm'|'tetra'}（失败自动回滚） */
function rfmModeStatus(){
  var el=rfmQ('rfmModeNow'); if(!el) return;
  fetch('/api/tetra/status', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false){ el.textContent='读取失败'; el.style.color='#ef4444'; return; }
    var tetra=(d.service==='active'), mm=(d.mmdvm==='active');
    var txt, col, badge;
    if(tetra && !mm){ txt='TDR Link 模式（TDR Link基站运行中，多通道已停止）'; col='var(--amber2)'; badge='TDR Link 模式'; }
    else if(mm && !tetra){ txt='常规模式（多通道射频收发运行中）'; col='var(--green2)'; badge='常规模式'; }
    else if(!mm && !tetra){ txt='当前没有射频在运行（点下面按钮选择模式）'; col='#94a3b8'; badge='空闲'; }
    else { txt='异常：TDR Link 与多通道同时活动，请点一次切换按钮纠正'; col='#ef4444'; badge='冲突'; }
    el.textContent = txt + (d.sdr_owner ? (' ｜ SX1255 归属：'+d.sdr_owner) : '');
    el.style.color = col;
    var b=rfmQ('rfmModeBadge'); if(b) b.textContent=badge;
    var bm=rfmQ('rfmBtnMmdvm'), bt=rfmQ('rfmBtnTetra');   /* 当前模式按钮置灰防误点 */
    if(bm) bm.style.opacity=(mm && !tetra)? '.5' : '';
    if(bt) bt.style.opacity=(tetra && !mm)? '.5' : '';
  }).catch(function(e){ el.textContent='读取失败：'+e; el.style.color='#ef4444'; });
}
function rfmSwitchMode(what){
  var toTetra=(what==='tetra');
  var q = toTetra
    ? '把 SX1255 交给「TDR Link 模式（TDR Link）」？\n\n将停止全部多通道射频收发（MMDVM-Multi 与各通道），然后启动 TDR Link基站。'
    : '切回「常规模式（多通道射频收发）」？\n\n将停止 TDR Link基站，然后按「信道规划」启动多通道（引擎 + 各通道 IQ/Host）。';
  if(!confirm(q + '\n\n约需 20-40 秒，期间射频中断。继续？')) return;
  var msg = toTetra ? '正在切换到TDR Link 模式（TDR Link）…' : '正在切换回常规模式（多通道）…';
  var el=rfmQ('rfmModeNow'); if(el){ el.textContent=msg; el.style.color='#fbbf24'; }
  if(typeof toast==='function') toast(msg);
  rfmPost('/api/tetra/switch',{what:what},function(d){
    var ok=!!(d&&d.ok);
    var t=String((d&&(d.error||d.out))||'');
    if(typeof toast==='function')
      toast(ok ? (toTetra?'已切换到TDR Link 模式（TDR Link）':'已切换回常规模式（多通道）')
               : ('切换失败：'+t.slice(0,80)), ok);
    setTimeout(function(){ rfmModeStatus(); rfmLoad(); rfmPlanLoad(); }, 2500);
  });
}
/* ==================== 各通道状态（永远列出 1-6，含未启用） ==================== */
function rfmBanner(){
  var b=rfmQ('rfmBanner'); if(!b) return;
  var n=rfmNChSel(), arr=[];
  for(var i=1;i<=n;i++){ arr.push('CH'+i+' '+RFM_MHZ(rfmChanRx(i))+'/'+RFM_MHZ(rfmChanTx(i))); }
  b.textContent = arr.join('  ｜  ') || '（未配置）';
}
function rfmLoad(){
  fetch('/api/mc/channels?light=1', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false) return;
    var live={};
    (d.channels||[]).forEach(function(c){ live[parseInt(c.n,10)]=c; });
    RFM_CHS=d.channels||[];
    RFM_HW=d.hardware||{};
    var planList={}; ((RFM_PLAN&&RFM_PLAN.list)||[]).forEach(function(c){ planList[parseInt(c.n,10)]=c; });
    if(typeof mcSeqBuild==='function') mcSeqBuild(d.channels||[]);
    var box=rfmQ('rfmChans'); if(!box) return;
    var h='<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px">';
    for(var i=1;i<=RFM_MAX;i++){
      var c=live[i]||null;
      var mode=c?String(c.mode||'NONE'):String((planList[i]&&planList[i].mode)||'NONE');
      var rx=(c&&c.rx)?c.rx:((planList[i]&&planList[i].rx)||0);
      var tx=(c&&c.tx)?c.tx:((planList[i]&&planList[i].tx)||0);
      var run=!!(c&&c.iq&&c.host), on=!!c;
      var opts=RFM_MODES.map(function(m){
        return '<option value="'+m+'"'+(mode.toUpperCase()===m?' selected':'')+'>'+m+'</option>';
      }).join('');
      h+='<div class="fld" data-ch="'+i+'" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2);'+(on?'':'opacity:.5')+'">'
        +   '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">'
        +     '<span style="width:8px;height:8px;border-radius:9px;flex:0 0 8px;background:'+(run?'var(--green2)':(on?'var(--amber2)':'#64748b'))+'"></span>'
        +     '<b style="font-size:13.5px;color:#7dd3fc">通道 '+i+'</b>'
        +     (on?'':'<span class="hint">未启用</span>')
        +     '<span style="margin-left:auto;display:flex;align-items:center;gap:7px;color:#fff;font-weight:800;font-size:13.5px">'
        +     rfmSvg(mode)+mode+'</span>'
        +   '</div>'
        +   '<div class="hint" style="margin-bottom:8px">RX '+RFM_MHZ(rx)+' / TX '+RFM_MHZ(tx)+' MHz'
        +     (on?((c.iq?' ｜ IQ ✓':' ｜ IQ ✗')+(c.host?' Host ✓':' Host ✗')):'')+'</div>'
        +   '<div style="display:flex;gap:6px;align-items:center">'
        +     '<select class="rfm-mode2" style="flex:1;min-width:0"'+(on?'':' disabled')+'>'+opts+'</select>'
        +     '<button class="btn btn-green btn-sm"'+(on?'':' disabled')+' onclick="rfmSwitch('+i+')">切换</button>'
        +     '<button class="btn btn-ghost btn-sm"'+(on?'':' disabled')+' onclick="rfmRestart('+i+')">重启</button>'
        +   '</div>'
        + '</div>';
    }
    h+='</div>';
    box.innerHTML=h;
    rfmBanner();
  }).catch(function(){});
}
function rfmSwitch(n){
  var card=document.querySelector('#rfmChans .fld[data-ch="'+n+'"]'); if(!card) return;
  var c2=card.querySelector('.rfm-mode2'); var mode=c2?c2.value:'NONE';
  if(!confirm('把射频通道 '+n+' 切换为 '+mode+' ？将重启该通道。')) return;
  if(typeof toast==='function') toast('通道 '+n+' 正在切换…');
  rfmPost('/api/mc/switch',{ch:n,mode:mode},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?('通道 '+n+' 已切换为 '+mode):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(function(){ rfmLoad(); rfmPlanLoad(); }, 1800);
  });
}
function rfmRestart(n){
  if(!confirm('重启射频通道 '+n+' 的基带与主机？')) return;
  rfmPost('/api/mc/restart',{ch:n},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(rfmLoad,1800);
  });
}
function rfmRestartAll(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。')) return;
  rfmPost('/api/mc/multi-restart',{},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(function(){ rfmLoad(); rfmPlanLoad(); }, 5000);
  });
}
document.addEventListener('change', function(e){
  if(e.target && e.target.id==='rfmPower') rfmPowerChanged();
});
(function(){ var s=rfmQ('rfmPower'); if(s) rfmPowerChanged(); })();
/* 兜底：规划读取失败时页面也能用（默认中心频率 + 通道数选择器 + 通道行） */
(function(){
  if(!rfmQ('rfmNCh')) return;
  if(!rfmQ('rfmNCh').innerHTML) rfmFillNCh(6);
  if(rfmQ('rfmCrx') && !rfmQ('rfmCrx').value) rfmQ('rfmCrx').value='434.450';
  if(rfmQ('rfmCtx') && !rfmQ('rfmCtx').value) rfmQ('rfmCtx').value='439.450';
  if(rfmQ('rfmRows') && !document.querySelector('#rfmRows .fld[data-n]')) rfmRenderRows();
})();

/* 点侧栏进入本页时自动加载：规划 + 通道状态 + 台站参数 */
document.addEventListener("click", function(e){
  var el = e.target.closest && e.target.closest('[data-page="rfmode"]');
  if(!el) return;
  setTimeout(function(){
    if(typeof rfmPlanLoad==="function") rfmPlanLoad();
    if(typeof rfmLoad==="function") rfmLoad();
    if(typeof rfmLoadStation==="function") rfmLoadStation();
    if(typeof rfmModeStatus==="function") rfmModeStatus();
  }, 120);
});
</script>

<!-- ===== 引擎监控 页面脚本（独立块，隔离；给射频模式配置页的诊断卡用） ===== -->
<script>

var PLN = {channels:0, sample_rate:150000, callsign:'', list:[]};   /* 主控制台读它（规划摘要） */
var CH_GAP = 25000;
function chGridStep(i, sr){
  sr = parseInt(sr,10) || parseInt((typeof PLN==='object'&&PLN&&PLN.sample_rate)||0,10) || 300000;
  var n = Math.max(1, Math.round(sr/CH_GAP));
  var mf = Math.max(1, Math.min(Math.floor(n/2)-1, 4));
  var p = i-1, b = (p<mf) ? p : (n-1)-(p-mf);
  return (b*2<=n) ? b : b-n;
}
var MC_HZ = (typeof window!=='undefined' && window.FREQ_HZ) ? window.FREQ_HZ : function(v){
  var s=String(v==null?'':v).trim().replace(/[^0-9.]/g,''); var f=parseFloat(s);
  if(!isFinite(f)||f<=0) return 0;
  return Math.round((s.indexOf('.')>=0||f<100000)? f*1000000 : f);
};
var MC_MHZ = (typeof window!=='undefined' && window.FREQ_MHZ) ? window.FREQ_MHZ : function(hz){
  hz=parseInt(hz,10); if(!isFinite(hz)||hz<=0) return '';
  return (hz/1000000).toFixed((hz%12500===0&&hz%25000!==0)?4:3);
};
function mcI(id){ return document.getElementById(id); }
function mcEsc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function mcDot(on){ return '<span style="display:inline-block;width:7px;height:7px;border-radius:9px;margin-right:5px;background:'+(on?'var(--green2)':'#ef4444')+'"></span>'; }
function mcKV(k,v){ return '<div class="fld" style="background:var(--card2);border:1px solid var(--border);border-radius:9px;padding:9px 11px">'
  + '<div class="hint" style="margin-bottom:3px">'+mcEsc(k)+'</div>'
  + '<div style="font-size:14.5px;font-weight:700;color:#dfe8f5">'+mcEsc(v)+'</div></div>'; }
function mcMsg(t, ok){
  var e=mcI('mcMsg'); if(e) e.textContent=t||'';
  if(t && typeof toast==='function') toast(t, ok);
}
function mcPlanLite(){
  return fetch('/api/mc/plan', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(p){
    if(p && p.ok!==false && p.list){ PLN=p; if(!PLN.sample_rate) PLN.sample_rate=150000; }
    return PLN;
  }).catch(function(){ return PLN; });
}
function mcRefreshAll(){
  mcPlanLite();
  mcModeLite(function(){
    fetch('/api/mc/channels?light=1', {credentials:'same-origin'}).then(function(r){ return r.json(); })
    .then(function(d){
      if(!d || d.ok===false){ mcMsg('读取失败'+String((d&&(d.error||d.out))||''), false); return; }
      if(typeof mcSeqBuild==='function') mcSeqBuild(d.channels||[]);
      mcRenderEng(d); mcRenderChs(d); mcRenderSvcs(d);
    }).catch(function(e){ mcMsg('网络错误: '+e, false); });
  });
}
/* 当前运行模式：常规模式（多通道射频收发）/ TDR Link 模式（TDR Link） */
var MC_MODE_TXT = '--';
function mcModeLite(cb){
  fetch('/api/tetra/status', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(d && d.ok!==false){
      var t=(d.service==='active'), m=(d.mmdvm==='active');
      MC_MODE_TXT = (t&&!m) ? 'TDR Link 模式（TDR Link）'
                  : (m&&!t) ? '常规模式（多通道射频收发）'
                  : (!t&&!m)? '空闲（当前没有射频在运行）' : '异常（两种模式同时活动）';
    }
    if(cb) cb();
  }).catch(function(){ if(cb) cb(); });
}
function mcRenderEng(d){
  var e=d.multi||{}, box=mcI('mcEngBox'); if(!box) return;
  var nch = parseInt((PLN&&PLN.channels)||0,10) || (d.channels||[]).length;
  box.innerHTML = mcKV('当前模式', MC_MODE_TXT)
    + mcKV('引擎状态', e.active?'运行中':'已停止')
    + mcKV('运行通道数', nch + ' / 配置 ' + (e.channels||'-'))
    + mcKV('采样率', e.rate? (e.rate+' Hz'):'--')
    + mcKV('SDR 类型', e.sdr||'--')
    + mcKV('RX 中心', e.rx? (MC_MHZ(e.rx)+' MHz'):'--')
    + mcKV('TX 中心', e.tx? (MC_MHZ(e.tx)+' MHz'):'--')
    + mcKV('RX 增益', (e.rxg===''||e.rxg==null)?'--':e.rxg+' dB')
    + mcKV('TX 增益', (e.txg===''||e.txg==null)?'--':e.txg+' dB');
  var b=mcI('mcChBadge'); if(b) b.textContent='IQ/Host '+(d.channels||[]).length+' 路';
}
function mcRenderChs(d){
  var cs=d.channels||[], box=mcI('mcChBox'); if(!box) return;
  var h='';
  for(var i=0;i<cs.length;i++){
    var c=cs[i];
    h+='<div class="fld" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2);margin-bottom:9px">'
      + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:7px">'
      +   '<b style="font-size:13.5px;color:#7dd3fc">射频通道 '+(typeof mcSeq==='function'?mcSeq(c.n):c.n)+'</b>'
      +   '<span class="hint">'+mcEsc(c.mode)+'</span>'
      +   '<span style="margin-left:auto" class="hint">'+mcDot(c.iq)+'IQ '+mcDot(c.host)+'Host</span>'
      + '</div>'
      + '<div class="form-grid" style="margin:0">'
      +   mcKV('RX 频率', c.rx? (MC_MHZ(c.rx)+' MHz') : '--')
      +   mcKV('TX 频率', c.tx? (MC_MHZ(c.tx)+' MHz') : '--')
      +   mcKV('Multi 端口', c.mport)+mcKV('Host 端口', c.hport)
      + '</div>'
      + '<div class="hint" style="margin-top:8px">最近通联：'+mcEsc(c.lastq||'（无）')+'</div>'
      + '<div class="mc-foot" style="margin:8px 0 0">'
      +   '<button class="btn btn-ghost btn-sm" onclick="mcRestartCh('+c.n+')">&#8634; 重启本通道</button>'
      + '</div></div>';
  }
  box.innerHTML = h || '<div class="sp-empty">未检测到通道配置</div>';
}
function mcRenderSvcs(d){
  var sv=d.services||[], box=mcI('mcSvcBox'); if(!box) return;
  var h='';
  for(var i=0;i<sv.length;i++){ h+=mcKV(sv[i].name, (sv[i].active?'● 运行':'○ 停止')); }
  box.innerHTML = h || '<div class="hint">无服务信息</div>';
  var b=mcI('mcSvcBadge');
  if(b){ var on=sv.filter(function(x){return x.active;}).length; b.textContent=on+' / '+sv.length+' 在运行'; }
  mcFillLogCh(d.channels||[]);
}
function mcFillLogCh(cs){
  var sel=mcI('mcLogCh'); if(!sel) return;
  var cur=sel.value, h='<option value="0">（引擎级）</option>';
  if(typeof mcSeqBuild==='function') mcSeqBuild(cs);
  for(var i=0;i<cs.length;i++){
    if(String(cs[i].mode||'NONE').toUpperCase()==='NONE') continue;
    h+='<option value="'+cs[i].n+'">'+((typeof mcSeq==='function')?mcSeq(cs[i].n):cs[i].n)+'. '+mcEsc(cs[i].mode)+'</option>';
  }
  sel.innerHTML=h;
  if(cur) sel.value=cur;
}
function mcPostJson(url, body, cb){
  fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},
             body:JSON.stringify(body||{})})
    .then(function(r){ return r.json(); }).then(function(d){ cb(d); })
    .catch(function(e){ mcMsg('网络错误: '+e, false); });
}
function mcRestartCh(n){
  mcMsg('通道 '+n+' 正在重启…');
  mcPostJson('/api/mc/restart',{ch:n},function(d){
    var ok=!!(d&&d.ok);
    mcMsg(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')), ok);
    if(ok) setTimeout(mcRefreshAll, 2500);
  });
}
function mcRestartEngine(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。')) return;
  mcMsg('正在重启引擎…');
  mcPostJson('/api/mc/multi-restart',{},function(d){
    var ok=!!(d&&d.ok);
    mcMsg(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')), ok);
    if(ok) setTimeout(mcRefreshAll, 6000);
  });
}
function mcLoadLog(){
  var ch=(mcI('mcLogCh')||{}).value||'0', svc=(mcI('mcLogSvc')||{}).value||'mmdvmhost',
      ln=(mcI('mcLogLines')||{}).value||'200';
  var box=mcI('mcLogBox'); if(box) box.textContent='加载中…';
  fetch('/api/mc/logs?ch='+encodeURIComponent(ch)+'&svc='+encodeURIComponent(svc)+'&lines='+encodeURIComponent(ln),
        {credentials:'same-origin'})
  .then(function(r){ return r.json(); })
  .then(function(d){
    if(box) box.textContent = (d&&(d.text||d.out)) || ('读取失败：'+String((d&&d.error)||''));
  }).catch(function(e){ if(box) box.textContent='网络错误: '+e; });
}
/* 本文件提供「射频模式配置」页诊断卡所需的监控函数（原独立监控页已并入该页） */

</script>

<!-- ===== TDR Link 高级参数 页面脚本（独立块，含防闪动） ===== -->
<script>

/* ==================== TDR Link 高级参数（TETRA_ADV）====================
   · 字段表与后端读写由同一份 spec 生成
   · 同时修复「TDR Link 配置内容闪动」：数据没变化时不再重写表单 */
var TADV = [
  ["cell_info","timezone","tz","text"],
  ["cell_info","freq_offset","foff","sel"],
  ["cell_info","reverse_operation","revop","chk"],
  ["cell_info","location_area","la","num"],
  ["cell_info","colour_code","ccode","num"],
  ["cell_info","hangtime_secs","hang","num"],
  ["cell_info","call_timeout_secs","ctmo","num"],
  ["cell_info","ul_inactivity_secs","ulino","num"],
  ["cell_info","periodic_registration_secs","perreg","num"],
  ["cell_info","voice_service","voice","chk"],
  ["cell_info","system_wide_services","syswide","chk"],
  ["cell_info","release_group_on_same_speaker_retake","relgrp","chk"],
  ["cell_info","local_ssi_ranges","lssi","area"],
  ["security","issi_whitelist","wlist","area"],
  ["security","whitelist_mode","wmode","sel"],
  ["recovery","enabled","recen","chk"],
  ["recovery","reactive_enabled","recrx","chk"],
  ["phy_io.soapysdr","rx_gain_lna","glna","num"],
  ["phy_io.soapysdr","rx_gain_pga","gpga","num"],
  ["phy_io.soapysdr","tx_gain_dac","gdac","num"],
  ["phy_io.soapysdr","tx_gain_mixer","gmix","num"],
  ["phy_io.soapysdr","rx_antenna","rant","text"],
  ["phy_io.soapysdr","tx_antenna","tant","text"],
  ["cell_info.home_mode_display","text","hometxt","text"]
];
function tA(id){ return document.getElementById(id); }
function tAdvMsg(t, ok){
  var e=tA('tAdvMsg'); if(e) e.textContent=t||'';
  if(t && typeof toast==='function') toast(t, ok);
}
/* 后端 -> 表单 */
function tAdvFill(cfg){
  var adv = (cfg && cfg.adv) || {};
  for(var i=0;i<TADV.length;i++){
    var sec=TADV[i][0], key=TADV[i][1], id=TADV[i][2], typ=TADV[i][3];
    var el=tA(id); if(!el) continue;
    var v=adv[sec+"."+key];
    if(v===undefined || v===null) continue;
    if(typ==='chk') el.value = (v===true || String(v).toLowerCase()==='true') ? 'true' : 'false';
    else el.value = String(v);
  }
}
/* 表单 -> 后端 */
function tAdvCollect(){
  var out={}, bad=null;
  for(var i=0;i<TADV.length;i++){
    var sec=TADV[i][0], key=TADV[i][1], id=TADV[i][2], typ=TADV[i][3];
    var el=tA(id); if(!el) continue;
    var v=String(el.value||'').trim();
    if(typ==='area'){
      var rows=[];
      v.split(/\r?\n/).forEach(function(ln){
        ln=ln.trim(); if(!ln) return;
        rows.push(ln);
      });
      if(sec==='cell_info' && key==='local_ssi_ranges' && rows.length){
        rows=rows.map(function(r){ return r.split(/[,，]/).map(function(x){ return parseInt(x,10)||0; }); });
        v=JSON.stringify(rows);
      } else if(rows.length){
        v=JSON.stringify(rows.map(function(r){ return parseInt(r,10)||0; }));
      } else { v=''; }
    } else if(typ==='num'){
      if(v===''){ v=''; } else { v=String(parseInt(v,10)); }
    }
    out[sec+"."+key]=v;
  }
  return {adv: out};
}
/* 包装原有 fill / collect / refresh（不改动原脚本，防闪动） */
(function(){
  if(typeof fill === 'function'){
    var _f = fill;
    window.fill = function(cfg, st){
      var sig='';
      try { sig = JSON.stringify(cfg); } catch(e){}
      if(sig && sig === window.__tetraSig){          /* 数据没变：只刷时间戳，不重写表单 */
        var b=tA('bTime'); if(b) b.textContent=new Date().toLocaleTimeString();
        return;
      }
      window.__tetraSig = sig;
      _f(cfg, st);
      try { tAdvFill(cfg); } catch(e){}
    };
  }
  if(typeof collect === 'function'){
    var _c = collect;
    window.collect = function(){
      var o=_c();
      try { var a=tAdvCollect(); for(var k in a.adv) o[k]=a.adv[k]; o.adv=a.adv; } catch(e){}
      return o;
    };
  }
  /* 重新装一个更温和的定时刷新：不在本页 / 正在输入时不刷新 */
  try { var _i=0; while(_i++ < 100000){ clearInterval(_i); } } catch(e){}
  setInterval(function(){
    var pg=tA('page-tetra');
    if(!pg || pg.style.display === 'none') return;
    var ae=document.activeElement;
    if(ae && pg.contains(ae) && /INPUT|SELECT|TEXTAREA/.test(ae.tagName||'')) return;
    if(typeof refresh === 'function') refresh();
  }, 30000);
})();

</script>

<style>
/* ARCH responsive + night contrast refinements */
body:not(.light){
  --card:#0a1623;--card-hi:#102235;--card-elev:#142a40;
  --border:rgba(125,211,252,.20);--text:#eef8ff;--muted:#a8bfd6;--dim:#7e9bb8;
}
body:not(.light) .card,
body:not(.light) .stat,
body:not(.light) .fld,
body:not(.light) .port-card,
body:not(.light) .db-card,
body:not(.light) .qso-item,
body:not(.light) .console,
body:not(.light) .sp{
  border-color:rgba(125,211,252,.18)!important;
  box-shadow:0 8px 28px rgba(0,0,0,.24),inset 0 1px 0 rgba(255,255,255,.035)!important;
}
body:not(.light) input, body:not(.light) select, body:not(.light) textarea{
  background:#081522!important;color:#edf8ff!important;border-color:#29465f!important;
}
.tg-srv-card{
  text-align:left;border-radius:10px;padding:11px 12px;cursor:pointer;
  background:var(--card2);border:1px solid var(--border);color:var(--text);
}
.tg-srv-card.is-selected{
  background:linear-gradient(135deg,rgba(34,211,238,.13),rgba(59,130,246,.06))!important;
  border-color:#22d3ee!important;box-shadow:0 0 0 1px rgba(34,211,238,.18);
}
body:not(.light) .tg-srv-card{
  background:#0b1b2a!important;border-color:#2a4a66!important;color:#eaf7ff!important;
}
body:not(.light) .tg-srv-card.is-selected{
  background:linear-gradient(135deg,#102e48,#0b1c2d)!important;
  border-color:#22d3ee!important;box-shadow:0 0 16px rgba(34,211,238,.18);
}
body:not(.light) .tg-srv-card.is-active:not(.is-selected){border-color:#22c55e!important;}
body:not(.light) .hint{color:var(--muted)!important;}
#tgTermTable{table-layout:fixed;min-width:760px}
#tgTermTable th:nth-child(1),#tgTermTable td:nth-child(1){width:82px}
#tgTermTable th:nth-child(2),#tgTermTable td:nth-child(2){width:86px}
#tgTermTable th:nth-child(3),#tgTermTable td:nth-child(3){width:92px}
#tgTermTable th:nth-child(4),#tgTermTable td:nth-child(4){width:92px}
#tgTermTable th:nth-child(5),#tgTermTable td:nth-child(5){width:92px}
#tgTermTable th:nth-child(6),#tgTermTable td:nth-child(6){width:190px}
#tgTermTable th:nth-child(7),#tgTermTable td:nth-child(7){width:72px}
@media(min-width:1600px){.content{padding-left:26px;padding-right:26px}#tgSlots{gap:16px!important}}
@media(min-width:2200px){.content{max-width:1960px;margin-left:auto;margin-right:auto}}
@media(max-width:1100px){ #tgSlots{grid-template-columns:repeat(2,minmax(0,1fr))!important}}
@media(max-width:700px){
  .content{padding:10px 8px 24px!important}
  #tgSlots{grid-template-columns:1fr!important}
  #tgSrvCards{grid-template-columns:1fr!important}
  #tgSrvForm{grid-template-columns:1fr!important}
  #tgSrvForm > div:last-child{grid-column:auto!important;justify-content:flex-start!important}
  .form-grid{grid-template-columns:1fr!important}
  .main-top{padding-left:8px!important;padding-right:8px!important}
}
</style>
<!-- ===== TDR Link 模式状态宫格 页面脚本（独立块） ===== -->
<script>

/* ==================== TDR Link 模式状态宫格（首页）====================
   TDR Link 模式() -> 显示本宫格、隐藏「频率模式列表」；常规模式 -> 反过来 */
function tgQ(id){ return document.getElementById(id); }
function tgModeOf(st){
  var tetra = (st && st.service === 'active'), mm = (st && st.mmdvm === 'active');
  if(tetra && !mm) return 'tetra';
  if(mm && !tetra) return 'mmdvm';
  return 'idle';
}
function tgToggle(mode){
  var grid = tgQ('tetraGrid'), freq = tgQ('freqModeCard'), qso = tgQ('qsoPanel');
  if(grid) grid.style.display = (mode === 'tetra') ? '' : 'none';
  if(freq) freq.style.display = (mode === 'tetra') ? 'none' : '';
  if(qso) qso.style.display = (mode === 'tetra') ? 'none' : '';
}
function tgCallText(s){
  if(!s || (!s.busy && !s.gssi && !s.caller_issi && !s.called_issi)) return '--';
  if(s.call_type === 'individual'){
    if(!s.simplex){
      var ids = [];
      [s.caller_issi, s.called_issi].forEach(function(v){ if(v && ids.indexOf(v) < 0) ids.push(v); });
      return (ids.join(' / ') || s.gssi || '--') + ' · 双工';
    }
    var one = s.called_issi || s.caller_issi || s.active_speaker;
    return (one || s.gssi || '--') + ' · 单呼';
  }
  return s.gssi || '--';
}
function tgRenderSlots(d, onlySlots){
  var el = tgQ('tgFreqNow');
  if(el && d && (d.base_dl || d.fallback)){
    el.textContent = (d.fallback ? '⚠ 基站退到 FALLBACK 配置（主配置有错，设置未生效）' : '')
      + '  实际 DL/TX ' + (d.base_dl || '--') + ' MHz ｜ UL/RX ' + (d.base_ul || '--') + ' MHz';
    if(d.fallback) el.style.color = '#f87171'; else el.style.color = '';
  }
  var box = tgQ('tgSlots'); if(!box) return;
  var names = {1:'TS1 控制信道', 2:'TS2 通话时隙', 3:'TS3 通话时隙', 4:'TS4 通话时隙'};
  var h = '';
  (d.ts || []).forEach(function(s){
    var active = (s.n === 1) ? true : (!!s.busy || !!s.gssi || !!s.caller_issi || !!s.called_issi);
    var col = (s.n === 1) ? '#22d3ee' : (active ? '#22c55e' : '#64748b');
    var txt = (s.n === 1) ? (d.bcast ? '广播中' : '待广播') : (active ? (s.status || '通话中') : '空闲');
    var callText = (s.n === 1) ? '--' : tgCallText(s);
    h += '<div class="fld" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2)">'
      +   '<div style="display:flex;align-items:center;gap:8px;margin-bottom:7px">'
      +     '<span aria-hidden="true" style="display:inline-block;width:10px;height:10px;min-width:10px;border-radius:50%;background:'+col+';box-shadow:'+(active?'0 0 10px '+col:'none')+'"></span>'
      +     '<b style="font-size:13.5px;color:var(--text)">'+names[s.n]+'</b>'
      +   '</div>'
      +   '<div style="font-size:17px;font-weight:800;color:'+((s.n === 1) ? '#dc2626' : 'var(--text)')+'">'+txt+'</div>'
      +   '<div style="margin-top:7px;font-size:16.5px;font-weight:800;color:#22d3ee">通话组：'+callText+'</div>'
      + '</div>';
  });
  box.innerHTML = h;
  var b = tgQ('tgBadge');
  if(b) b.textContent = d.bcast ? ('MCCH ' + d.bcast) : 'TDR Link';
  if(onlySlots) return;
  tgRenderTerminals(d);
}
function tgRenderTerminals(d){
  var tb = tgQ('tgTermBadge'); if(tb) tb.textContent = '（' + (d.count || 0) + ' 台）';
  var box2 = tgQ('tgTerms'); if(!box2) return;
  var rows = (d.terminals || []);
  var T = [];
  rows.forEach(function(t){
    T.push('<tr>'
      + '<td style="padding:7px 9px;border-bottom:1px solid var(--border);color:var(--text);font-weight:700">' + (t.issi || '—') + '</td>'
      + '<td style="padding:7px 9px;border-bottom:1px solid var(--border)">' + (t.callsign || '—') + '</td>'
      + '<td style="padding:7px 9px;border-bottom:1px solid var(--border)">' + (t.rssi || '—') + '</td>'
      + '<td style="padding:7px 9px;border-bottom:1px solid var(--border)">' + (t.seen || '—') + '</td>'
      + '<td style="padding:7px 9px;border-bottom:1px solid var(--border)">' + (t.batt || '—') + '</td>'
      + '<td style="width:130px;min-width:110px;padding:7px 9px;border-bottom:1px solid var(--border);font-size:12.5px;line-height:1.35;white-space:normal;word-break:break-word">' + (t.groups || '—') + '</td>'
      + '<td style="padding:7px 9px;border-bottom:1px solid var(--border);color:#4ade80">已附着</td></tr>');
  });
  if(!T.length){
    T.push('<tr><td style="padding:7px 9px">—</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td>'
      + '<td style="padding:7px 9px;color:#94a3b8">暂无终端注册（手台入网后自动出现）</td></tr>');
  }
  box2.innerHTML = T.join('');
}
function tgRender(d){ tgRenderSlots(d, false); }

var tgServerData = null, tgServerSel = '', tgServersLoaded = false;
function tgServerEsc(v){
  return String(v == null ? '' : v).replace(/[&<>"']/g, function(c){
    return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
  });
}
function tgServerMsg(text, ok){
  var el = tgQ('tgSrvMsg');
  if(el){ el.textContent = text || ''; el.style.color = ok === false ? '#dc2626' : ''; }
}
function tgSelectServer(id){
  tgServerSel = id;
  tgRenderServerCards();
  tgFillServerForm();
}
function tgRenderServerCards(){
  var box = tgQ('tgSrvCards'); if(!box || !tgServerData) return;
  box.innerHTML = (tgServerData.servers || []).map(function(s){
    var active = s.id === tgServerData.active_server;
    var selected = s.id === tgServerSel;
    var border = selected ? 'var(--theme-1)' : 'var(--border)';
    var bg = selected ? 'color-mix(in srgb, var(--theme-1) 9%, var(--card2))' : 'var(--card2)';
    return '<button type="button" class="tg-srv-card'+(selected?' is-selected':'')+(active?' is-active':'')+'" onclick="tgSelectServer(\'' + s.id + '\')">'
      + '<div style="display:flex;align-items:center;gap:8px"><b>' + tgServerEsc(s.name) + '</b>'
      + (active ? '<span class="badge" style="color:#16a34a">当前连接</span>' : '')
      + (selected ? '<span class="badge">正在编辑</span>' : '') + '</div>'
      + '<div class="hint" style="margin-top:5px">' + tgServerEsc(s.host) + ':' + tgServerEsc(s.port)
      + ' · ' + (s.tls ? 'WSS/TLS' : 'WS') + '</div></button>';
  }).join('');
}
function tgFillServerForm(){
  var s = ((tgServerData || {}).servers || []).find(function(x){ return x.id === tgServerSel; });
  if(!s) return;
  if(tgQ('tgSrvDmrId')) tgQ('tgSrvDmrId').value = s.dmr_id || '';
  if(tgQ('tgSrvPassword')) tgQ('tgSrvPassword').value = s.password || '';
  if(tgQ('tgSrvCurrent')) tgQ('tgSrvCurrent').textContent = '当前：' + (tgServerData.servers.find(function(x){return x.id===tgServerData.active_server;}) || {}).name;
  if(tgQ('tgSrvRegister')) tgQ('tgSrvRegister').style.display = (s.id === 'arch') ? '' : 'none';
}
function tgLoadServers(force){
  if(tgServersLoaded && !force) return;
  fetch('/api/tetra/servers', {credentials:'same-origin'}).then(function(r){ return r.json(); }).then(function(d){
    if(!d || !d.ok) throw new Error((d && d.error) || '读取服务器失败');
    tgServerData = d;
    if(!tgServerSel || !(d.servers || []).some(function(x){return x.id===tgServerSel;})) tgServerSel = d.active_server || (d.servers[0] || {}).id;
    tgServersLoaded = true;
    tgRenderServerCards();
    tgFillServerForm();
    tgServerMsg('已读取服务器配置。');
  }).catch(function(e){ tgServerMsg('读取失败：' + e.message, false); });
}
function tgSaveServers(){
  if(!tgServerData) return;
  var dmr = (tgQ('tgSrvDmrId') || {}).value || '';
  var pass = (tgQ('tgSrvPassword') || {}).value || '';
  dmr = dmr.replace(/\s+/g, '');
  if(!/^\d{7}$/.test(dmr)){ tgServerMsg('DMR ID 必须是 7 位数字。', false); return; }
  if(!pass){ tgServerMsg('密码不能为空。', false); return; }
  var outgoing = (tgServerData.servers || []).map(function(s){
    var c = Object.assign({}, s);
    if(c.id === tgServerSel){ c.dmr_id = dmr; c.password = pass; c.username = dmr; delete c.essid; }
    return c;
  });
  tgServerMsg('正在保存并重启基站…');
  fetch('/api/tetra/servers/save', {method:'POST', credentials:'same-origin', headers:{'Content-Type':'application/json'},
    body: JSON.stringify({active_server: tgServerSel, servers: outgoing})})
  .then(function(r){ return r.json(); }).then(function(d){
    if(!d || !d.ok) throw new Error((d && (d.error || d.message)) || '保存失败');
    tgServerData = d; tgServersLoaded = true; tgRenderServerCards(); tgFillServerForm();
    tgServerMsg('服务器配置已保存，TDR Link 基站已重启。', true);
  }).catch(function(e){ tgServerMsg('保存失败：' + e.message, false); });
}

var tgLastData = null, tgLiveBusy = false;
function tgSeedData(){
  return {ok:true, bcast:'', base_dl:'', base_ul:'', fallback:false, count:0, terminals:[],
    ts:[1,2,3,4].map(function(n){ return {n:n,last:'',seq:'',count:0,busy:false,gssi:'',status:'',call_type:'',caller_issi:null,called_issi:null,active_speaker:null,simplex:null}; })};
}
function tgLoad(){
  if(!tgQ('tgSlots')) return;
  fetch('/api/tetra/status', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(st){
    var mode = tgModeOf(st);
    tgToggle(mode);
    try { tgFillFreq(st && st.carrier, st && st.cfg); } catch(e){}
    if(mode !== 'tetra'){ return; }
    tgLoadServers(false);
    if(!tgLastData) tgLastData = tgSeedData();
    tgLoadLive();
    return fetch('/api/tetra/slots', {credentials:'same-origin'}).then(function(r){ return r.json(); })
      .then(function(d){ tgLastData = d; tgRender(d); });
  }).catch(function(){});
}
function tgLoadLive(){
  if(tgLiveBusy || !tgLastData || document.hidden) return;
  var grid = tgQ('tetraGrid'); if(!grid || grid.style.display === 'none') return;
  tgLiveBusy = true;
  fetch('/api/tetra/live', {credentials:'same-origin', cache:'no-store'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || !d.ok || !tgLastData) return;
    var oldTerms = {};
    (tgLastData.terminals || []).forEach(function(t){ oldTerms[String(t.issi)] = t; });
    (d.ts || []).forEach(function(ns){
      var cur = (tgLastData.ts || []).find(function(x){ return Number(x.n) === Number(ns.n); });
      if(!cur) return;
      ['busy','gssi','status','call_type','caller_issi','called_issi','active_speaker','simplex','last'].forEach(function(k){
        if(k in ns) cur[k] = ns[k];
      });
    });
    if(Array.isArray(d.terminals)){
      tgLastData.terminals = d.terminals.map(function(t){
        var old = oldTerms[String(t.issi)] || {};
        return Object.assign({}, old, t, {callsign: old.callsign || t.callsign || '—'});
      });
      tgLastData.count = d.count || tgLastData.terminals.length;
    }
    if(d.bcast) tgLastData.bcast = d.bcast;
    tgRenderSlots(tgLastData, true);
    tgRenderTerminals(tgLastData);
  }).catch(function(){}).then(function(){ tgLiveBusy = false; });
}
/* ==================== TDR Link 收发频率（本模式专用，独立于多通道规划）==================== */
document.addEventListener('input', function(e){
  if(e.target && e.target.id === 'tgRx') e.target.dataset.touched = '1';
});
function tgMsg2(t, ok){
  var e = tgQ('tgFreqMsg'); if(e) e.textContent = t || '';
  if(t && typeof toast === 'function') toast(t, ok);
}
function tgFreqBandCarrier(tx){
  if(!isFinite(tx) || tx < 350 || tx > 500) return null;
  var band = Math.floor(tx / 100);
  var base = band * 100;
  var rel = Math.round((tx - base) * 40);
  var aligned = base + rel / 40;
  if(Math.abs(aligned - tx) > 0.0005 || rel < 0 || rel > 3999) return null;
  return {band:band, base:base, rel:rel, abs:band * 4000 + rel};
}
function tgFreqHint(){
  var tx = parseFloat((tgQ('tgTx')||{}).value);
  var info = tgFreqBandCarrier(tx);
  if(tgQ('tgCarrier')){
    tgQ('tgCarrier').value = info ? info.rel : '超出范围 / 非 25 kHz 栅格';
    if(tgQ('tgCarrierHz')){
      tgQ('tgCarrierHz').textContent = info ? (info.band + ' 频段 · 基准 ' + info.base + ' MHz · 主载波 ' + info.rel) : '可填 350.000-500.000 MHz（25 kHz 栅格）';
    }
  }
}
function tgFillFreq(c, cfg){
  if(!c || !cfg) return;
  if(tgQ('tgTx') && c.tx) tgQ('tgTx').value = (c.tx / 1e6).toFixed(3);
  if(tgQ('tgRx') && c.rx) tgQ('tgRx').value = (c.rx / 1e6).toFixed(3);
  var now = tgQ('tgFreqNow');
  if(now) now.textContent = '已配置 DL/TX ' + ((c.tx || 0) / 1e6).toFixed(3) + ' MHz ｜ UL/RX '
    + ((c.rx || 0) / 1e6).toFixed(3) + ' MHz ｜ 载波 ' + (c.carrier || '--');
  var p = tgQ('tgParams');
  if(p) p.innerHTML = 'MCC ' + (cfg.mcc || '--') + ' · MNC ' + (cfg.mnc || '--')
    + ' · 色码 ' + (cfg.color_code || '--')
    + ' · 采样率 ' + (cfg.sample_rate || '--') + ' · 双载波 ' + (cfg.dual_carrier ? '开' : '关')
    + ' · 服务器联网 ' + ((cfg.brew && cfg.brew.enable) ? ('开（' + (cfg.brew.host || '') + '）') : '关');
  tgFreqHint();
}
function tgSaveFreq(){
  var tx = parseFloat((tgQ('tgTx')||{}).value);
  var rx = parseFloat((tgQ('tgRx')||{}).value);
  if(!isFinite(tx) || tx < 350 || tx > 500){ tgMsg2('TX 频率必须在 350.000-500.000 MHz 之间', false); return; }
  if(!isFinite(rx) || rx < 350 || rx > 500){ tgMsg2('RX 频率必须在 350.000-500.000 MHz 之间', false); return; }
  if(!tgFreqBandCarrier(tx)){ tgMsg2('TX 频率必须按 25 kHz 栅格填写', false); return; }
  if(!tgFreqBandCarrier(rx)){ tgMsg2('RX 频率必须按 25 kHz 栅格填写', false); return; }
  var rev = rx > tx;
  if(!confirm('把 TDR Link 收发频率设为：\n  DL/TX ' + tx.toFixed(3) + ' MHz\n  UL/RX ' + rx.toFixed(3)
      + ' MHz' + (rev ? '（上行高于下行）' : '')
      + '\n\n双工频差由系统自动下发。将重启 TDR Link 基站，约 10-20 秒。')) return;
  tgMsg2('正在保存并重启基站…');
  fetch('/api/tetra/save', {method:'POST', credentials:'same-origin',
    headers:{'Content-Type':'application/json'},
    body: JSON.stringify({tx_freq_hz: Math.round(tx*1e6), rx_freq_hz: Math.round(rx*1e6),
                          reverse_operation: rev, restart: true})
  }).then(function(r){ return r.json(); }).then(function(d){
    var ok = !!(d && d.ok !== false);
    tgMsg2(ok ? '✅ 已换算载波并保存，基站正在重启…' : ('❌ ' + String((d && (d.error || d.message)) || '')), ok);
    setTimeout(tgLoad, 10000);
  }).catch(function(e){ tgMsg2('失败：' + e, false); });
}

document.addEventListener('click', function(e){
  var el = e.target.closest && e.target.closest('[data-page="dashboard"]');
  if(el) setTimeout(tgLoad, 150);
});
(function(){ if(tgQ('tgSlots')){ tgLastData = tgSeedData(); tgRender(tgLastData); tgLoad(); setInterval(tgLoad, 5000); setInterval(tgLoadLive, 500); } })();


/* ========== Theme Toggle (Dark/Light) ========== */
(function() {
  var saved = localStorage.getItem('mmdvm-theme-mode') || (localStorage.getItem('arch-theme-mode') === 'day' ? 'light' : 'dark');
  if (saved === 'light') document.body.classList.add('light');
  var el = document.getElementById('themeBtn');
  if (el) el.textContent = saved === 'dark' ? '\u{1F319}' : '\u2600\uFE0F';
})();

function toggleTheme() {
  var isLight = document.body.classList.contains('light');
  if (isLight) {
    document.body.classList.remove('light');
    localStorage.setItem('mmdvm-theme-mode', 'dark');
  } else {
    document.body.classList.add('light');
    localStorage.setItem('mmdvm-theme-mode', 'light');
  }
  try { localStorage.setItem('arch-theme-mode', isLight ? 'night' : 'day'); } catch(e) {}
  var el = document.getElementById('themeBtn');
  if (el) el.textContent = isLight ? '\u{1F319}' : '\u2600\uFE0F';
}



/* ===== 日间 / 夜间模式（系统设置底部开关）===== */
function themeModeApply(mode){
  var light = (mode === "day");
  try { document.body.classList.toggle("light", light); } catch(e) {}
  var b = document.getElementById("themeModeBtn");
  var h = document.getElementById("themeModeHint");
  if(b) b.textContent = light ? "🌙 切换到夜间模式" : "☀️ 切换到日间模式";
  if(h) h.textContent = light ? "当前：日间模式（浅色）" : "当前：夜间模式（深色）";
  try { localStorage.setItem("arch-theme-mode", light ? "day" : "night"); } catch(e) {}
  try { localStorage.setItem("mmdvm-theme-mode", light ? "light" : "dark"); } catch(e) {}
  var themeIcon = document.getElementById("themeBtn");
  if(themeIcon) themeIcon.textContent = light ? "☀️" : "🌙";
}
function themeModeToggle(){
  themeModeApply(document.body.classList.contains("light") ? "night" : "day");
  try { if(typeof toast === "function") toast(document.body.classList.contains("light") ? "已切换到日间模式" : "已切换到夜间模式", true); } catch(e) {}
}
try { var _tm = null; try { _tm = localStorage.getItem("arch-theme-mode"); } catch(e) {} if(_tm === "day") themeModeApply("day"); } catch(e) {}
function tetraConfigUrl(){ return "http://" + location.hostname + ":8080/"; }
async function tetraReadiness(){
  try{
    var r = await fetch("/api/tetra/ready", {credentials:"same-origin", cache:"no-store"});
    return await r.json();
  }catch(e){ return null; }
}
async function waitTetraReady(timeoutMs){
  var end = Date.now() + (timeoutMs || 90000);
  while(Date.now() < end){
    var d = await tetraReadiness();
    if(d && d.ok && d.http_ready) return d;
    await new Promise(function(resolve){ setTimeout(resolve, 1500); });
  }
  return null;
}
async function openTetraConfig(){
  var now = await tetraReadiness();
  if(now && now.http_ready){ window.open(tetraConfigUrl(), "_blank"); return; }
  if(!confirm("TDR Link基站当前未运行。\n\n是否现在切换到 TDR Link模式？\n切换会停止常规热点，通常需要 10-30 秒。")) return;
  if(typeof toast === "function") toast("正在切换到 TDR Link 模式，请稍候…", "ok");
  try{
    var r = await fetch("/api/tetra/switch", {method:"POST", headers:{"Content-Type":"application/json"}, credentials:"same-origin", body:JSON.stringify({what:"tetra"})});
    var d = await r.json();
    if(!r.ok || !d || d.ok === false){ alert("切换失败：" + ((d && d.error) || "未知原因")); return; }
    if(typeof toast === "function") toast("TDR Link 已启动，正在等待 8080 配置页就绪…", "ok");
    var ready = await waitTetraReady(90000);
    if(!ready){ alert("TDR Link 模式已切换，但 8080 配置页尚未就绪，请稍后再点一次。"); return; }
    window.open(tetraConfigUrl(), "_blank");
  }catch(e){ alert("切换请求失败：" + e); }
}
window.openTetraConfig = openTetraConfig;
function modeSwitch(what){
  if(what === "tetra"){ openTetraConfig(); return; }
  if(!confirm("切回常规模式？\n\n会停掉 TDR Link基站，恢复六个通道的多模热点。")) return;
  fetch("/api/tetra/switch", {method:"POST", headers:{"Content-Type":"application/json"}, credentials:"same-origin", body:JSON.stringify({what:"mmdvm"})})
    .then(function(r){ return r.json(); })
    .then(function(d){
      if(d && d.ok === false){ alert("切换失败：" + (d.error || "未知原因")); return; }
      alert("已切换回常规模式");
      location.reload();
    })
    .catch(function(e){ alert("切换请求失败：" + e); });
}
function modeRefresh(){
  fetch("/api/tetra/status", {credentials: "same-origin"}).then(function (r) { return r.json(); }).then(function (st) {
    var te = (st && st.service === "active"), mm = (st && st.mmdvm === "active");
    var h = document.getElementById("modeHint");
    if (h) { h.textContent = te ? "当前：TDR Link 模式（TDR Link）" : (mm ? "当前：常规模式（多模热点）" : "当前：未运行"); }
  }).catch(function () {});
}
if (document.getElementById("modeSwitchCard")) { modeRefresh(); }
</script>
</body>
</html>'''

@app.route('/')
def index():
    return render_template_string(HTML)

@app.route('/api/status')
def api_status():
    return jsonify(get_telemetry(request.args.get("light") == "1"))

@app.route('/api/ports')
def api_ports():
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'ports'])
    ports = {}
    if res['ok']:
        for line in res['stdout'].split('\n'):
            if '|' in line:
                parts = line.split('|')
                if len(parts) >= 3:
                    ports[parts[0]] = {
                        'proc': parts[1] if parts[1] != '-' else None,
                        'state': parts[2]
                    }
    return jsonify(ports)

@app.route('/api/switch/<mode>', methods=['POST'])
def api_switch(mode):
    allowed = {'DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG'}
    if mode not in allowed:
        return jsonify({'success': False, 'message': '不支持的模式'}), 400
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'switch', mode], timeout=90)
    if res['ok']:
        # 只返回简短消息，绝不返回完整 stdout
        return jsonify({'success': True, 'message': '已切换到 ' + mode + ' 模式'})
    else:
        # 错误也截断到 200 字符
        err = (res['stderr'] or '切换失败').strip()[:200]
        return jsonify({'success': False, 'message': err}), 500

@app.route('/api/config/<mode>')
def api_config(mode):
    allowed = {'RF','DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG'}
    if mode not in allowed:
        return jsonify({}), 400
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'get-config', mode])
    config = {}
    if res['ok']:
        cur_sec = None
        for raw in res['stdout'].split('\n'):
            line = raw.strip()
            if line.startswith('=== ') and line.endswith(' ==='):
                cur_sec = line.replace('=== ','').replace(' ===','')
                config[cur_sec] = {}
            elif '=' in line and cur_sec:
                k, v = line.split('=', 1)
                config[cur_sec][k] = v
    return jsonify(config)

def _cur_freqs():
    """实际频点优先读取通道规划，避免 Host 旧元数据把界面和电台带偏。"""
    _, item = plan_primary()
    if item:
        return str(item.get('rx') or ''), str(item.get('tx') or '')
    rx = tx = ''
    for p in ('/etc/MMDVM-Host.ini', '/etc/MMDVM-Host-1.ini'):
        try:
            cur = None
            for ln in open(p, encoding='utf-8', errors='replace'):
                s = ln.strip()
                if s.startswith('[') and s.endswith(']'):
                    cur = s[1:-1]
                elif cur == 'Modem' and s.lower().startswith('rxfrequency=') and not rx:
                    rx = s.split('=', 1)[1].strip()
                elif cur == 'Modem' and s.lower().startswith('txfrequency=') and not tx:
                    tx = s.split('=', 1)[1].strip()
            if rx and tx:
                break
        except Exception:
            pass
    if not rx or not tx:
        try:
            cur = None
            for ln in open('/etc/MMDVM-Multi.ini', encoding='utf-8', errors='replace'):
                s = ln.strip()
                if s.startswith('[') and s.endswith(']'):
                    cur = s[1:-1]
                elif cur == 'Modem' and s.lower().startswith('rxfrequency=') and not rx:
                    rx = s.split('=', 1)[1].strip()
                elif cur == 'Modem' and s.lower().startswith('txfrequency=') and not tx:
                    tx = s.split('=', 1)[1].strip()
        except Exception:
            pass
    return rx, tx


def _ini_get(path, sec, key, dv=''):
    """读取 ini 某段的键值（表单留空时用于"保持原值"）"""
    cur = None
    try:
        for ln in open(path, encoding='utf-8', errors='replace'):
            s = ln.strip()
            if s.startswith('[') and s.endswith(']'):
                cur = s[1:-1]
            elif cur == sec and '=' in s and not s.startswith(';') and not s.startswith('#'):
                k, v = s.split('=', 1)
                if k.strip().lower() == key.lower():
                    return v.strip()
    except Exception:
        pass
    return dv


def _flat_get(path, key, dv=''):
    try:
        for ln in open(path, encoding='utf-8', errors='replace'):
            s = ln.strip()
            if s and not s.startswith('#') and '=' in s:
                k, v = s.split('=', 1)
                if k.strip().lower() == key.lower():
                    return v.strip()
    except Exception:
        pass
    return dv


def _call_strip(v):
    """去掉 ircDDB 呼号尾缀（'NOCALL  G' -> 'NOCALL'）"""
    s = re.sub(r'\s+', ' ', str(v or '').strip()).upper()
    return re.sub(r'\s+[A-Z]$', '', s)


@app.route('/api/save/<mode>', methods=['POST'])
def api_save(mode):
    data = request.get_json() or {}
    cmd = ['sudo', '/usr/local/bin/web-manage.sh']

    # 频率兜底：RF / DMR / YSF 表单可能不含频率字段，避免 valid_freq 报错
    if mode in ('DMR', 'YSF', 'FM'):
        # 注意：RF 页已无频率输入 —— 不在这里补频率，避免把旧值当新值写回配置
        frx, ftx = _cur_freqs()
        if not data.get('rxfreq'):
            data['rxfreq'] = frx
        if not data.get('txfreq'):
            data['txfreq'] = ftx
    if mode == 'DMR':
        # 表单留空 -> 用当前配置兜底（修复"BM 主机不能为空"导致保存失败）
        if not data.get('bm_host'):
            data['bm_host'] = _ini_get('/etc/DMRGateway.ini', 'DMR Network 1', 'Address')
        if not data.get('bm_port'):
            data['bm_port'] = _ini_get('/etc/DMRGateway.ini', 'DMR Network 1', 'Port', '62031')
        if not data.get('bm_pw'):
            data['bm_pw'] = _ini_get('/etc/DMRGateway.ini', 'DMR Network 1', 'Password')
        if not data.get('id'):
            data['id'] = _ini_get('/etc/MMDVM-Host.ini', 'General', 'Id')
        if not data.get('callsign'):
            data['callsign'] = _ini_get('/etc/MMDVM-Host.ini', 'General', 'Callsign')
    if mode == 'D-STAR':
        if not data.get('gw_call'):
            data['gw_call'] = _call_strip(_flat_get('/etc/ircddbgateway', 'gatewayCallsign',
                                          _ini_get('/etc/MMDVM-Host.ini', 'General', 'Callsign')))
        if not data.get('rep_call'):
            data['rep_call'] = _call_strip(_flat_get('/etc/ircddbgateway', 'repeaterCall1',
                                           data.get('gw_call', '')))
        data['ircddb_host'] = data.get('ircddb_host') or _flat_get(
            '/etc/ircddbgateway', 'ircddbHostname', 'rr.ircddb.net')

    if mode == 'RF':
        data['duplex'] = '1'          # 本系统固定双工
        if not data.get('id'):
            data['id'] = ''

    if mode == 'RF':
        cmd += ['save-rf',
                data.get('callsign',''), data.get('id',''),
                data.get('rxfreq',''), data.get('txfreq',''),
                data.get('duplex','1'), data.get('power','1'),
                data.get('rxgain','50'), data.get('txgain','30')]
    elif mode == 'DMR':
        cmd += ['save-dmr',
                data.get('callsign',''), data.get('id',''),
                data.get('cc','1'), data.get('rxfreq',''), data.get('txfreq',''),
                data.get('bm_host',''), data.get('bm_port','62031'),
                data.get('bm_pw',''), data.get('essid','')]
    elif mode == 'YSF':
        cmd += ['save-ysf',
                data.get('callsign',''), data.get('id',''),
                data.get('suffix','RPT') or 'RPT', data.get('startup',''),
                data.get('wiresx','1'), data.get('uppermode','')]
    elif mode == 'P25':
        cmd += ['save-p25',
                data.get('callsign',''), data.get('nac','293'),
                data.get('static',''), '120', '60']
    elif mode == 'NXDN':
        cmd += ['save-nxdn',
                data.get('callsign',''), data.get('id',''),
                data.get('ran','1'), data.get('protocol','Icom'), data.get('static',''),
                '120', '60']
    elif mode == 'D-STAR':
        # ★ 本机模块字母跟随“模块呼号”尾字母（NOCALL B -> B），没写尾字母则默认 B
        _rep_raw = str(data.get('rep_call','') or '').strip()
        _mb = re.search(r'([A-Za-z])\s*$', _rep_raw)
        _band = _mb.group(1).upper() if _mb else 'B'
        cmd += ['save-dstar',
                _call_strip(data.get('gw_call','')), _call_strip(_rep_raw),
                _band, data.get('irc_user',''), data.get('irc_pw',''),
                data.get('rxfreq',''), data.get('txfreq',''),
                data.get('reflector1',''),
                data.get('atStartup1','1'),
                data.get('callsign_routing',''),
                data.get('time_announce',''),
                data.get('use_dplus_xrf',''),
                data.get('ircddb_host','')]
    elif mode == 'FM':
        cmd += ['save-fm',
                data.get('callsign',''), data.get('ctcss','88.5'),
                '30', '20',
                data.get('rxfreq',''), data.get('txfreq','')]
    elif mode == 'POCSAG':
        cmd += ['save-pocsag', data.get('freq','439500000')]
    else:
        return jsonify({'success': False, 'message': 'NONE'}), 400

    res = run_cmd(cmd, timeout=60)
    msg = (res['stdout'] if res['ok'] else (res['stderr'] or '保存失败')).strip()
    # 失败时返回 500，前端才会走 "保存失败: xxx" 分支（原来恒返回 200 导致提示被吞）
    return jsonify({'success': bool(res['ok']), 'ok': bool(res['ok']),
                    'message': msg[:400]}), (200 if res['ok'] else 500)

# ==================== TDR Link模式状态（首页宫格用） ====================
_TETRA_LOG_RE = re.compile(r"(\d{2}:\d{2}:\d{2})\.\d+.*?rx_tpsap_prim got NormalTrainSeq(\d) in fullslot")
_TETRA_BCAST_RE = re.compile(r"(\d{2}:\d{2}:\d{2})\.\d+.*?hyperframe broadcast slot")


def _tetra_slot_snapshot():
    """读取基站时隙活动与当前真实在网终端。

    在线终端只取自 FlowStation 当前 ms_map 快照，不再读取 recovery_cache，
    避免手台关机后仍被历史缓存显示为在线。
    """
    slots = {i: {"n": i, "last": "", "seq": "", "count": 0} for i in range(1, 5)}
    bcast = ""
    try:
        rr = subprocess.run(["sudo", "-n", "/usr/local/bin/web-manage.sh", "logs", "tetra-bs", "800"],
                            capture_output=True, text=True, timeout=30)
        for ln in (rr.stdout or "").splitlines():
            m = _TETRA_LOG_RE.search(ln)
            if m:
                mm = re.search(r"\b\d+/\d+/\d+/(\d)\b", ln)
                n = int(mm.group(1)) if mm else 0
                if n in slots:
                    slots[n]["last"] = m.group(1)
                    slots[n]["seq"] = "NormalTrainSeq" + m.group(2)
                    slots[n]["count"] += 1
                continue
            b = _TETRA_BCAST_RE.search(ln)
            if b:
                bcast = b.group(1)
    except Exception:
        pass

    pub = {}
    try:
        with urllib.request.urlopen("http://127.0.0.1:8080/api/public", timeout=3) as _r:
            decoded = json.loads(_r.read().decode("utf-8", "ignore"))
        if isinstance(decoded, dict):
            pub = decoded
    except Exception:
        pub = {}

    pub_slots = {}
    if isinstance(pub, dict) and isinstance(pub.get("slots"), list):
        for item in pub["slots"]:
            if not isinstance(item, dict):
                continue
            try:
                n = int(item.get("ts"))
            except Exception:
                continue
            if n not in pub_slots:
                pub_slots[n] = item
    for n, slot in slots.items():
        remote = pub_slots.get(n) or {}
        active = bool(remote.get("busy"))
        gssi = remote.get("gssi")
        slot["busy"] = active or slot.get("count", 0) > 0
        slot["gssi"] = str(gssi) if gssi not in (None, "") else ""
        slot["status"] = ("通话中" if remote.get("speaking") else "呼叫中") if active and n != 1 else ""
        slot["call_type"] = str(remote.get("call_type") or "")
        for key in ("caller_issi", "called_issi", "active_speaker", "simplex"):
            slot[key] = remote.get(key)
        if active and not slot.get("last"):
            slot["last"] = time.strftime("%H:%M:%S")

    terms = []
    raw_ms = pub.get("ms") if isinstance(pub, dict) else None
    if isinstance(raw_ms, list):
        callsigns = {}
        ids = []
        for item in raw_ms:
            if isinstance(item, dict) and str(item.get("issi") or "").isdigit():
                ids.append(str(item["issi"]))
        if ids:
            try:
                with urllib.request.urlopen("http://127.0.0.1:8080/api/callsigns?ids=" + ",".join(ids), timeout=3) as _r:
                    cs_data = json.loads(_r.read().decode("utf-8", "ignore"))
                if isinstance(cs_data, dict):
                    callsigns = cs_data
            except Exception:
                callsigns = {}

        for item in raw_ms:
            if not isinstance(item, dict):
                continue
            issi = str(item.get("issi") or "").strip()
            if not issi.isdigit():
                continue
            try:
                age = max(0, int(float(item.get("last_seen_secs_ago", 0))))
            except Exception:
                age = 0
            groups = item.get("groups") or []
            if not isinstance(groups, (list, tuple)):
                groups = [groups]
            raw_rssi = item.get("rssi_dbfs")
            try:
                rssi = f"{float(raw_rssi):.1f} dBFS" if raw_rssi is not None else "—"
            except Exception:
                rssi = "—"
            esm = int(item.get("energy_saving_mode") or 0)
            cs_item = callsigns.get(issi)
            if isinstance(cs_item, dict):
                callsign = cs_item.get("cs") or cs_item.get("callsign") or "—"
            else:
                callsign = cs_item or "—"
            terms.append({
                "issi": issi,
                "callsign": callsign,
                "groups": "、".join(str(x) for x in groups) if groups else "—",
                "rssi": rssi,
                "seen": "刚刚" if age <= 2 else f"{age} 秒前",
                "batt": ("节能 " + str(esm)) if esm else "常驻",
                "age": age,
            })
        terms.sort(key=lambda t: t.get("age", 999999))

    dl = ul = ""
    fb = False
    try:
        rr2 = subprocess.run(["sudo", "-n", "/usr/local/bin/web-manage.sh", "logs", "tetra-bs", "150"],
                             capture_output=True, text=True, timeout=25)
        for ln in (rr2.stdout or "").splitlines():
            if "FALLBACK CONFIG ACTIVE" in ln or "Primary config is invalid" in ln:
                fb = True
            m = re.search(r"DL / UL: ([0-9.]+) MHz / ([0-9.]+) MHz", ln)
            if m:
                dl, ul = m.group(1), m.group(2)
    except Exception:
        pass
    return {"ok": True, "ts": [slots[i] for i in range(1, 5)], "bcast": bcast,
            "terminals": terms[:50], "count": len(terms),
            "base_dl": dl, "base_ul": ul, "fallback": fb}

_TETRA_CALLSIGN_CACHE = {}


def _tetra_live_callsigns(ids):
    """Resolve callsigns through a small in-process cache (no 1-second upstream lookups)."""
    now = time.time()
    out = {}
    missing = []
    for ident in ids:
        item = _TETRA_CALLSIGN_CACHE.get(ident)
        if item and item.get("expires", 0) > now:
            out[ident] = item.get("callsign") or "—"
        elif ident not in missing:
            missing.append(ident)
    if missing:
        try:
            with urllib.request.urlopen("http://127.0.0.1:8080/api/callsigns?ids=" + ",".join(missing), timeout=2) as r:
                data = json.loads(r.read().decode("utf-8", "ignore"))
            if not isinstance(data, dict):
                data = {}
        except Exception:
            data = {}
        for ident in missing:
            item = data.get(ident)
            if isinstance(item, dict):
                callsign = item.get("cs") or item.get("callsign") or "—"
            else:
                callsign = item or "—"
            ttl = 5 if callsign == "—" else 300
            _TETRA_CALLSIGN_CACHE[ident] = {"callsign": callsign, "expires": now + ttl}
            out[ident] = callsign
    return out


def _tetra_live_snapshot():
    """Fast 1-second path for TS2-TS4 and registered terminal state."""
    slots = {i: {"n": i, "last": "", "seq": "", "count": 0, "busy": False, "gssi": "",
                 "status": "", "call_type": "", "caller_issi": None, "called_issi": None,
                 "active_speaker": None, "simplex": None}
             for i in range(1, 5)}
    terms = []
    bcast = ""
    try:
        with urllib.request.urlopen("http://127.0.0.1:8080/api/public", timeout=1) as r:
            pub = json.loads(r.read().decode("utf-8", "ignore"))
    except Exception:
        pub = {}
    if isinstance(pub, dict) and isinstance(pub.get("slots"), list):
        for item in pub["slots"]:
            if not isinstance(item, dict):
                continue
            try:
                n = int(item.get("ts"))
            except Exception:
                continue
            if n not in (2, 3, 4):
                continue
            active = bool(item.get("busy"))
            gssi = item.get("gssi")
            slots[n]["busy"] = active
            slots[n]["gssi"] = str(gssi) if gssi not in (None, "") else ""
            slots[n]["status"] = ("通话中" if item.get("speaking") else "呼叫中") if active else ""
            slots[n]["call_type"] = str(item.get("call_type") or "")
            for key in ("caller_issi", "called_issi", "active_speaker", "simplex"):
                slots[n][key] = item.get(key)
            if active:
                slots[n]["last"] = time.strftime("%H:%M:%S")
    raw_ms = pub.get("ms") if isinstance(pub, dict) else None
    if isinstance(raw_ms, list):
        ids = [str(x.get("issi")) for x in raw_ms if isinstance(x, dict) and str(x.get("issi") or "").isdigit()]
        live_callsigns = _tetra_live_callsigns(ids)
        for item in raw_ms:
            if not isinstance(item, dict):
                continue
            issi = str(item.get("issi") or "").strip()
            if not issi.isdigit():
                continue
            try:
                age = max(0, int(float(item.get("last_seen_secs_ago", 0))))
            except Exception:
                age = 0
            groups = item.get("groups") or []
            if not isinstance(groups, (list, tuple)):
                groups = [groups]
            raw_rssi = item.get("rssi_dbfs")
            try:
                rssi = f"{float(raw_rssi):.1f} dBFS" if raw_rssi is not None else "—"
            except Exception:
                rssi = "—"
            esm = int(item.get("energy_saving_mode") or 0)
            terms.append({
                "issi": issi,
                "callsign": live_callsigns.get(issi, "—"),
                "groups": "、".join(str(x) for x in groups) if groups else "—",
                "rssi": rssi,
                "seen": "刚刚" if age <= 2 else f"{age} 秒前",
                "batt": ("节能 " + str(esm)) if esm else "常驻",
                "age": age,
            })
        terms.sort(key=lambda t: t.get("age", 999999))
    return {"ok": True, "ts": [slots[i] for i in (2, 3, 4)], "bcast": bcast,
            "terminals": terms, "count": len(terms)}

@app.route("/api/tetra/live")
def api_tetra_live():
    return jsonify(_tetra_live_snapshot())


@app.route("/api/tetra/slots")
def api_tetra_slots():
    return jsonify(_tetra_slot_snapshot())


@app.route('/api/bm-status')
def api_bm_status():
    """BrandMeister 会话状态：直接解析 DMRGateway 日志，给出人话原因"""
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'logs', 'dmrgateway'], timeout=20)
    raw = (res['stdout'] if res['ok'] else (res['stderr'] or '')) or ''
    lines = [l.strip() for l in raw.replace('\r', '').split('\n') if l.strip()]
    state, reason = 'unknown', '未取得 DMRGateway 日志（服务可能未运行）'
    for line in reversed(lines):
        low = line.lower()
        if 'logged into the master successfully' in low:
            state, reason = 'online', '已登录 BrandMeister Master'
            break
        if 'master is closing down' in low:
            state = 'closed'
            reason = ('BM 主动断开：配置报文里的呼号必须与 BM 注册呼号一致'
                      '（不能是 NOCALL），或该 ESSID/热点密码在 BM 自服务页面未登记')
            break
        if 'login to the master has failed' in low:
            state, reason = 'auth', 'BM 登录失败：热点连接密码不正确'
            break
        if 'timed out' in low:
            state, reason = 'timeout', '连接 BM 超时：网络不通或被防火墙拦截'
            break
        if ('could not lookup' in low) or ('unable to resolve' in low):
            state, reason = 'dns', 'BM 主机名解析失败'
            break
    info = {}
    try:
        r2 = run_cmd(['sudo', '/usr/local/libexec/mmdvm-save.py', 'BMINFO'], timeout=20)
        if r2['ok'] and r2['stdout'].strip():
            info = json.loads(r2['stdout'].strip())
    except Exception:
        info = {}
    info.update({'ok': True, 'state': state, 'reason': reason, 'tail': lines[-6:]})
    return jsonify(info)

@app.route('/api/dstar-flags')
def api_dstar_flags():
    """读取 D-STAR 三个网关开关的当前状态"""
    def flat_get(path, key, dv=''):
        try:
            for ln in open(path, encoding='utf-8', errors='replace'):
                s = ln.strip()
                if s.lower().startswith(key.lower() + '='):
                    return s.split('=', 1)[1].strip()
        except Exception:
            pass
        return dv
    ts = {}
    try:
        for ln in open('/etc/timeserver', encoding='utf-8', errors='replace'):
            s = ln.strip()
            if '=' in s and not s.startswith('#'):
                k, v = s.split('=', 1)
                ts[k.strip().lower()] = v.strip()
    except Exception:
        pass
    flags = {
        'callsign_routing': flat_get('/etc/ircddbgateway', 'ircddbEnabled', '0') or '0',
        'use_dplus_xrf': '1' if os.path.exists('/etc/hostfiles.nodextra') else '0',
        'time_announce': '0' if os.path.exists('/etc/timeserver.dissable') else '1',
        'timeserver_send': ''.join(ts.get('send' + c, '0') for c in 'ABCDE'),
        'timeserver_lang': ts.get('language', '0'),
        'timeserver_interval': ts.get('interval', '2'),
    }
    return jsonify(flags)


@app.route('/api/logs/<service>')
def api_logs(service):
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'logs', service], timeout=15)
    return jsonify({'logs': res['stdout'] if res['ok'] else res['stderr']})

@app.route('/api/bm-test/<host>')
def api_bm_test(host):
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'bm-test', host], timeout=20)
    try:
        return jsonify(json.loads(res['stdout']))
    except: return jsonify({'host': host, 'loss': 'N/A', 'rtt': 'N/A'})

@app.route('/api/diagnose')
def api_diagnose():
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'diagnose'], timeout=30)
    return jsonify({'output': res['stdout'] if res['ok'] else res['stderr']})

@app.route('/api/backup', methods=['POST'])
def api_backup():
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'backup'], timeout=45)
    return jsonify({'message': res['stdout'] if res['ok'] else res['stderr']})

@app.route('/api/update-db/<dbtype>', methods=['POST'])
def api_update_db(dbtype):
    res = run_cmd(['sudo', '/usr/local/bin/web-manage.sh', 'update-db', dbtype], timeout=360)
    msg = (res['stdout'] if res['ok'] else (res['stderr'] or '更新失败'))
    msg = re.sub(r'\x1b\[[0-9;]*m', '', msg or '')
    msg = msg.strip()
    if len(msg) > 2400:
        msg = msg[-2400:]
    return jsonify({'ok': bool(res['ok']), 'message': msg or '更新完成'})

@app.route('/api/restart/<service>', methods=['POST'])
def api_restart(service):
    allowed = {'mmdvmhost','dmrgateway','ysfgateway','p25gateway','nxdngateway',
               'ircddbgatewayd','mmdvm-iq','mmdvm-web','all'}
    if service not in allowed:
        return jsonify({'ok':False,'message':'无效服务'}), 400
    if service == 'all':
        run_cmd(['sudo','systemctl','restart','mmdvm-iq.service'], timeout=30)
        run_cmd(['sudo','systemctl','restart','mmdvmhost.service'], timeout=30)
        run_cmd(['sudo','systemctl','restart','dmrgateway.service'], timeout=30)
    else:
        run_cmd(['sudo','systemctl','restart',f'{service}.service'], timeout=30)
    return jsonify({'ok':True,'message':f'{service} 已重启'})

# DMR 协议网络白名单 / 其它协议黑名单（Pi-Star DMR_Hosts.txt 里混有 XLX 条目）
_DMR_NETS = {'BM', 'DMR', 'TGIF', 'FREEDMR', 'FD', 'DMR+', 'DMRPLUS', 'HB',
             'HBLINK', 'FREESTAR', 'SYSTEMX', 'DMRX', 'DMR-MARC', 'MARC',
             'DAS', 'MMDVM', 'BRANDMEISTER', 'HOTSPOT'}
_NON_DMR_NET = {'XLX', 'REF', 'XRF', 'DCS', 'CCS', 'FCS', 'YSF', 'P25',
                'NXDN', 'M17', 'DSTAR', 'D-STAR', 'WIRESX'}
_NON_DMR_HOST = ('ysf', 'fcs', 'p25', 'nxdn', 'dstar', 'd-star', 'ircddb')


def _is_bm_dmr(m, keep_xlx=False):
    """主机记录是否属于 DMR 协议网络

    · net 在 DMR 白名单里 -> 是
    · net 是 XLX/REF/XRF/DCS/CCS/FCS/YSF/P25/NXDN/M17 等 -> 默认不是
      （XLX 只会出现在 D-STAR 页面的反射器列表里）
    · 都没有时按域名判断，并排除本机伪主机（127.0.0.x / localhost）
    """
    if not isinstance(m, dict):
        return False
    net = str(m.get('net') or '').strip().upper()
    if net in _NON_DMR_NET:
        return bool(keep_xlx and net == 'XLX')
    if net in _DMR_NETS:
        return True
    host = str(m.get('host') or '').strip().lower()
    if not host or host.startswith('127.') or host in ('localhost', '0.0.0.0'):
        return False
    for bad in _NON_DMR_HOST:
        if bad in host:
            return False
    for good in ('brandmeister', 'dmr', 'hblink', 'tgif', 'freedmr',
                 'systemx', 'mmdvm'):
        if good in host:
            return True
    return False


@app.route('/api/bm-list')
def api_bm_list():
    """DMR 服务器列表（hosts-build.py 从 Pi-Star DMR_Hosts.txt 转换）

    · 默认只返回 DMR 协议网络（BrandMeister / TGIF / FreeDMR / DMR+ / HB …）
    · ?net=BM 只看某个网络；?all=1 才会连 XLX 一起返回
    · 返回顺序保持主机库顺序（4602 中国 -> 4501 韩国 -> 其余 BM -> 其它 DMR 网）
    """
    import json as _json
    try:
        with open('/usr/local/etc/hosts/DMR_Hosts.json', encoding='utf-8') as f:
            data = _json.load(f)
    except Exception:
        data = []
    want = (request.args.get('net') or '').strip().upper()
    show_all = (request.args.get('all') or '') in ('1', 'true', 'yes')
    masters, nets = [], {}
    for m in (data if isinstance(data, list) else []):
        if not _is_bm_dmr(m, keep_xlx=show_all):
            continue
        host = (m.get('host') or '').strip()
        if not host:
            continue
        net = (m.get('net') or 'BM').strip().upper()
        nets[net] = nets.get(net, 0) + 1
        if want and net != want:
            continue
        masters.append({
            'id': str(m.get('id', '')),
            'net': net,
            'label': m.get('label', '') or host,
            'region': m.get('region', ''),
            'country': m.get('country', ''),
            'host': host,
            'port': m.get('port', 62031),
            'pw': m.get('pw', ''),
        })
    return jsonify({'masters': masters, 'count': len(masters),
                    'nets': nets, 'total': sum(nets.values()),
                    'net': want})


@app.route('/api/dstar-reflectors')
def api_dstar_reflectors():
    """D-STAR 反射器列表（从 Pi-Star 官方 hosts 转换的 JSON 读取）"""
    import json as _json
    try:
        with open('/usr/local/etc/hosts/DSTAR_Reflectors.json', encoding='utf-8') as f:
            data = _json.load(f)
    except Exception:
        data = {"REF": [], "XRF": [], "DCS": [], "XLX": [], "CCS": []}
    return jsonify(data)


def _reflector_items(data, mode):
    """兼容网关官方 JSON 和旧版网页数组格式。"""
    rows = data.get('items') if isinstance(data, dict) else data
    if rows is None and isinstance(data, dict):
        rows = data.get('reflectors') or []
    if not isinstance(rows, list):
        return []
    out = []
    for it in rows:
        if not isinstance(it, dict):
            continue
        rid = str(it.get('id') if it.get('id') is not None else it.get('designator') or '')
        host = it.get('host') or it.get('ipv4') or it.get('ipv6') or ''
        port = it.get('port') or ''
        if mode == 'YSF':
            country = str(it.get('country') or '')
            name = str(it.get('name') or '')
            if not rid:
                rid = str(it.get('designator') or '')
            if it.get('use_xx_prefix'):
                full_name = 'XX-' + name
            elif country and name:
                full_name = country + '-' + name
            else:
                full_name = name
            out.append({'id': rid, 'name': full_name,
                        'desc': it.get('description') or it.get('desc') or '',
                        'host': host, 'port': port, 'country': country})
        else:
            if not rid:
                rid = str(it.get('designator') or '')
            out.append({'id': rid, 'name': it.get('name') or (mode + '-' + rid),
                        'host': host, 'port': port})
    return out


@app.route('/api/ysf-hosts')
def api_ysf_hosts_list():
    import json as _json
    try:
        with open('/usr/local/etc/hosts/YSFHosts.json', encoding='utf-8') as f:
            items = _reflector_items(_json.load(f), 'YSF')
    except Exception:
        items = []
    return jsonify({'ok': True, 'items': items, 'count': len(items)})


@app.route('/api/p25-hosts')
def api_p25_hosts_list():
    import json as _json
    try:
        with open('/usr/local/etc/hosts/P25Hosts.json', encoding='utf-8') as f:
            items = _reflector_items(_json.load(f), 'P25')
    except Exception:
        items = []
    return jsonify({'ok': True, 'items': items, 'count': len(items)})


@app.route('/api/nxdn-hosts')
def api_nxdn_hosts_list():
    import json as _json
    try:
        with open('/usr/local/etc/hosts/NXDNHosts.json', encoding='utf-8') as f:
            items = _reflector_items(_json.load(f), 'NXDN')
    except Exception:
        items = []
    return jsonify({'ok': True, 'items': items, 'count': len(items)})


@app.route('/api/fcs-hosts')
def api_fcs_hosts_list():
    import json as _json
    try:
        with open('/usr/local/etc/hosts/FCSHosts.json', encoding='utf-8') as f:
            items = _json.load(f)
    except Exception:
        items = []
    return jsonify({'ok': True, 'items': items, 'count': len(items)})


@app.route('/api/hosts-status')
def api_hosts_status():
    """主机 / 反射器库统计（看板展示）"""
    import json as _json
    for p in ('/usr/local/etc/hosts/hosts-index.json',
              '/usr/local/etc/hosts-index.json'):
        try:
            with open(p, encoding='utf-8') as f:
                return jsonify(_json.load(f))
        except Exception:
            continue
    return jsonify({'built': '', 'counts': {}})


@app.route('/api/hosts-rebuild', methods=['POST'])
def api_hosts_rebuild():
    """从本地 .txt 主机文件重建全部 JSON 索引"""
    res = run_cmd(['sudo', '/usr/local/libexec/hosts-build.py', '-v'], timeout=180)
    return jsonify({'ok': res['ok'], 'message': res['stdout'] or res['stderr']})


@app.route('/api/clientlog', methods=['POST'])
def api_clientlog():
    """接收浏览器端 JS 错误 / 诊断信息（写入 /tmp/mmdvm-client.log，用于远程排查）"""
    import time as _t
    d = request.get_json(silent=True) or {}
    try:
        line = '%s | %-8s | %s | %s | line=%s col=%s | build=%s | %s | %s\n' % (
            _t.strftime('%F %T'), str(d.get('kind', '?'))[:12], str(d.get('msg', ''))[:300],
            str(d.get('src', ''))[-70:], str(d.get('line', ''))[:6], str(d.get('col', ''))[:6],
            str(d.get('build', ''))[:24], str(d.get('url', ''))[:100], str(d.get('ua', ''))[:110])
        with open('/tmp/mmdvm-client.log', 'a', encoding='utf-8') as f:
            f.write(line)
    except Exception:
        pass
    return jsonify({'ok': True})


# ==================== 系统设置（WiFi / 系统信息 / 电源 / 时区）====================
_SYS_HELPER = "/usr/local/bin/sys-manage.sh"


_URL_RE = re.compile(r'^https?://[A-Za-z0-9._~:/?#\[\]@!$&()*+,;=%-]{0,200}$')


def _sys_args_ok(act, args):
    """动作参数白名单校验：SSID/密码允许任意可见字符（含中文），其余按类型收紧"""
    for a in args:
        if len(a) > 80:
            return False
        if re.search(r'[\x00-\x1f\x7f]', a):
            return False
        if a.startswith('-'):
            return False
    a0 = args[0] if args else ''
    if act in ('logs',):
        if not re.match(r'^[A-Za-z0-9@._-]{1,60}$', a0):
            return False
        if len(args) > 1 and args[1] and not re.match(r'^\d{1,4}$', args[1]):
            return False
    elif act == 'ping':
        if not re.match(r'^[A-Za-z0-9._:-]{1,60}$', a0):
            return False
    elif act == 'tz':
        if not re.match(r'^[A-Za-z_]+/[A-Za-z0-9_+/-]{1,40}$', a0):
            return False
    elif act == 'wifi-country':
        return False          # ★ 国家码已固定 CN，不再允许从接口传入
    elif act == 'net-info':
        if a0 and a0 not in ('eth0', 'wlan0'):
            return False
    elif act == 'net-set':
        if a0 not in ('eth0', 'wlan0'):
            return False
        if len(args) > 1 and args[1] and not re.match(r'^\d{1,3}(?:\.\d{1,3}){3}$', args[1]):
            return False
        if len(args) > 2 and args[2] and not re.match(r'^(?:\d{1,3}(?:\.\d{1,3}){3}|\d{1,2})$', args[2]):
            return False
        if len(args) > 3 and args[3] and not re.match(r'^\d{1,3}(?:\.\d{1,3}){3}$', args[3]):
            return False
        if len(args) > 4 and args[4] and not re.match(r'^[0-9., ]{0,64}$', args[4]):
            return False
        if len(args) > 5 and args[5] not in ('static', 'dhcp', ''):
            return False
    elif act == 'wifi-radio':
        if a0 not in ('on', 'off'):
            return False
    elif act in ('wifi-connect', 'wifi-hidden'):
        if not a0:
            return False
    elif act == 'wifi-priority':
        if not re.match(r'^-?\d{1,4}$', args[1] if len(args) > 1 else ''):
            return False
    elif act == 'wifi-autoconnect':
        if (args[1] if len(args) > 1 else '') not in ('yes', 'no'):
            return False
    elif act in ('fw-url', 'fw-check', 'fw-download'):
        if a0 and not _URL_RE.match(a0):
            return False
    elif act == 'fw-install':
        if a0 and not re.match(r'^/tmp/[A-Za-z0-9._-]{1,60}$', a0):
            return False
    return True


@app.route('/api/sys/fw/upload', methods=['POST'])
def api_sys_fw_upload():
    """从电脑上传固件安装包（保存到 /tmp/mmdvm-fw.pkg，再交给 fw-install 处理）"""
    import os as _os
    f = request.files.get('file')
    if f is None or not f.filename:
        return jsonify({'ok': False, 'message': '没有收到文件（请选择安装包）'})
    ext = _os.path.splitext(f.filename)[1].lower()
    if ext not in ('.zip', '.gz', '.tgz', '.tar', '.deb', '.xz', '.bin', '.img'):
        return jsonify({'ok': False, 'message': '只支持 .tar.gz / .tgz / .zip / .deb 等压缩包，当前是 ' + ext})
    dst = '/tmp/mmdvm-fw.pkg'
    try:
        f.save(dst)
        size = _os.path.getsize(dst)
    except Exception as e:
        return jsonify({'ok': False, 'message': '保存失败：' + str(e)})
    return jsonify({'ok': True, 'path': dst, 'size': size,
                    'message': '已上传 %s（%.2f MB）→ %s，可点「安装已上传/已下载的包」' % (f.filename, size / 1048576.0, dst)})


def _sys_call(*args, timeout=90):
    try:
        r = subprocess.run(["sudo", "-n", _SYS_HELPER] + [str(a) for a in args],
                           capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout.strip(), r.stderr.strip()
    except Exception as e:
        return -1, "", str(e)


@app.route('/api/sys/info')
def api_sys_info():
    rc, out, err = _sys_call('info', timeout=30)
    data = {}
    for ln in (out or '').splitlines():
        if '=' in ln:
            k, v = ln.split('=', 1)
            data[k.strip()] = v.strip()
    data['ok'] = (rc == 0)
    if err:
        data['error'] = err
    return jsonify(data)


@app.route('/api/sys/net')
def api_sys_net():
    rc, out, err = _sys_call('net', timeout=40)
    return (out or err or '读取失败'), 200, {'Content-Type': 'text/plain; charset=utf-8'}


@app.route('/api/sys/wifi')
def api_sys_wifi():
    rc, out, err = _sys_call('wifi-list', timeout=60)
    items = []
    for ln in (out or '').splitlines():
        f = ln.split(':')
        if len(f) >= 3 and f[1].strip():
            items.append({'use': f[0].strip() == '*', 'ssid': f[1].strip(),
                          'signal': f[2].strip(), 'sec': (f[3].strip() if len(f) > 3 else '')})
    return jsonify({'ok': rc == 0, 'items': items, 'message': (err or '')})


@app.route('/api/sys/wifi/connect', methods=['POST'])
def api_sys_wifi_connect():
    ssid = (request.form.get('ssid') or '').strip()
    pw = request.form.get('password') or ''
    hid = '1' if (request.form.get('hidden') or '') in ('1', 'true', 'on', 'yes') else '0'
    if not ssid:
        return jsonify({'ok': False, 'message': '缺少 SSID'})
    if len(ssid) > 80 or len(pw) > 80:
        return jsonify({'ok': False, 'message': 'SSID 或密码过长'})
    rc, out, err = _sys_call('wifi-connect', ssid, pw, hid, timeout=150)
    return jsonify({'ok': rc == 0, 'message': (out or err)})


@app.route('/api/sys/action', methods=['POST'])
def api_sys_action():
    d = request.get_json(silent=True) or {}
    act = str(d.get('action') or '').strip()
    ALLOW = {'restart-hotspot': '热点服务已重启（约 30 秒后恢复）',
             'restart-web': 'Web 控制台已重启',
             'wifi-disconnect': '已断开 WiFi',
             'reboot': '设备即将重启，请 1 分钟后重新连接',
             'shutdown': '设备即将关机，需要手动上电'}
    # ★ 带参数的动作：act -> 允许的参数个数（参数只能是字面量，防注入）
    ARGED = {'time-status': 0, 'ntp': 1, 'tz': 1, 'timesync': 0,
             'sched-reboot': 3, 'backup': 0, 'logs': 2, 'ping': 1,
             # ---- WiFi ----
             'wifi-status': 0, 'wifi-scan': 0, 'wifi-saved': 0, 'wifi-radio': 1,
             'wifi-country': 1, 'wifi-connect': 3, 'wifi-hidden': 3,
             'wifi-forget': 1, 'wifi-reconnect': 1, 'wifi-info': 1,
             'wifi-priority': 2, 'wifi-autoconnect': 2,
             # ---- 系统更新 / 固件更新 ----
             'sys-upgrade-count': 0, 'sys-update-check': 0, 'sys-update': 0,
             'sys-update-status': 0, 'sys-update-clean': 0,
             'boot-fw-status': 0, 'boot-fw-update': 0,
             'fw-info': 0, 'fw-url': 1, 'fw-check': 1, 'fw-download': 1,
             'fw-install': 1, 'fw-log': 1,
             # ---- 网络参数 / WiFi 自愈 / 单元修正 ----
             'net-info': 1, 'net-set': 6, 'wifi-restore': 0, 'wifi-boot': 0, 'fix-units': 0}
    args = []
    if act in ARGED:
        raw = d.get('args') or []
        if isinstance(raw, str):
            raw = raw.split('\x1f')
        args = [str(x) for x in raw][:ARGED[act]]
        while len(args) < ARGED[act]:
            args.append('')
        if not _sys_args_ok(act, args):
            return jsonify({'ok': False, 'message': '参数非法'})
    elif act not in ALLOW:
        return jsonify({'ok': False, 'message': '不支持的操作'})
    rc, out, err = _sys_call(act, *args, timeout=180)
    # 重启 Web / 重启 / 关机 都会切断本次请求的子进程，返回码不可靠，视为已下发
    ok = (rc == 0) or act in ('reboot', 'shutdown', 'restart-web')
    if act in ARGED:
        # 这类动作把助手原始输出直接回显（日志/诊断/备份等）
        return jsonify({'ok': ok, 'message': (out or err or ''), 'rc': rc})
    msg = ALLOW[act]
    if out:
        msg += ' | ' + out
    elif err:
        msg += ' | ' + err
    return jsonify({'ok': ok, 'message': msg})


# ==================== 多信道扩展（MMDVM-Multi）====================
# 复用本控制台进程与端口：仅追加 /multi 页面 + /api/mc/* 接口，不改动原有页面

_MC_HELPER = "/usr/local/bin/web-multi.sh"


def _mc_call(*args, timeout=90):
    try:
        r = subprocess.run(["sudo", "-n", _MC_HELPER] + [str(a) for a in args],
                           capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout.strip(), r.stderr.strip()
    except Exception as e:
        return -1, "", str(e)


def _mc_reply(*args, timeout=90):
    rc, out, err = _mc_call(*args, timeout=timeout)
    try:
        d = json.loads(out) if out else {}
        if not isinstance(d, dict):
            d = {"ok": rc == 0, "out": out}
    except Exception:
        d = {"ok": rc == 0, "out": out}
    if "ok" not in d:
        d["ok"] = rc == 0
    if err:
        d["stderr"] = err
    return jsonify(d), (200 if d.get("ok") else 500)


MC_HTML = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ARCH 多信道控制台</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#dfe8f5;font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
body.embed .top{display:none}
a{color:#4fc3f7;text-decoration:none}
.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.top h1{font-size:18px;font-weight:650;letter-spacing:.3px}
.badge{font-size:12px;padding:3px 9px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.spacer{flex:1}
button{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:6px 12px;border-radius:7px;font-size:14px;font-family:inherit}
button:hover{background:#1d3260;border-color:#3b5686}
button:active{transform:translateY(1px)}
button.sm{padding:4px 9px;font-size:12px}
button.dngr{background:#3a1a1a;border-color:#6e2626;color:#fca5a5}
.card{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;padding:14px;margin-bottom:14px}
.card>h2{font-size:14px;font-weight:640;color:#9fb6d6;margin-bottom:11px;letter-spacing:.3px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(148px,1fr));gap:10px}
.kv{background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:9px 11px}
.kv .k{font-size:12.5px;color:#7d93b2;letter-spacing:.4px;text-transform:uppercase}
.kv .v{font-size:16px;font-weight:600;margin-top:3px;word-break:break-all}
.chs{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px;margin-bottom:14px}
.ch{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;overflow:hidden}
.ch-hd{display:flex;align-items:center;gap:9px;padding:11px 14px;background:linear-gradient(180deg,#17233a,#111c30);border-bottom:1px solid #1e2b44}
.ch-hd .num{font-size:16px;font-weight:680}
.ch-hd .mode{font-size:12px;font-weight:700;padding:3px 9px;border-radius:6px;background:#1b3050;color:#7dd3fc;letter-spacing:.5px}
.ch-bd{padding:13px 14px}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row+.row{margin-top:9px}
select{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:6px 9px;border-radius:7px;font-size:14px;font-family:inherit}
.dot{width:9px;height:9px;border-radius:99px;background:#4b5563;display:inline-block;flex-shrink:0}
.dot.on{background:#22c55e;box-shadow:0 0 7px #22c55e}
.dot.off{background:#ef4444;box-shadow:0 0 7px #ef4444}
.st{display:flex;align-items:center;gap:6px;font-size:13.5px;color:#9fb6d6}
.qso{margin-top:10px;padding:8px 10px;background:#0c1526;border:1px solid #1b2840;border-radius:8px;font-size:13.5px;color:#9fb6d6;min-height:34px}
.svcs{display:flex;flex-wrap:wrap;gap:8px}
.svc{display:flex;align-items:center;gap:7px;font-size:12.5px;background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:6px 10px}
/* ---- 信道规划器 ---- */
.pl{background:linear-gradient(165deg,#111d33,#0b1322);border:1px solid #1f304d;border-radius:12px;padding:15px;margin-bottom:14px;position:relative;overflow:hidden}
.pl:before{content:"";position:absolute;inset:0 0 auto 0;height:2px;background:linear-gradient(90deg,#22d3ee,#3b82f6,#8b5cf6,transparent)}
.pl h2{font-size:14px;font-weight:660;color:#9fb6d6;margin-bottom:13px;display:flex;align-items:center;gap:8px}
.pl h2 .tag{font-size:12px;font-weight:700;letter-spacing:1.2px;color:#22d3ee;background:#0a2733;border:1px solid #155e75;border-radius:5px;padding:2px 7px}
.chips{display:flex;gap:7px;flex-wrap:wrap}
.chip{cursor:pointer;min-width:40px;text-align:center;padding:7px 13px;border-radius:8px;border:1px solid #2a3a56;background:#0c1526;color:#9fb6d6;font-size:14px;font-weight:650;user-select:none;transition:.14s}
.chip:hover{border-color:#3b5686;color:#cfe0f5}
.chip.on{background:linear-gradient(180deg,#0e7490,#155e75);border-color:#22d3ee;color:#e0f7ff;box-shadow:0 0 12px rgba(34,211,238,.28)}
.plrow{display:grid;grid-template-columns:64px 132px 1fr 1fr;gap:9px;align-items:center;padding:8px 10px;background:#0a1322;border:1px solid #1b2840;border-radius:9px;margin-top:8px}
.plrow .n{font-weight:700;color:#7dd3fc;font-size:14.5px;letter-spacing:.3px}
.plrow.dis{opacity:.34}
.plout{margin-top:12px;padding:11px 13px;background:#070d18;border:1px solid #1b2840;border-radius:9px;font:12.5px/1.6 Consolas,monospace;white-space:pre-wrap;word-break:break-all;color:#9fd8b4;max-height:250px;overflow:auto;display:none}
.lbl{font-size:12.5px;color:#7d93b2;letter-spacing:.5px;text-transform:uppercase;margin-bottom:4px}
.f{display:flex;flex-direction:column}
input[type=text],input[type=number]{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:7px 10px;border-radius:7px;font-size:14.5px;font-family:inherit;width:100%}
input[type=text]:focus,input[type=number]:focus{outline:none;border-color:#22d3ee;box-shadow:0 0 0 2px rgba(34,211,238,.14)}
input[type=number]{-moz-appearance:textfield}
pre{background:#070d18;border:1px solid #1b2840;border-radius:8px;padding:12px;max-height:430px;overflow:auto;font:12.5px/1.5 Consolas,monospace;white-space:pre-wrap;word-break:break-all;color:#b9cbe4}
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:99;display:none;padding:9px 14px;border-radius:11px;background:rgba(13,21,32,.74);border:1px solid rgba(var(--theme-rgb),.42);color:#eaf2ff;font-size:13.5px;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);max-width:min(420px,84vw);text-align:center}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#a7f3d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fecaca}
/* ==================== ★ 整体字号放大 0.5 号 ==================== */
body{font:14.5px/1.6 "Segoe UI","Microsoft YaHei",sans-serif}
.top h1{font-size:18.5px}
.badge{font-size:13.5px}
button{font-size:13.5px}
button.sm{font-size:13.5px}
.card>h2{font-size:14.5px}
.kv .k{font-size:12px}
.kv .v{font-size:15.5px}
.ch-hd .num{font-size:16.5px}
.ch-hd .mode{font-size:13.5px}
.lbl{font-size:12px}
.plrow{grid-template-columns:96px 132px 1fr 1fr}
.plrow .n{font-size:14px}
.plrow .fq{font-size:13px}
input[type=text],input[type=number]{font-size:14px}
select{font-size:13.5px}
.plout,pre{font-size:13px}
#toast{position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:99;display:none;padding:9px 14px;border-radius:11px;background:rgba(13,21,32,.74);border:1px solid rgba(var(--theme-rgb),.42);color:#eaf2ff;font-size:13.5px;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);max-width:min(420px,84vw);text-align:center}

/* ================================================================
   UNIFIED TYPOGRAPHY & SPACING SYSTEM
   ================================================================ */
:root {
  /* Type scale */
  --fs-xs:10.5px;
  --fs-sm:11.5px;
  --fs-base:13px;
  --fs-md:14px;
  --fs-lg:16px;
  --fs-xl:18px;
  --fs-2xl:22px;

  /* Spacing scale */
  --sp-1:4px;
  --sp-2:8px;
  --sp-3:12px;
  --sp-4:16px;
  --sp-5:20px;
  --sp-6:24px;

  /* Radius */
  --r-sm:6px;
  --r-md:8px;
  --r-lg:11px;
  --r-xl:13px;
}

/* --- Unified card padding --- */
.card { padding:var(--sp-4) var(--sp-5); }

/* --- Unified form field spacing --- */
.form-grid .fld { gap:6px; }
.form-grid label { margin-bottom:0; }

/* --- Unified section titles --- */
.freq-banner-txt > b,
.card-title,
.mc-hd {
  letter-spacing:.2px;
}

/* --- Consistent mono font sizing --- */
.stat-val,
.freq-chip,
.mc-freq {
  font-variant-numeric:tabular-nums;
}

/* --- Better line heights --- */
.card-sub,
.db-card p,
.form-grid .hint,
.essid-note,
.freq-strip {
  line-height:1.6;
}

/* --- Unified border radius --- */
.card { border-radius:var(--r-lg); }
.btn { border-radius:var(--r-md); }
.mode-pill { border-radius:var(--r-md); }
.mc-card { border-radius:var(--r-lg); }
.qso-item { border-radius:var(--r-xl); }
.stat { border-radius:var(--r-lg); }
.port-card { border-radius:var(--r-md); }
.db-card { border-radius:var(--r-lg); }
.freq-banner { border-radius:var(--r-lg); }
.preset-box { border-radius:var(--r-lg); }
.sp { border-radius:var(--r-xl); }
.bm-bar { border-radius:var(--r-md); }
.console { border-radius:var(--r-md); }
.freq-chip { border-radius:var(--r-md); }
.freq-row { border-radius:var(--r-md); }
.freq-strip { border-radius:var(--r-md); }
.db-bar { border-radius:var(--r-lg); }
.port-badge { border-radius:14px; }

/* --- Unified transition timing --- */
.card,
.btn,
.mode-pill,
.sb-item,
.port-card,
.db-card,
.qso-item,
.freq-chip,
.sp-chip,
.sp-row,
select,
input {
  transition:all .2s cubic-bezier(.4,0,.2,1);
}

/* --- Light mode: softer body background gradient --- */
body.light {
  background:
    radial-gradient(1200px 600px at 12% -10%, rgba(16,185,129,.03), transparent 60%),
    radial-gradient(900px 500px at 88% 0%, rgba(56,189,248,.03), transparent 55%),
    #e8e3d9 !important;
  background-attachment:fixed !important;
}
body.light::before {
  background:
    linear-gradient(rgba(0,0,0,.015) 1px, transparent 1px),
    linear-gradient(90deg, rgba(0,0,0,.015) 1px, transparent 1px) !important;
  background-size:44px 44px !important;
  mask-image:radial-gradient(circle at 50% 0%, #000 0%, transparent 78%) !important;
  -webkit-mask-image:radial-gradient(circle at 50% 0%, #000 0%, transparent 78%) !important;
}
body.light::after {
  display:none !important;
}


/* ========== FINAL CONTRAST OVERRIDE ========== */
body.light * {
  --muted:#4b5563;
  --dim:#6b7280;
}
body.light [style*="color:var(--muted)"] { color:#4b5563 !important; }
body.light [style*="color:var(--dim)"] { color:#6b7280 !important; }

</style>
</head>
<body>
<div class="top">
  <h1>ARCH 多信道控制台</h1>
  <span class="badge" id="bEngine">引擎 --</span>
  <span class="badge" id="bCh">信道 --</span>
  <span class="spacer"></span>
  <span class="badge" id="bTime">--</span>
  <button onclick="refresh()">立即刷新</button>
  <button class="sm" onclick="multiRestart()">重启 Multi 引擎</button>
  <a href="/" class="badge">← 返回主控制台</a>
</div>

<div class="pl">
  <h2><span class="tag">PLANNER</span>信道规划 · 自由选择通道数与各通道模式</h2>

  <div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr));margin-bottom:12px">
    <div class="f"><div class="lbl">呼号 Callsign</div>
      <input type="text" id="pCall" placeholder="NOCALL" maxlength="10"></div>
    <div class="f"><div class="lbl">DMR ID</div>
      <input type="text" id="pId" placeholder="000000000" maxlength="12"></div>
  </div>

  <div class="lbl">实际运行的通道数量（1 — 7）</div>
  <div class="chips" id="pChips" style="margin-bottom:12px"></div>

  <div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(220px,1fr));margin:4px 0 12px">
    <div class="f"><div class="lbl">RX 中心频率（Hz，SDR 基准 = 通道 1）</div>
      <input type="number" id="pCrx" step="25000" value="434500000" oninput="planFreqs()"></div>
    <div class="f"><div class="lbl">TX 中心频率（Hz）</div>
      <input type="number" id="pCtx" step="25000" value="439500000" oninput="planFreqs()"></div>
  </div>

  <div class="lbl">各通道模式与频率（Hz）—— 频率可以直接修改</div>
  <div id="pRows"></div>

  <div class="lbl" style="margin-top:10px;line-height:1.75;text-transform:none;color:#7d93b2">
    ⓘ 多路射频同时工作，不需要切换：每路各自跑自己的模式。<br>
    ⓘ 频率可以自由修改：改任意一路会以「它」为准整体平移（因为 SX1255 硬件各通道固定相隔 25 kHz）。<br>
    ⓘ 例如把通道 2 改成 434.5375，中心会自动变成 434.5125，通道 1/3/4 跟着变 434.5125/434.5625/434.5875。<br>
    ⓘ 表格里显示的 MHz 就是设备真实的收发频率，对讲机请按它设置。
  </div>

  <div class="row" style="margin-top:13px">
    <button onclick="planLoad()">读取当前规划</button>
    <button onclick="planApply()" style="background:#0e7490;border-color:#22d3ee;color:#e0f7ff">应用规划（写入配置并重启）</button>
    <button onclick="planSave()">仅保存规划</button>
  </div>
  <div class="plout" id="pOut"></div>
</div>

<div class="card">
  <h2>多信道引擎（SX1255 共享 I/Q 前端）</h2>
  <div class="grid" id="eng">加载中...</div>
</div>

<div class="chs" id="chs"></div>

<div class="card">
  <h2>服务状态</h2>
  <div class="svcs" id="svcs">加载中...</div>
</div>

<div class="card">
  <h2>日志</h2>
  <div class="row">
    <select id="logCh"></select>
    <select id="logSvc">
      <option value="mmdvmhost">MMDVM-Host</option>
      <option value="mmdvm-iq">MMDVM-IQ</option>
      <option value="mmdvm-multi">ARCH 融合中枢</option>
      <option value="dmrgateway">DMRGateway</option>
      <option value="ircddbgatewayd">ircDDBGateway (D-STAR)</option>
      <option value="ysfgateway">YSFGateway</option>
      <option value="p25gateway">P25Gateway</option>
      <option value="nxdngateway">NXDNGateway</option>
    </select>
    <select id="logLines">
      <option value="100">100 行</option>
      <option value="200" selected>200 行</option>
      <option value="500">500 行</option>
    </select>
    <button onclick="loadLog()">加载日志</button>
  </div>
  <pre id="logBox">点击“加载日志”查看。</pre>
</div>

<div id="toast"></div>
<script>
if(location.search.indexOf('embed=1')>=0)document.body.className='embed';
function $(i){return document.getElementById(i);}
function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
function dot(on){return '<span class="dot '+(on?"on":"off")+'"></span>';}
function kv(k,v){return '<div class="kv"><div class="k">'+esc(k)+'</div><div class="v">'+esc(v)+'</div></div>';}
function toast(m,ok){
  var t=$("toast"); t.textContent=m; t.className=(ok===false?"bad":(ok===true?"ok":""));
  t.style.display="block"; clearTimeout(window._tt);
  window._tt=setTimeout(function(){t.style.display="none";},4200);
}
var MODES=["DMR","D-STAR","YSF","P25","NXDN","FM","POCSAG","NONE"];

function post(u,b){
  return fetch(u,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b||{})}).then(function(r){return r.json();});
}
function refresh(){
  fetch("/api/mc/channels?light=1").then(function(r){return r.json();}).then(function(d){
    if(!d||d.ok===false){
      $("bEngine").textContent="读取失败"; $("bEngine").className="badge bad";
      return;
    }
    render(d);
    $("bTime").textContent=new Date().toLocaleTimeString();
  }).catch(function(e){ toast("网络错误: "+e,false); });
}
function render(d){
  var e=d.multi||{};
  $("bEngine").textContent = e.active ? "引擎运行中" : "引擎停止";
  $("bEngine").className = "badge "+(e.active?"ok":"bad");
  $("bCh").textContent = "信道 "+(d.channels||[]).length+" / 配置 "+e.channels;
  $("eng").innerHTML = kv("状态", e.active?"运行":"停止")+kv("信道数", e.channels)
    +kv("采样率", e.rate?e.rate+" Hz":"--")+kv("SDR 类型", e.sdr||"--")
    +kv("RX 中心", e.rx?e.rx+" Hz":"--")+kv("TX 中心", e.tx?e.tx+" Hz":"--")
    +kv("RX 增益", e.rxg==null?"--":e.rxg)+kv("TX 增益", e.txg==null?"--":e.txg);

  var cs=d.channels||[], h="";
  for(var i=0;i<cs.length;i++){
    var c=cs[i];
    h+='<div class="ch"><div class="ch-hd"><span class="num">射频通道 '+mcSeq(c.n)+'</span>'
      +'<span class="mode">'+esc(c.mode)+'</span><span class="spacer"></span>'
      +dot(c.iq)+'<span class="st">IQ</span>'+dot(c.host)+'<span class="st">Host</span></div>'
      +'<div class="ch-bd">'
      +'<div class="grid" style="grid-template-columns:1fr 1fr">'
        +kv("RX 频率", c.rx+" Hz")+kv("TX 频率", c.tx+" Hz")
        +kv("Multi 端口", c.mport)+kv("Host 端口", c.hport)
      +'</div>'
      +'<div class="row" style="margin-top:11px">'
        +'<span class="lbl" style="text-transform:none">模式由「信道规划」分配；'
        +((PLN&&PLN.channels)||MC_SEQ_CHS.length||1)+' 路同时工作，无需切换</span>'
        +'<button class="sm" onclick="restartCh('+c.n+')">重启本通道</button>'
      +'</div>'
      +'<div class="qso">最近通信: '+esc(c.lastq||"（无）")+'</div>'
      +'</div></div>';
  }
  $("chs").innerHTML = h || '<div class="card">未检测到信道配置</div>';

  var sv=d.services||[], sh="";
  for(var k=0;k<sv.length;k++){ sh+='<div class="svc">'+dot(sv[k].active)+esc(sv[k].name)+'</div>'; }
  $("svcs").innerHTML=sh||"无";
  fillLogCh(cs);
}
function fillLogCh(cs){
  var sel=$("logCh"), cur=sel.value, h='<option value="0">（引擎级）</option>';
  mcSeqBuild(cs);
  for(var i=0;i<cs.length;i++){
    if(String(cs[i].mode||'NONE').toUpperCase()==='NONE') continue;
    h+='<option value="'+cs[i].n+'">'+mcSeq(cs[i].n)+'. '+esc(cs[i].mode)+'</option>';
  }
  sel.innerHTML=h;
  if(cur){ sel.value=cur; }
}
function restartCh(n){
  toast("通道 "+n+" 正在重启 ...");
  post("/api/mc/restart",{ch:n}).then(function(r){
    toast(r.ok?("通道 "+n+" 已重启"):("失败: "+(r.error||r.out)),r.ok);
    setTimeout(refresh,1800);
  }).catch(function(e){ toast("错误: "+e,false); });
}
function multiRestart(){
  toast("正在重启 MMDVM-Multi 引擎 ...");
  post("/api/mc/multi-restart",{}).then(function(r){
    toast(r.ok?"MMDVM-Multi 已重启":"失败: "+(r.error||r.out),r.ok);
    setTimeout(refresh,3000);
  }).catch(function(e){ toast("错误: "+e,false); });
}
function loadLog(){
  var ch=$("logCh").value, svc=$("logSvc").value, ln=$("logLines").value;
  $("logBox").textContent="加载中...";
  fetch("/api/mc/logs?ch="+ch+"&svc="+svc+"&lines="+ln).then(function(r){return r.json();}).then(function(d){
    $("logBox").textContent = d.text || ("> (无输出) "+(d.error||""));
  }).catch(function(e){ $("logBox").textContent="错误: "+e; });
}
/* ==================== 信道规划器 ==================== */
var PLN={channels:2,callsign:"",dmr_id:"",center_rx:434500000,center_tx:439500000,spacing:25000,sample_rate:150000,list:[]};
function pv(id,v){var e=$(id); if(v!==undefined&&v!==null&&v!==""){e.value=v;} return e.value;}
function pnum(id,dv){var v=parseFloat($(id).value); return isNaN(v)?dv:v;}
function planGet(i){
  for(var k=0;k<PLN.list.length;k++){ if(PLN.list[k].n===i){ return PLN.list[k]; } }
  var sp=25000, crx=434500000, ctx=439500000;
  var st=chGridStep(i);
  return {n:i,mode:"NONE",rx:crx+st*sp,tx:ctx+st*sp};
}
function planRender(n){
  if(n){ PLN.channels=n; }
  n=PLN.channels;
  var ch="";
  for(var i=1;i<=MC_DYN_CH();i++){   /* MC_DYN_CH 动态通道数 */
    ch+='<div class="chip'+(i===n?" on":"")+'" onclick="planRender('+i+')">'+i+'</div>';
  }
  $("pChips").innerHTML=ch;
  var h="";
  for(var i=1;i<=MC_DYN_CH();i++){   /* MC_DYN_CH 动态通道数 */
    var c=planGet(i), opts="", dis=(i>n);
    for(var j=0;j<MODES.length;j++){
      opts+='<option value="'+MODES[j]+'"'+(c.mode===MODES[j]?" selected":"")+'>'+MODES[j]+'</option>';
    }
    h+='<div class="plrow'+(dis?" dis":"")+'"><span class="n">射频通道 '+i+'</span>'
      +'<select id="pm'+i+'"'+(dis?" disabled":"")+'>'+opts+'</select>'
      +'<input type="number" id="pr'+i+'" step="2500" '+(dis?"disabled":"")+' oninput="onChFreq('+i+')">'
      +'<input type="number" id="pt'+i+'" step="2500" '+(dis?"disabled":"")+' oninput="onChFreq('+i+')">'
      +'<span class="fq">RX <b id="fr'+i+'">--</b> ／ TX <b id="ft'+i+'">--</b></span>'
      +'</div>';
  }
  $("pRows").innerHTML=h;
  planFreqs();
}
/* ★ 通道 -> 相对中心的 25k 步数：MMDVM-Multi 的 PFB 子带映射
   （本机 SampleRate=150000 ⇒ 6 个 bin，Receiver/Transmitter 里
     m_fillReal = min(6/2-1, 4) = 2，其余通道从高 bin 逆序取）
     ⇒ ch1=bin0(0) ch2=bin1(+25k) ch3=bin5(-25k) ch4=bin4(-50k) ch5=bin3(+75k)
   旧代码用 (i-1)*25k 线性推算，与硬件不符。 */
var CH_GAP=25000;
/* 按采样率推导网格（与 MMDVM-Multi 源码代数一致）：
   n_pfb = SampleRate/25000，m_fill = min(max(n_pfb/2-1,1),4)
   p=ch-1；bin = p<m_fill ? p : (n_pfb-1)-(p-m_fill)；
   频率步数 = bin < n_pfb/2 ? bin : bin-n_pfb
   150k（PFB=6）-> 0/+1/-1/-2/+3；250k（PFB=10）-> 0/+1/+2/+3/-1 */
function chGridStep(i, sr){
  sr = parseInt(sr, 10) || parseInt((typeof PLN === 'object' && PLN && PLN.sample_rate) || 0, 10) || 250000;
  var n = Math.max(1, Math.round(sr / CH_GAP));
  var mf = Math.max(1, Math.min(Math.floor(n/2)-1, 4));   /* 与 MMDVM-Multi 源码：m_fillReal = PFB/2 */
  var p = i - 1;
  var b = (p < mf) ? p : (n - 1) - (p - mf);
  return (b * 2 <= n) ? b : b - n;   /* bin == n/2 是 +fs/2（正频边缘）*/
}
function chanFreq(i,c){ return c + chGridStep(i)*CH_GAP; }
function fmtMHz(hz){ return (hz/1e6).toFixed(4)+" MHz"; }
function planFreqs(){
  var e1=$("pCrx"), e2=$("pCtx");
  var crx=parseInt(e1&&e1.value,10)||0, ctx=parseInt(e2&&e2.value,10)||0;
  for(var i=1;i<=MC_DYN_CH();i++){   /* MC_DYN_CH 动态通道数 */
    var r=$("pr"+i), t=$("pt"+i);
    if(r && crx){ r.value = String(chanFreq(i,crx)); }
    if(t && ctx){ t.value = String(chanFreq(i,ctx)); }
    if($("fr"+i)){ $("fr"+i).textContent = crx?fmtMHz(chanFreq(i,crx)):"--"; }
    if($("ft"+i)){ $("ft"+i).textContent = ctx?fmtMHz(chanFreq(i,ctx)):"--"; }
  }
}
/* 用户改某一路频率 -> 以该路为准反推中心频率，然后整体刷新（其它通道按网格跟着走） */
function onChFreq(i){
  var e1=$("pr"+i), e2=$("pt"+i);
  var r=parseInt(e1&&e1.value,10), t=parseInt(e2&&e2.value,10);
  var st=chGridStep(i);
  if(r>0 && $("pCrx")){ $("pCrx").value = String(r-st*CH_GAP); }
  if(t>0 && $("pCtx")){ $("pCtx").value = String(t-st*CH_GAP); }
  planFreqs();
}
function planCollect(){
  var lst=[];
  for(var i=1;i<=MC_DYN_CH();i++){   /* MC_DYN_CH 动态通道数 */
    if(!$("pm"+i)) continue;                 // 行不存在则跳过
    /* ★ 只提交模式：通道频率由后端按硬件网格生成（ch1=0 ch2=+25k ch3=-25k ch4=-50k ch5=+75k） */
    lst.push({n:i, mode:$("pm"+i).value});
  }
  if(!lst.length) return null;
  var e1=$("pCrx"), e2=$("pCtx");
  var crx=parseInt(e1&&e1.value,10)||PLN.center_rx||434500000;
  var ctx=parseInt(e2&&e2.value,10)||PLN.center_tx||439500000;
  return {channels:MC_DYN_CH(), callsign:pv("pCall"), dmr_id:pv("pId"),
          /* 中心频率 = 通道 1；其余通道按 25 kHz 网格自动推算 */
          center_rx:crx, center_tx:ctx,
          spacing:25000, sample_rate:(PLN.sample_rate||150000), list:lst};
}
function planShow(msg,ok){
  var o=$("pOut"); o.style.display="block"; o.textContent=msg;
  o.style.color=(ok===false)?"#fca5a5":"#9fd8b4";
}
function planLoad(){
  fetch("/api/mc/plan").then(function(r){return r.json();}).then(function(d){
    if(!d||d.ok===false||!d.list||!d.list.length){
      planShow("读取失败: "+((d&&d.error)||"无数据"),false); return;
    }
    PLN=d;
    pv("pCall",d.callsign); pv("pId",d.dmr_id);
    if($("pCrx")){ $("pCrx").value=d.center_rx||434500000; }
    if($("pCtx")){ $("pCtx").value=d.center_tx||439500000; }
    if(d.sample_rate){ PLN.sample_rate=d.sample_rate; }
    planRender(d.channels);
  }).catch(function(e){ planShow("网络错误: "+e,false); });
}
function planPush(mode){
  var p=planCollect();
  if(!p){ planShow("没有可用的通道行", false); return; }
  if(mode==="apply"&&!confirm("将写入全部配置文件并重启 MMDVM-Multi 与全部通道。\n\n请遵守所在国家或地区关于无线电发射设备、频率使用和操作资格的法律法规。因违规使用造成的后果由操作者承担，与本软件及作者无关。\n\n继续？")){ return; }
  planShow(mode==="apply"?"正在应用规划（约 30-60 秒，含服务重启）...":"正在保存规划...");
  fetch("/api/mc/plan-"+mode,{method:"POST",
      headers:{"Content-Type":"application/json"},body:JSON.stringify(p)})
  .then(function(r){return r.json();}).then(function(d){
    var ok=(d&&d.ok)!==false;
    planShow((ok?"[OK] ":"[失败] ")+((d&&(d.message||d.out||d.error))||JSON.stringify(d)),ok);
    if(ok){ setTimeout(refresh,1500); setTimeout(planLoad,2000); try{ if(typeof mcInvalidateChannels === 'function') mcInvalidateChannels(); }catch(e){} }
  }).catch(function(e){ planShow("网络错误: "+e,false); });
}
function planApply(){ planPush("apply"); }
function planSave(){ planPush("save"); }
planLoad();

refresh();
setInterval(refresh,5000);
</script>
</body>
</html>'''


@app.route("/rfmode")
def rfmode_page_view():
    return render_template_string(RFMODE_STANDALONE)


@app.route("/multi")
def mc_page():
    return render_template_string(MC_HTML)


@app.route("/api/mc/channels")
def mc_channels():
    light = "light" if request.args.get("light") == "1" else "full"
    return _mc_reply("channels", light, timeout=40)


@app.route("/api/mc/switch", methods=["POST"])
def mc_switch():
    d = request.get_json(silent=True) or {}
    ch, mode = str(d.get("ch", "")), str(d.get("mode", ""))
    if not ch or not mode:
        return jsonify({"ok": False, "error": "缺少 ch / mode"}), 400
    return _mc_reply("switch", ch, mode, timeout=160)


@app.route("/api/mc/restart", methods=["POST"])
def mc_restart():
    d = request.get_json(silent=True) or {}
    ch = str(d.get("ch", ""))
    if not ch:
        return jsonify({"ok": False, "error": "缺少 ch"}), 400
    return _mc_reply("restart", ch, timeout=120)


@app.route("/api/mc/multi-restart", methods=["POST"])
def mc_multi_restart():
    return _mc_reply("multi-restart", timeout=200)


@app.route("/api/mc/logs")
def mc_logs():
    return _mc_reply("logs",
                     request.args.get("ch", "0"),
                     request.args.get("svc", "mmdvmhost"),
                     request.args.get("lines", "200"), timeout=40)


@app.route("/api/mc/plan")
def mc_plan():
    return _mc_reply("plan", timeout=60)


@app.route("/api/mc/plan-apply", methods=["POST"])
def mc_plan_apply():
    body = json.dumps(request.get_json(silent=True) or {}, ensure_ascii=False)
    return _mc_reply("plan-apply", body, timeout=300)


@app.route("/api/mc/plan-save", methods=["POST"])
def mc_plan_save():
    body = json.dumps(request.get_json(silent=True) or {}, ensure_ascii=False)
    return _mc_reply("plan-save", body, timeout=90)


# ==================== 多信道扩展结束 ====================

# ==================== FM 联网配置页 ====================
_FMO_HELPER = "/usr/local/bin/web-fmo.sh"


def _fmo_call(*args, timeout=90):
    try:
        r = subprocess.run(["sudo", "-n", _FMO_HELPER] + [str(a) for a in args],
                           capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout.strip(), r.stderr.strip()
    except Exception as e:
        return -1, "", str(e)


def _fmo_reply(*args, timeout=90):
    rc, out, err = _fmo_call(*args, timeout=timeout)
    try:
        d = json.loads(out) if out else {}
        if not isinstance(d, dict):
            d = {"ok": rc == 0, "out": out}
    except Exception:
        d = {"ok": rc == 0, "out": out}
    if "ok" not in d:
        d["ok"] = rc == 0
    if err:
        d["stderr"] = err
    return jsonify(d), (200 if d.get("ok") else 500)


@app.route("/fmo")
def fmo_page():
    return render_template_string(FMO_HTML)


@app.route("/api/fmo/status")
def fmo_status():
    return _fmo_reply("status", timeout=40)


@app.route("/api/fmo/save", methods=["POST"])
def fmo_save():
    body = json.dumps(request.get_json(silent=True) or {}, ensure_ascii=False)
    return _fmo_reply("save", body)


@app.route("/api/fmo/ping", methods=["POST"])
def fmo_ping():
    d = request.get_json(silent=True) or {}
    return _fmo_reply("ping", d.get("host", ""), d.get("port", 8081), timeout=60)


@app.route("/api/fmo/register", methods=["POST"])
def fmo_register():
    d = request.get_json(silent=True) or {}
    return _fmo_reply("register", d.get("host", ""), d.get("port", 8081),
                      d.get("callsign", ""), d.get("uid", 0), timeout=150)


@app.route("/api/fmo/ctcss")
def fmo_ctcss():
    return _fmo_reply("ctcss")


@app.route("/api/fmo/tailtone")
def fmo_tailtone():
    return _fmo_reply("tailtone", timeout=30)


@app.route("/api/fmo/tailtone/save", methods=["POST"])
def fmo_tailtone_save():
    body = json.dumps(request.get_json(silent=True) or {}, ensure_ascii=False)
    return _fmo_reply("tailtone-set", body, timeout=120)


@app.route("/api/fmo/apply-ctcss", methods=["POST"])
def fmo_apply_ctcss():
    body = json.dumps(request.get_json(silent=True) or {}, ensure_ascii=False)
    return _fmo_reply("apply-ctcss", body, timeout=160)


@app.route("/api/fmo/restart", methods=["POST"])
def fmo_restart():
    d = request.get_json(silent=True) or {}
    return _fmo_reply("restart", d.get("what", "all"), timeout=160)


@app.route("/api/fmo/logs")
def fmo_logs():
    return _fmo_reply("logs", request.args.get("lines", "150"), timeout=40)


@app.route("/api/fmo/tone-test", methods=["POST"])
def fmo_tone_test():
    return _fmo_reply("tone-test")


# ==================== FM 联网配置页结束 ====================


# ==================== TDR Link 配置页 ====================
_TETRA_HELPER = "/usr/local/bin/web-tetra.sh"


def _tetra_reply(act, *args, timeout=120):
    try:
        r = subprocess.run(["sudo", "-n", _TETRA_HELPER, act] + [str(a) for a in args],
                           capture_output=True, text=True, timeout=timeout)
        rc, out, err = r.returncode, r.stdout.strip(), r.stderr.strip()
    except Exception as e:
        rc, out, err = -1, "", str(e)
    try:
        d = json.loads(out) if out else {}
        if not isinstance(d, dict):
            d = {"ok": rc == 0, "out": out}
    except Exception:
        d = {"ok": rc == 0, "out": out}
    d.setdefault("ok", rc == 0)
    if err:
        d["stderr"] = err
    return jsonify(d), (200 if d.get("ok") else 500)


@app.route("/tetra")
def tetra_page():
    return render_template_string(TETRA_HTML)


@app.route("/api/tetra/status")
def tetra_status():
    return _tetra_reply("status", timeout=40)


@app.route("/api/tetra/servers")
def tetra_servers():
    return _tetra_reply("servers", timeout=40)


@app.route("/api/tetra/servers/save", methods=["POST"])
def tetra_servers_save():
    return _tetra_reply("server-save", json.dumps(request.get_json(silent=True) or {},
                                                   ensure_ascii=False), timeout=180)


@app.route("/api/tetra/server-switch", methods=["POST"])
def tetra_server_switch():
    return _tetra_reply("server-switch", json.dumps(request.get_json(silent=True) or {},
                                                     ensure_ascii=False), timeout=180)


@app.route("/api/tetra/server-update", methods=["POST"])
def tetra_server_update():
    return _tetra_reply("server-update", json.dumps(request.get_json(silent=True) or {},
                                                     ensure_ascii=False), timeout=120)


@app.route("/api/tetra/ready")
def tetra_ready():
    return _tetra_reply("ready", timeout=10)


@app.route("/api/tetra/save", methods=["POST"])
def tetra_save():
    return _tetra_reply("save", json.dumps(request.get_json(silent=True) or {},
                                           ensure_ascii=False), timeout=60)


@app.route("/api/tetra/<act>", methods=["POST"])
def tetra_act(act):
    d = request.get_json(silent=True) or {}
    if act == "switch":
        return _tetra_reply("switch", d.get("what", "tetra"), timeout=300)
    if act in ("start", "stop", "restart"):
        return _tetra_reply(act, timeout=300)
    return jsonify({"ok": False, "error": "未知操作"}), 400


@app.route("/api/tetra/logs")
def tetra_logs():
    return _tetra_reply("logs", request.args.get("lines", "150"), timeout=30)



@app.route("/api/tetra/dash")
def tetra_dash():
    return _tetra_reply("dash", timeout=90)



# ==================== TDR Link 配置方案（profiles）====================
@app.route("/api/tetra/profiles", methods=["GET", "POST"])
def tetra_profiles():
    return _tetra_reply("profiles", timeout=40)


@app.route("/api/tetra/profile-save", methods=["POST"])
def tetra_profile_save():
    d = request.get_json(silent=True) or {}
    return _tetra_reply("profile-save", d.get("name", ""), timeout=60)


@app.route("/api/tetra/profile-activate", methods=["POST"])
def tetra_profile_activate():
    d = request.get_json(silent=True) or {}
    return _tetra_reply("profile-activate", d.get("name", ""), timeout=240)


@app.route("/api/tetra/profile-delete", methods=["POST"])
def tetra_profile_delete():
    d = request.get_json(silent=True) or {}
    return _tetra_reply("profile-delete", d.get("name", ""), timeout=60)



# ==================== TDR Link 白名单（whitelist）====================
@app.route("/api/tetra/whitelist", methods=["GET", "POST"])
def tetra_whitelist():
    d = request.get_json(silent=True) or {}
    op = str(d.get("op") or request.args.get("op") or "get")
    arg = str(d.get("arg") or request.args.get("arg") or "")
    return _tetra_reply("whitelist", op, arg, timeout=180)



@app.route("/api/tetra/asterisk-get", methods=["GET", "POST"])
def tetra_ast_get():
    return _tetra_reply("asterisk-get", timeout=40)


@app.route("/api/tetra/asterisk-save", methods=["POST"])
def tetra_ast_save():
    return _tetra_reply("asterisk-save", json.dumps(request.get_json(silent=True) or {},
                                                     ensure_ascii=False), timeout=180)


# ==================== TDR Link 配置页结束 ====================


# ARCH_TETRA_SLOT_CALLSIGN_PATCH
try:
    _arch_orig_tetra_slot_snapshot = _tetra_slot_snapshot

    def _arch_load_callsigns():
        import re
        table = {}
        for fp in ('/usr/local/etc/DMRIds.dat', '/usr/local/etc/nextionUsers.csv', '/usr/local/etc/NXDN.csv'):
            try:
                with open(fp, encoding='utf-8', errors='ignore') as f:
                    for line in f:
                        parts = re.split(r'[,\t;]+', line.strip())
                        if len(parts) < 2:
                            continue
                        ident, call = parts[0].strip(), parts[1].strip().upper()
                        if ident.isdigit() and call and call not in ('NOCALL', '—', '-', '??'):
                            table[ident] = call
            except Exception:
                pass
        return table

    def _tetra_slot_snapshot():
        data = _arch_orig_tetra_slot_snapshot()
        try:
            table = _arch_load_callsigns()
            for item in (data.get('terminals') or []):
                ident = str(item.get('issi') or '').strip()
                call = table.get(ident)
                if not call and len(ident) > 7:
                    call = table.get(ident[:7]) or table.get(ident[:-2])
                if call:
                    item['callsign'] = call
        except Exception:
            pass
        return data
except Exception:
    pass



# ARCH_TETRA_SLOT_HTTP_PATCH
try:
    @app.after_request
    def _arch_enrich_tetra_slot_callsigns(resp):
        try:
            if request.path == '/api/tetra/slots' and resp.is_json:
                import re, json as _json
                data = resp.get_json(silent=True)
                if isinstance(data, dict):
                    table = {}
                    for fp in ('/usr/local/etc/hosts/DMRIds.dat', '/usr/local/etc/DMRIds.dat', '/usr/local/etc/nextionUsers.csv'):
                        try:
                            with open(fp, encoding='utf-8', errors='ignore') as f:
                                for line in f:
                                    parts = re.split(r'[,\t;]+', line.strip())
                                    if len(parts) >= 2:
                                        ident, call = parts[0].strip(), parts[1].strip().upper()
                                        if ident.isdigit() and call and call not in ('NOCALL', '—', '-', '??'):
                                            table[ident] = call
                        except Exception:
                            pass
                    for item in (data.get('terminals') or []):
                        ident = str(item.get('issi') or '').strip()
                        call = table.get(ident)
                        if not call and len(ident) > 7:
                            call = table.get(ident[:7]) or table.get(ident[:-2])
                        if call:
                            item['callsign'] = call
                    resp.set_data(_json.dumps(data, ensure_ascii=False))
        except Exception:
            pass
        return resp
except Exception:
    pass



# ARCH_TETRA_SLOT_REMOTE_PATCH
try:
    @app.after_request
    def _arch_enrich_tetra_slot_remote(resp):
        try:
            if request.path == '/api/tetra/slots' and resp.is_json:
                import os, gzip, json as _json
                table = {}
                cache = '/opt/mmdvm-web/data/arch-contacts.json.gz'
                if os.path.exists(cache):
                    with gzip.open(cache, 'rt', encoding='utf-8') as f:
                        table = _json.load(f)
                data = resp.get_json(silent=True)
                if isinstance(data, dict) and table:
                    for item in (data.get('terminals') or []):
                        ident = str(item.get('issi') or '').strip()
                        call = table.get(ident)
                        if not call and len(ident) > 7:
                            call = table.get(ident[:7]) or table.get(ident[:-2])
                        if call:
                            item['callsign'] = str(call).upper()
                    resp.set_data(_json.dumps(data, ensure_ascii=False))
        except Exception:
            pass
        return resp
except Exception:
    pass


if __name__ == '__main__':
    app.run(host='127.0.0.1', port=18080)
```

### `opt/mmdvm-web/console_new.py`

```python
# -*- coding: utf-8 -*-
"""全新自包含控制台（推翻重做，干净统一，只依赖现有 /api/* 接口）"""

CONSOLE_CSS = r"""
:root{--bg:#0b1220;--card:#111d33;--card2:#0c1526;--border:#1e2b44;--border2:#2a3d5c;
--txt:#eaf2ff;--dim:#8fa6c4;--muted:#6f86a6;
--mono:Consolas,"Cascadia Mono","DejaVu Sans Mono",monospace;
--acc:#22d3ee;--ok:#10b981;--warn:#fbbf24;--bad:#f87171}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{background:var(--bg);color:var(--txt);font:13.5px/1.6 "Segoe UI","Microsoft YaHei",sans-serif;display:flex;min-height:100vh}
.side{width:224px;flex:0 0 224px;background:#0a111e;border-right:1px solid var(--border);padding:14px 10px;overflow-y:auto;position:sticky;top:0;height:100vh}
.brand{display:flex;align-items:center;gap:10px;padding:4px 6px 14px;border-bottom:1px solid var(--border);margin-bottom:10px}
.brand .logo{width:38px;height:38px;border-radius:50%;flex:0 0 38px;display:grid;place-items:center;background:linear-gradient(135deg,#0ea5e9,#8b5cf6);color:#fff;font-weight:800;font-size:15px}
.brand .nm{font-size:14.5px;font-weight:800;color:#fff;line-height:1.2}
.brand .sub{font-size:10.5px;color:var(--muted)}
.nav{display:flex;flex-direction:column;gap:2px}
.nav a{display:flex;align-items:center;gap:10px;padding:9px 11px;border-radius:8px;color:#c8d2df;font-size:13.5px;font-weight:600;cursor:pointer;transition:.16s;user-select:none}
.nav a:hover{background:#101a2e;color:#fff}
.nav a.on{background:linear-gradient(135deg,#0e7490,#7c3aed);color:#fff;font-weight:700}
.nav a .ico{width:17px;text-align:center;font-size:14px}
.main{flex:1;padding:18px 20px 40px;overflow-y:auto;min-width:0}
.topbar{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px}
.topbar h1{font-size:18px;font-weight:750;letter-spacing:.3px}
.topbar .spacer{flex:1}
.badge{font-size:11px;padding:4px 11px;border-radius:99px;background:var(--card2);border:1px solid var(--border);color:var(--dim);font-weight:600}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.badge.warn{background:#33280e;border-color:#7a5a1d;color:#fbbf24}
.tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:16px;border-bottom:1px solid var(--border)}
.tab{padding:9px 17px;font-size:13.5px;font-weight:650;color:var(--dim);cursor:pointer;border:1px solid transparent;border-bottom:none;border-radius:9px 9px 0 0;transition:.15s;user-select:none}
.tab:hover{color:var(--txt);background:var(--card)}
.tab.on{color:#fff;background:var(--card);border-color:var(--border);margin-bottom:-1px}
.pane{display:none}
.pane.on{display:block}
.card{background:linear-gradient(180deg,var(--card),var(--card2));border:1px solid var(--border);border-radius:12px;padding:15px;margin-bottom:14px}
.card h2{font-size:13px;font-weight:700;color:var(--dim);margin-bottom:12px;letter-spacing:.4px;display:flex;align-items:center;gap:8px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:12px}
@media(max-width:720px){.grid{grid-template-columns:1fr}}
.ch{border:1px solid var(--border);border-radius:11px;padding:13px 14px;background:linear-gradient(180deg,var(--card),var(--card2));transition:.16s}
.ch:hover{border-color:var(--border2)}
.ch.on{border-color:rgba(16,185,129,.4)}
.ch .hd{display:flex;align-items:center;gap:9px;margin-bottom:10px}
.ch .dot{width:8px;height:8px;border-radius:99px;flex:0 0 8px}
.ch .n{font-size:13px;font-weight:800;color:#7dd3fc;font-family:var(--mono)}
.ch .mode{margin-left:auto;display:flex;align-items:center;gap:8px;font-weight:800;font-size:14px;color:#fff;font-family:var(--mono)}
.ch .mode svg{width:34px;height:34px;flex:0 0 34px}
.ch .freq{display:flex;gap:12px;font-family:var(--mono);margin-bottom:10px}
.ch .freq .k{font-size:10px;color:var(--muted);font-weight:700;letter-spacing:.5px;margin-bottom:1px}
.ch .freq .v{font-size:13px;color:var(--txt);font-weight:700}
.ch .freq .col{flex:1}
.ch .ops{display:flex;gap:7px;align-items:center;flex-wrap:wrap}
select,input[type=number]{background:var(--card2);border:1px solid var(--border);color:var(--txt);padding:6px 9px;border-radius:8px;font-size:12.5px;font-family:inherit}
select:focus,input:focus{outline:none;border-color:var(--acc)}
select.grow{flex:1;min-width:0}
input[type=number]{font-family:var(--mono)}
.btn{cursor:pointer;border:1px solid var(--border);background:var(--card);color:var(--txt);padding:7px 13px;border-radius:8px;font-size:12.5px;font-weight:650;font-family:inherit;transition:.14s;white-space:nowrap}
.btn:hover{border-color:var(--border2);color:#fff}
.btn:active{transform:translateY(1px)}
.btn.sm{padding:5px 10px;font-size:11.5px}
.btn.g{background:#0e2f22;border-color:#1d6b4a;color:#a7f3d0}
.btn.r{background:#331616;border-color:#7f2222;color:#fecaca}
.btn.b{background:#0a2733;border-color:#155e75;color:#7dd3fc}
.kv{display:flex;gap:8px;flex-wrap:wrap}
.kv .item{flex:1;min-width:120px;background:var(--card2);border:1px solid var(--border);border-radius:9px;padding:10px 12px}
.kv .k{font-size:10px;color:var(--muted);font-weight:700;letter-spacing:.5px;text-transform:uppercase}
.kv .v{font-size:15px;font-weight:750;color:var(--txt);margin-top:2px;font-family:var(--mono)}
.qso-item{display:flex;align-items:center;gap:10px;padding:9px 12px;border-radius:9px;background:var(--card2);border:1px solid var(--border);margin-bottom:7px;flex-wrap:wrap}
.qso-call{font-weight:800;font-size:13.5px;color:#fff;font-family:var(--mono)}
.qso-tag{font-size:10.5px;padding:2px 8px;border-radius:99px;font-weight:700}
.qso-tag.mode{background:#0a2733;border:1px solid #155e75;color:#7dd3fc}
.qso-tag.src{background:#171c2e;border:1px solid #26354f;color:var(--dim)}
.qso-time{margin-left:auto;font-size:11px;color:var(--muted);font-family:var(--mono)}
.qso-meta{font-size:11px;color:var(--muted)}
.qso-empty{padding:16px;text-align:center;color:var(--muted);font-size:12px}
.log{background:#070d18;border:1px solid var(--border);border-radius:10px;padding:12px;max-height:460px;overflow:auto;font-family:var(--mono);font-size:11.5px;line-height:1.6;color:var(--dim);white-space:pre-wrap}
.hint{font-size:11px;color:var(--muted);line-height:1.6}
.note{background:var(--card2);border:1px solid var(--border);border-radius:9px;padding:11px 13px;font-size:11.5px;color:var(--dim);line-height:1.7}
.note b{color:#6ee7b7}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.mt{margin-top:10px}.mb{margin-bottom:10px}
"""

CONSOLE_BODY = r"""
<aside class="side">
  <div class="brand">
    <div class="logo">M</div>
    <div><div class="nm">ARCH 射频核心</div><div class="sub" id="bCallsign">—</div></div>
  </div>
  <nav class="nav" id="nav">
    <a data-p="overview" class="on"><span class="ico">📊</span>总览</a>
    <a data-p="rfmode"><span class="ico">📻</span>射频模式</a>
    <a data-p="qso"><span class="ico">📡</span>通联</a>
    <a data-p="log"><span class="ico">📜</span>运行日志</a>
    <a href="/tetra"><span class="ico">🏢</span>TETRA 集群</a>
    <a href="/fmo"><span class="ico">🔗</span>FMO 桥接</a>
  </nav>
</aside>

<main class="main">
  <div class="topbar">
    <h1 id="pgTitle">总览</h1>
    <span class="spacer"></span>
    <span class="badge" id="bEng">引擎 —</span>
    <span class="badge" id="bCh">通道 —</span>
    <span class="badge" id="bSdr">SDR —</span>
    <button class="btn b" onclick="doRefresh()">⟳ 刷新</button>
  </div>

  <!-- ============ 总览 ============ -->
  <div class="pane on" id="p-overview">
    <div class="kv" id="ovStats"></div>
    <div class="card mt">
      <h2>频率模式列表</h2>
      <div class="grid" id="ovChans"><div class="qso-empty">读取中…</div></div>
    </div>
    <div class="card">
      <h2>实时通联</h2>
      <div id="ovQso"><div class="qso-empty">读取中…</div></div>
    </div>
  </div>

  <!-- ============ 射频模式 ============ -->
  <div class="pane" id="p-rfmode">
    <div class="card">
      <h2>射频通道</h2>
      <div class="grid" id="rfChans"><div class="qso-empty">读取中…</div></div>
    </div>
    <div class="card">
      <h2>频率设置</h2>
      <div class="note mb">修改后点「保存并应用」：写入 MMDVM-Multi.ini 与各通道 ini，并只重启受影响通道。频率须为 25 kHz 整数倍。</div>
      <div id="rfFreq"></div>
      <div class="row mt">
        <button class="btn g" onclick="saveFreq(false)">💾 仅保存</button>
        <button class="btn g" onclick="saveFreq(true)">⚡ 保存并应用</button>
        <span class="hint" id="rfFreqMsg"></span>
      </div>
    </div>
    <div class="card">
      <h2>硬件基础配置（RF & Station）</h2>
      <div class="note">全局射频载波与硬件基础配置保持原实现不变。</div>
      <div class="row mt">
        <button class="btn b" onclick="openHw()">🔧 打开硬件基础配置</button>
        <button class="btn" onclick="embedHw()">📄 本页内嵌</button>
      </div>
      <div id="hwBox" class="mt"></div>
    </div>
  </div>

  <!-- ============ 通联 ============ -->
  <div class="pane" id="p-qso">
    <div class="card"><h2>实时通联列表</h2><div id="qsoList"><div class="qso-empty">读取中…</div></div></div>
  </div>

  <!-- ============ 运行日志 ============ -->
  <div class="pane" id="p-log">
    <div class="card">
      <h2>运行日志</h2>
      <div class="row mb">
        <select id="logCh"></select>
        <select id="logSvc">
          <option value="mmdvmhost">Host</option>
          <option value="mmdvm-iq">IQ</option>
          <option value="mmdvm-multi">引擎</option>
        </select>
        <button class="btn" onclick="loadLog()">⟳ 刷新</button>
        <label class="hint" style="display:flex;align-items:center;gap:5px"><input type="checkbox" id="logAuto" checked>自动刷新 5s</label>
      </div>
      <div class="log" id="logBox">（选择后自动加载）</div>
    </div>
  </div>
</main>
"""

CONSOLE_JS = r"""
var $=function(id){return document.getElementById(id);};
var MODES=['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];
var CACHE=null;
function icon(m){
  var m2=String(m||'').toUpperCase();
  var col={DMR:'#0ea5e9','D-STAR':'#8b5cf6',YSF:'#f97316',NXDN:'#2563eb',P25:'#e11d48',FM:'#10b981',POCSAG:'#f59e0b'}[m2]||'#64748b';
  var g='';
  if(m2==='DMR')g='<rect x="9" y="15" width="22" height="4" rx="2" fill="#fff"/><rect x="9" y="23" width="14" height="4" rx="2" fill="#fff" opacity=".75"/>';
  else if(m2==='D-STAR')g='<path d="M20 10l2.6 6.1 6.4.6-4.9 4.3 1.5 6.4L20 24l-5.6 3.4 1.5-6.4-4.9-4.3 6.4-.6z" fill="#fff"/>';
  else if(m2==='YSF')g='<path d="M20 10l8 10-8 10-8-10z" fill="#fff"/>';
  else if(m2==='NXDN')g='<path d="M13 27V13h4.2l5.6 8V13H27v14h-4.2l-5.6-8v8z" fill="#fff"/>';
  else if(m2==='P25')g='<path d="M20 10l9 3.4v7.1c0 5-3.7 8.2-9 9.5-5.3-1.3-9-4.5-9-9.5v-7.1z" fill="#fff"/>';
  else if(m2==='FM')g='<path d="M9 20c2.6-6 5.2-6 7.8 0s5.2 6 7.8 0 5.2-6 5.4 0" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>';
  else if(m2==='POCSAG')g='<rect x="13" y="11" width="14" height="18" rx="3" fill="#fff"/><rect x="16" y="14" width="8" height="6" rx="1" fill="'+col+'"/>';
  else g='<circle cx="20" cy="20" r="6" fill="#fff" opacity=".7"/>';
  return '<svg viewBox="0 0 40 40" width="34" height="34" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="36" height="36" rx="9" fill="'+col+'"/>'+g+'</svg>';
}
function mhz(hz){hz=parseInt(hz,10)||0;return hz>0?(hz/1e6).toFixed(4)+' MHz':'—';}
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function toast(msg,ok){
  var t=$('toast');
  if(!t){t=document.createElement('div');t.id='toast';document.body.appendChild(t);}
  t.textContent=String(msg==null?'':msg);
  t.style.cssText='position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:99999;'
    +'background:rgba(13,21,32,.97);border:1px solid '+(ok===false?'rgba(239,68,68,.6)':'#2b3f57')+';'
    +'color:#eaf2ff;padding:18px 32px;border-radius:13px;text-align:center;max-width:min(560px,86vw);'
    +'font-size:15px;font-weight:600;line-height:1.6;display:block;opacity:0;pointer-events:none;'
    +'transition:opacity .3s ease';
  requestAnimationFrame(function(){t.style.opacity='1';});
  clearTimeout(window.__t1);clearTimeout(window.__t2);
  window.__t1=setTimeout(function(){t.style.opacity='0';},2000);
  window.__t2=setTimeout(function(){t.style.display='none';},2400);
}
function api(path,opts){
  var o=opts||{};
  return fetch(path,{credentials:'same-origin',method:o.method||'GET',
    headers:o.body?{'Content-Type':'application/json'}:undefined,
    body:o.body?JSON.stringify(o.body):undefined}).then(function(r){return r.json();});
}
function navGo(p,title){
  var pages=['overview','rfmode','qso','log'];
  document.querySelectorAll('#nav a[data-p]').forEach(function(a){
    a.classList.toggle('on',a.getAttribute('data-p')===p);});
  pages.forEach(function(x){var el=$('p-'+x);if(el)el.classList.toggle('on',x===p);});
  if(title)$('pgTitle').textContent=title;
  if(p==='rfmode')loadFreq();
  if(p==='log')loadLog();
}
document.addEventListener('click',function(e){
  var a=e.target.closest&&e.target.closest('#nav a[data-p]');
  if(a){var p=a.getAttribute('data-p');
    navGo(p,{overview:'总览',rfmode:'射频模式',qso:'通联',log:'运行日志'}[p]);}
});
function doRefresh(){
  api('/api/mc/channels').then(function(d){
    if(!d||d.ok===false)return; CACHE=d; renderAll(d);
  }).catch(function(e){toast('读取失败：'+e,false);});
  api('/api/qso').then(function(d){if(d&&d.qso)renderQso(d.qso);}).catch(function(){});
}
function renderAll(d){
  var chs=(d.channels||[]).filter(function(c){return String(c.mode||'NONE').toUpperCase()!=='NONE';});
  var eng=d.multi||{};
  var runN=chs.filter(function(c){return c.iq&&c.host;}).length;
  var b=$('bEng');if(b){b.textContent='引擎 '+(eng.active?'运行中':'停止');b.className='badge '+(eng.active?'ok':'bad');}
  var b2=$('bCh');if(b2)b2.textContent='通道 '+runN+' / '+chs.length;
  var b3=$('bSdr');if(b3)b3.textContent='SDR '+(eng.sample_rate?(eng.sample_rate/1000)+' kS/s':'—');
  var b4=$('bCallsign');if(b4)b4.textContent=eng.callsign||'';
  $('ovChans').innerHTML=chs.map(chanCardOverview).join('')||'<div class="qso-empty">无启用通道</div>';
  $('rfChans').innerHTML=chs.map(chanCardRf).join('')||'<div class="qso-empty">无启用通道</div>';
  $('ovStats').innerHTML=
    '<div class="item"><div class="k">引擎</div><div class="v" style="color:'+(eng.active?'#4ade80':'#f87171')+'">'+(eng.active?'运行中':'停止')+'</div></div>'
    +'<div class="item"><div class="k">在跑通道</div><div class="v">'+runN+' / '+chs.length+'</div></div>'
    +'<div class="item"><div class="k">采样率</div><div class="v">'+(eng.sample_rate?Math.round(eng.sample_rate/1000)+' kHz':'—')+'</div></div>';
  var sel=$('logCh');
  if(sel)sel.innerHTML=chs.map(function(c,i){return '<option value="'+c.n+'">射频通道 '+(i+1)+' ('+c.mode+')</option>';}).join('')+'<option value="">（引擎级）</option>';
}
function chanCardOverview(c,i){
  var run=!!(c.iq&&c.host);
  return '<div class="ch '+(run?'on':'')+'">'
    +'<div class="hd"><span class="dot" style="background:'+(run?'var(--ok)':'var(--warn)')+'"></span>'
    +'<span class="n">通道 '+(i+1)+'</span>'
    +'<span class="mode">'+icon(c.mode)+esc(c.mode)+'</span></div>'
    +'<div class="freq">'
    +'<div class="col"><div class="k">接收 RX</div><div class="v">'+mhz(c.rx)+'</div></div>'
    +'<div class="col"><div class="k">发射 TX</div><div class="v">'+mhz(c.tx)+'</div></div>'
    +'<div class="col"><div class="k">状态</div><div class="v" style="color:'+(run?'#4ade80':'#fbbf24')+'">'+(run?'运行中':'已停止')+'</div></div>'
    +'</div></div>';
}
function chanCardRf(c,i){
  var run=!!(c.iq&&c.host);
  var opts=MODES.map(function(m){return '<option value="'+m+'"'+(String(c.mode).toUpperCase()===m?' selected':'')+'>'+m+'</option>';}).join('');
  return '<div class="ch '+(run?'on':'')+'">'
    +'<div class="hd"><span class="dot" style="background:'+(run?'var(--ok)':'var(--warn)')+'"></span>'
    +'<span class="n">通道 '+(i+1)+'</span>'
    +'<span class="mode">'+icon(c.mode)+esc(c.mode)+'</span></div>'
    +'<div class="freq">'
    +'<div class="col"><div class="k">RX</div><div class="v">'+mhz(c.rx)+'</div></div>'
    +'<div class="col"><div class="k">TX</div><div class="v">'+mhz(c.tx)+'</div></div>'
    +'</div>'
    +'<div class="ops"><select class="grow" data-ch="'+c.n+'">'+opts+'</select>'
    +'<button class="btn sm g" onclick="doSwitch('+c.n+')">切换</button>'
    +'<button class="btn sm" onclick="doRestart('+c.n+')">重启</button></div>'
    +'</div>';
}
function renderQso(items){
  var h=items.map(function(q){
    return '<div class="qso-item"><span class="qso-call">'+esc(q.call||'-')+'</span>'
      +'<span class="qso-tag mode">'+esc(q.mode||'')+'</span>'
      +'<span class="qso-tag src">'+esc(q.src||'')+'</span>'
      +'<span class="qso-meta">→ '+esc(q.target||'-')+' · '+esc(q.duration||'-')+'</span>'
      +'<span class="qso-time">'+esc(q.time||'')+'</span></div>';
  }).join('');
  var el=$('qsoList');if(el)el.innerHTML=h||'<div class="qso-empty">暂无通联</div>';
  var el2=$('ovQso');if(el2)el2.innerHTML=(items.slice(0,8).map(function(q){
    return '<div class="qso-item"><span class="qso-call">'+esc(q.call||'-')+'</span>'
      +'<span class="qso-tag mode">'+esc(q.mode||'')+'</span>'
      +'<span class="qso-meta">→ '+esc(q.target||'-')+'</span>'
      +'<span class="qso-time">'+esc(q.time||'')+'</span></div>';
  }).join(''))||'<div class="qso-empty">暂无通联</div>';
}
function doSwitch(n){
  var sel=document.querySelector('.ch select[data-ch="'+n+'"]');
  if(!sel)return;
  var mode=sel.value;
  if(!confirm('把射频通道 '+n+' 切换为 '+mode+' ？将重启该通道。'))return;
  toast('通道 '+n+' 正在切换…');
  api('/api/mc/switch',{method:'POST',body:{ch:n,mode:mode}}).then(function(d){
    var ok=!!(d&&d.ok);
    toast(ok?('通道 '+n+' 已切换为 '+mode):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok)setTimeout(doRefresh,1800);
  }).catch(function(e){toast('失败：'+e,false);});
}
function doRestart(n){
  if(!confirm('重启射频通道 '+n+' 的基带与主机？'))return;
  toast('通道 '+n+' 正在重启…');
  api('/api/mc/restart',{method:'POST',body:{ch:n}}).then(function(d){
    var ok=!!(d&&d.ok);
    toast(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok)setTimeout(doRefresh,1800);
  }).catch(function(e){toast('失败：'+e,false);});
}
function restartAll(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。'))return;
  toast('正在重启全部通道…');
  api('/api/mc/multi-restart',{method:'POST',body:{}}).then(function(d){
    var ok=!!(d&&d.ok);
    toast(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok)setTimeout(doRefresh,5000);
  }).catch(function(e){toast('失败：'+e,false);});
}
function loadFreq(){
  var box=$('rfFreq');if(!box)return;
  api('/api/mc/plan').then(function(d){
    if(!d||d.ok===false||!d.list){box.innerHTML='<div class="note">读取规划失败</div>';return;}
    window.__plan=d;
    box.innerHTML='<div class="grid">'+(d.list||[]).map(function(c,i){
      var has=String(c.mode||'NONE').toUpperCase()!=='NONE';
      return '<div class="ch '+(has?'on':'')+'">'
        +'<div class="hd"><span class="n">通道 '+(i+1)+'</span>'
        +'<span class="mode">'+icon(c.mode)+esc(c.mode)+'</span></div>'
        +'<div class="ops"><span class="hint" style="flex:0 0 58px">接收 Hz</span>'
        +'<input type="number" step="25000" class="grow" id="fRx'+c.n+'" value="'+(c.rx||0)+'"></div>'
        +'<div class="ops mt"><span class="hint" style="flex:0 0 58px">发射 Hz</span>'
        +'<input type="number" step="25000" class="grow" id="fTx'+c.n+'" value="'+(c.tx||0)+'"></div>'
        +'<div class="hint mt">'+mhz(c.rx)+' / '+mhz(c.tx)+'</div>'
        +'</div>';
    }).join('')+'</div>';
  }).catch(function(e){box.innerHTML='<div class="note">网络错误：'+e+'</div>';});
}
function saveFreq(apply){
  var d=window.__plan;if(!d||!d.list){toast('请先刷新频率',false);return;}
  var bad=null;
  d.list.forEach(function(c){
    var rx=parseInt(($('fRx'+c.n)||{}).value,10)||0;
    var tx=parseInt(($('fTx'+c.n)||{}).value,10)||0;
    if(rx&&rx%25000!==0)bad='通道 '+c.n+' 接收频率非 25kHz 整数倍';
    if(tx&&tx%25000!==0)bad='通道 '+c.n+' 发射频率非 25kHz 整数倍';
    c.rx=rx;c.tx=tx;
  });
  if(bad){toast(bad,false);return;}
  var msg=$('rfFreqMsg');if(msg)msg.textContent=apply?'正在应用（约 30~60 秒）…':'正在保存…';
  api('/api/mc/plan-'+(apply?'apply':'save'),{method:'POST',body:d}).then(function(res){
    var ok=!!(res&&res.ok!==false);
    var t=String((res&&(res.message||res.out||res.error))||'');
    if(msg)msg.textContent=(ok?'✅ ':'❌ ')+t.slice(0,90);
    toast(ok?(apply?'频率已应用':'频率已保存'):('失败：'+t.slice(0,60)),ok);
    if(ok)setTimeout(doRefresh,2500);
  }).catch(function(e){if(msg)msg.textContent='网络错误：'+e;});
}
var __logTimer=null;
function loadLog(){
  var ch=($('logCh')||{}).value, svc=($('logSvc')||{}).value||'mmdvmhost';
  var box=$('logBox');if(!box)return;
  if(svc==='mmdvm-multi')ch='';
  var url='/api/mc/logs?svc='+encodeURIComponent(svc)+'&lines=200'+(ch?'&ch='+encodeURIComponent(ch):'');
  fetch(url,{credentials:'same-origin'}).then(function(r){return r.json();}).then(function(d){
    box.textContent=String((d&&(d.log||d.out||d.text))||'')||'（无日志）';
    box.scrollTop=box.scrollHeight;
  }).catch(function(e){box.textContent='读取失败：'+e;});
  if(__logTimer)clearInterval(__logTimer);
  __logTimer=setInterval(function(){
    var pn=$('p-log');if(pn&&pn.classList.contains('on')&&($('logAuto')||{}).checked)loadLog();
  },5000);
}
function openHw(){ location.href='/multi'; }
function embedHw(){
  var box=$('hwBox');if(!box)return;
  box.innerHTML='<div class="hint">嵌入式页面已停用。请<button class="sm primary" onclick="openHw()">在新窗口打开多通道控制台</button></div>';
}
function init(){
  doRefresh();
  setInterval(doRefresh, 8000);
  setInterval(function(){ api('/api/qso').then(function(d){if(d&&d.qso)renderQso(d.qso);}).catch(function(){}); }, 4000);
}
document.addEventListener('DOMContentLoaded', init);
if(document.readyState!=='loading')init();
"""

CONSOLE_STANDALONE = (
    "<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n"
    "<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
    "<title>ARCH业余无线电融合中枢 · 控制台</title>\n"
    "<style>\n" + CONSOLE_CSS + "\n</style>\n</head>\n<body>\n"
    + CONSOLE_BODY +
    "\n<script>\n" + CONSOLE_JS + "\n</script>\n</body>\n</html>\n"
)

if __name__ == "__main__":
    open("/tmp/_console_chk.html", "w", encoding="utf-8").write(CONSOLE_STANDALONE)
    print("整页 %d 字节   CSS %d / BODY %d / JS %d" % (
        len(CONSOLE_STANDALONE), len(CONSOLE_CSS), len(CONSOLE_BODY), len(CONSOLE_JS)))
    for k in ("<style>", "</style>", "<script>", "</script>", "<body>", "</body>",
              "doRefresh", "renderAll", "doSwitch", "doRestart", "saveFreq", "loadLog",
              "renderQso", "navGo"):
        print("  %-12s %d" % (k, CONSOLE_STANDALONE.count(k)))
```

### `opt/mmdvm-web/fmo_page.py`

```python
# -*- coding: utf-8 -*-
"""FM 联网配置页面（内嵌到主控制台的 /fmo 页，通过 iframe?embed=1 加载）。

只定义 FMO_HTML 常量，由 app.py 的 /fmo 路由 render_template_string 渲染。
样式与 MC_HTML（多信道页）保持一致。
"""

FMO_HTML = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ARCH · FM 联网配置</title>
<script>try{var q=new URLSearchParams(location.search).get("theme"),m=localStorage.getItem("arch-theme-mode");var light=(q==="light"||q==="day"||(q!=="dark"&&q!=="night"&&(m==="day"||m==="light")));document.documentElement.classList.toggle("light",light);window.addEventListener("storage",function(e){if(e.key==="arch-theme-mode"){var v=e.newValue;document.documentElement.classList.toggle("light",v==="day"||v==="light");}});}catch(e){}</script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#dfe8f5;font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
body.embed .top{display:none}
.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.top h1{font-size:18px;font-weight:650;letter-spacing:.3px}
.badge{font-size:12px;padding:3px 9px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.spacer{flex:1}
button{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:6px 12px;border-radius:7px;font-size:13px;font-family:inherit}
button:hover{background:#1d3260;border-color:#3b5686}
button:active{transform:translateY(1px)}
button.sm{padding:4px 9px;font-size:12px}
button.primary{background:#14532d;border-color:#1d6b4a;color:#bbf7d0}
.card{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;padding:14px;margin-bottom:14px}
.card>h2{font-size:14px;font-weight:640;color:#9fb6d6;margin-bottom:11px;letter-spacing:.3px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(158px,1fr));gap:10px}
.kv{background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:9px 11px}
.kv .k{font-size:11.5px;color:#7d93b2;letter-spacing:.4px;text-transform:uppercase}
.kv .v{font-size:15px;font-weight:600;margin-top:3px;word-break:break-all}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row+.row{margin-top:9px}
.lbl{font-size:12.5px;color:#9fb6d6;min-width:96px}
.dot{width:9px;height:9px;border-radius:99px;background:#4b5563;display:inline-block;flex-shrink:0}
.dot.on{background:#22c55e;box-shadow:0 0 7px #22c55e}
.dot.off{background:#ef4444;box-shadow:0 0 7px #ef4444}
.dot.pending{background:#f59e0b;box-shadow:0 0 7px #f59e0b}
.svcs{display:flex;flex-wrap:wrap;gap:8px}
.svc{display:flex;align-items:center;gap:7px;font-size:12.5px;background:#101c31;border:1px solid #27405f;border-radius:8px;padding:7px 10px;color:#e5f2ff}
.svc.remote{background:#102b46;border-color:#2d78b7;color:#d9f3ff}
.svc-state{color:#9fc7e8;font-size:11.5px;margin-left:2px}
.svc.remote .svc-state{color:#7dd3fc}
.dot.remote{background:#38bdf8;box-shadow:0 0 8px #38bdf8}
input,select{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:6px 9px;border-radius:7px;font-size:13px;font-family:inherit;min-width:110px}
input[type=checkbox]{min-width:0;width:15px;height:15px;accent-color:#22c55e}
input.wide{min-width:210px}
pre{background:#070d18;border:1px solid #1b2840;border-radius:8px;padding:11px;font:12.5px/1.5 Consolas,monospace;color:#9fb6d6;max-height:340px;overflow:auto;white-space:pre-wrap;word-break:break-all}
.hint{font-size:12.5px;color:#7d93b2;margin-top:8px}
.warn{font-size:12.5px;color:#fbbf24;margin-top:8px}
#toast{display:none;position:fixed;left:50%;transform:translateX(-50%);bottom:22px;padding:9px 14px;border-radius:11px;font-size:13.5px;background:rgba(13,21,32,.74);border:1px solid rgba(34,211,238,.42);color:#eaf2ff;z-index:99;max-width:min(420px,84vw);text-align:center;box-shadow:0 10px 28px rgba(0,0,0,.35);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px)}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#bbf7d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fca5a5}

/* ===== 日间 / 夜间主题 ===== */
html.light body{background:#f2efe8;color:#16243a}
html.light .top h1{color:#0f2742}
html.light .card{background:#fbfaf7;border-color:#d8d3c7;box-shadow:0 5px 16px rgba(31,58,45,.06)}
html.light .card>h2{color:#35516f}
html.light .badge{background:#e8eef5;border-color:#cdd9e5;color:#3d5876}
html.light .badge.ok{background:#e7f8ef;border-color:#9ed9b8;color:#166534}
html.light .badge.bad{background:#fff0f0;border-color:#f2b8b8;color:#b42318}
html.light .kv{background:#fdfcf9;border-color:#d8d3c7}
html.light .kv .k{color:#60758d}
html.light .svc{background:#fdfcf9;border-color:#d8d3c7;color:#18324d}
html.light .svc.remote{background:#eef7ff;border-color:#b9d9f2;color:#16476d}
html.light .svc-state{color:#536f8d}
html.light .svc.remote .svc-state{color:#17699a}
html.light button{background:#eef3f8;border-color:#c5d2e0;color:#203952}
html.light button:hover{background:#e3ebf4;border-color:#9fb5ca}
html.light button.primary{background:#0f766e;border-color:#0f766e;color:#fff}
html.light input,html.light select{background:#fff;border-color:#c5d2e0;color:#16243a}
html.light pre{background:#f7f9fc;border-color:#dbe4ee;color:#243b55}
html.light .hint{color:#60758d}
html.light .warn{color:#9a6700}
html.light #toast{background:rgba(255,255,255,.82);border-color:rgba(52,120,100,.28);color:#0f172a;box-shadow:0 10px 26px rgba(31,58,45,.14)}
html.light #toast.ok{background:#e7f8ef;border-color:#9ed9b8;color:#166534}
html.light #toast.bad{background:#fff0f0;border-color:#f2b8b8;color:#b42318}
</style>
</head>
<body>
<div class="top"><h1>FM 联网配置</h1>
  <span id="bSvc" class="badge">读取中…</span>
  <span class="spacer"></span>
  <span id="bTime" class="badge">--</span>
</div>

<div class="card">
  <h2>服务状态</h2>
  <div class="svcs" id="svcs"></div>
  <div class="row" style="margin-top:11px">
    <button class="sm" onclick="doRestart('bridge')">只重启桥接</button>
    <button class="sm" onclick="doRestart('host')">只重启 MMDVM-Host</button>
    <button class="sm" onclick="doRestart('all')">全部重启</button>
    <button class="sm" onclick="refresh()">刷新</button>
  </div>
  <div class="hint">改了“服务器/身份/语音/音频/尾音”后需要重启桥接；改了“哑音”会自动重启 Host。</div>
</div>
<div class="card">
  <h2>服务器与身份 <span id="regState" class="badge">未注册</span></h2>
  <div class="grid">
    <label class="kv"><div class="k">服务器地址</div><input id="srvHost" class="wide" value="fmo.hljrsa.cn"></label>
    <label class="kv"><div class="k">注册端口</div><input id="srvRegPort" type="number" value="8081"></label>
    <label class="kv"><div class="k">MQTT 端口</div><input id="srvMqttPort" type="number" value="1883"></label>
    <label class="kv"><div class="k">本机呼号</div><input id="cliCs" class="wide" placeholder="请输入本机呼号" autocomplete="off"></label>
  </div>
  <div class="grid" style="margin-top:10px">
    <div class="kv"><div class="k">服务器呼号 / UID</div><div class="v" id="vSrv">--</div></div>
    <div class="kv"><div class="k">证书呼号 / UID</div><div class="v" id="vCert">--</div></div>
    <div class="kv"><div class="k">证书有效期至</div><div class="v" id="vCertExp">--</div></div>
    <div class="kv"><div class="k">证书指纹</div><div class="v" id="vFp" style="font-size:12px">--</div></div>
  </div>
  <div class="row" style="margin-top:11px">
    <button class="sm primary" id="regBtn" onclick="doRegister()">一键注册</button>
  </div>
  <div class="hint">只需填写本机呼号。服务器地址、注册端口和连接端口已使用默认值；注册时本机生成密钥，私钥不会离开设备。</div>
</div>

<div class="card">
  <h2>语音与频率</h2>
  <div class="grid">
    <label class="kv"><div class="k">语音编解码</div>
      <select id="vCodec"><option value="radpcm">RADPCM（4bit ADPCM，兼容最好）</option>
      <option value="opus">OPUS（40ms/8kHz）</option></select></label>
    <label class="kv"><div class="k">中转主题</div><input id="vTopic" class="wide" value="FMO/RAW"></label>
    <label class="kv"><div class="k">上行增益（RF→网络）</div><input id="fmRxGain" type="number" step="0.05" value="1.0"></label>
    <label class="kv"><div class="k">下行增益（网络→RF）</div><input id="fmTxGain" type="number" step="0.05" value="0.45"></label>
    <div class="kv"><div class="k">当前 FM 频率</div><div id="fmFreqView" class="wide">按当前通道规划自动识别</div></div>
  </div>
  <div class="row" style="margin-top:10px">
    <label class="row" style="gap:6px"><input id="vRelay" type="checkbox"><span class="lbl" style="min-width:0">以服务器本台身份把上行同时转发到全网 FMO/RAW</span></label>
  </div>
  <div class="row" style="margin-top:11px"><button class="sm" onclick="saveCfg()">保存</button></div>
</div>

<div class="card">
  <h2>下行音频整形　<span class="badge">改善“发哑/发沙”</span></h2>
  <div class="row">
    <label class="row" style="gap:6px"><input id="dspOn" type="checkbox"><span class="lbl" style="min-width:0">启用下行音频整形</span></label>
  </div>
  <div class="row" style="margin-top:10px">
    <label class="row" style="gap:6px"><input id="dspAgc" type="checkbox">
      <span class="lbl" style="min-width:0"><b>AGC 自动增益控制</b>（把对方音量拉平，大声不再过载）</span></label>
  </div>
  <div class="grid" style="margin-top:10px">
    <label class="kv"><div class="k">AGC 目标电平（占满幅）</div><input id="agcTarget" type="number" step="0.005" value="0.09"></label>
    <label class="kv"><div class="k">AGC 最大提升 (dB)</div><input id="agcGmax" type="number" step="1" value="12"></label>
    <label class="kv"><div class="k">AGC 最大衰减 (dB)</div><input id="agcGmin" type="number" step="1" value="-26"></label>
    <label class="kv"><div class="k">AGC 压降速度 (ms)</div><input id="agcDn" type="number" step="1" value="12"></label>
    <label class="kv"><div class="k">AGC 回升速度 (ms)</div><input id="agcUp" type="number" step="50" value="600"></label>
  </div>
  <div class="grid" style="margin-top:10px">
    <label class="kv"><div class="k">高通去隆隆 (Hz)</div><input id="dspHp" type="number" step="10" value="180"></label>
    <label class="kv"><div class="k">临场感补偿 (dB)</div><input id="dspShelfDb" type="number" step="0.5" value="2.5"></label>
    <label class="kv"><div class="k">补偿中心频率 (Hz)</div><input id="dspShelfHz" type="number" step="100" value="1800"></label>
    <label class="kv"><div class="k">软限幅阈值 (0-1)</div><input id="dspLimit" type="number" step="0.005" value="0.965"></label>
    <label class="kv"><div class="k">噪声门（默认关）</div><input id="dspExp" type="checkbox"></label>
    <label class="kv"><div class="k">噪声门阈值 (0-1)</div><input id="dspExpThr" type="number" step="0.002" value="0.01"></label>
  </div>
  <div class="row" style="margin-top:11px"><button class="sm" onclick="saveCfg(true)">保存并重启桥接</button></div>
  <div class="hint">AGC 目标 0.09 ≈ 满幅的 9% RMS（实测把"RMS 56% / 4.8% 样点贴顶"的不良音频
    收敛到 8.8% / 0.000%）。桥接日志每 30 秒会打一条 <code>[音频AGC]</code>，可据此判断是否需要微调。</div>
</div>

<div class="card">
  <h2>尾音确认音　<span class="badge">发射结束后回一声</span></h2>
  <div class="row">
    <label class="row" style="gap:6px"><input id="toneOn" type="checkbox"><span class="lbl" style="min-width:0">启用尾音</span></label>
    <span class="v" id="toneChan">通道识别中…</span>
  </div>
  <div class="grid" style="margin-top:10px">
    <label class="kv"><div class="k">频率 (Hz)</div><input id="toneFreq" type="number" step="50" value="1000"></label>
    <label class="kv"><div class="k">时长 (ms)</div><input id="toneMs" type="number" step="20" value="200"></label>
    <label class="kv"><div class="k">延迟 (ms)</div><input id="toneDelay" type="number" step="20" value="120"></label>
    <label class="kv"><div class="k">音量 (0-1)</div><input id="toneGain" type="number" step="0.05" value="0.5"></label>
  </div>
  <div class="row" style="margin-top:11px">
    <button class="sm primary" onclick="saveTone()">保存尾音设置（热点本地）</button>
    <button class="sm" onclick="loadTone()">读取当前值</button>
  </div>
  <div class="hint">热点本地功能：松开 PTT 后，本机热点在当前的 FM 通道发射 1000Hz 确认音。FM 通道按通道规划实时识别，不固定绑定任何通道。</div>
</div>

<div class="card">
  <h2>FM 哑音 (CTCSS)</h2>
  <div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(260px,1fr))">
    <label class="kv"><div class="k">收发哑音</div>
      <select id="ctTone" class="wide" style="width:100%"></select></label>
    <div class="kv"><div class="k">当前状态</div><div class="v" id="ctState">--</div></div>
  </div>
  <div class="row" style="margin-top:11px">
    <label class="row" style="gap:6px"><input id="ctNeedRx" type="checkbox">
      <span class="lbl" style="min-width:0">接收需要哑音（手台必须发同频 CTCSS 才能打开中继）</span></label>
  </div>
  <div class="row" style="margin-top:11px">
    <button class="sm primary" onclick="applyCtcss()">应用（会自动重启 MMDVM-Host）</button>
    <button class="sm" onclick="loadCtcss()">读取当前值</button>
  </div>
  <div class="hint">音表取自固件 <code>FMCTCSSRX.cpp</code> 的 <code>RX_CTCSS_TABLE_DATA</code>，
    共 <b>50 个标准模拟哑音</b>（67.0 – 254.1 Hz）。</div>
  <div class="warn">⚠️ 勾选「接收需要哑音」后，<b>手台必须开启同频 CTCSS（默认 88.5 Hz）</b>，
    否则按 PTT 时中继收得到信号却不会转发（IQ 日志会显示 squelch open 但状态不进入 RELAYING_RF）。
    不确定就先取消勾选，确认上行通了再决定是否启用。</div>
</div>

<div class="card">
  <h2>桥接日志</h2>
  <div class="row"><button class="sm" onclick="loadLog()">刷新日志</button></div>
  <pre id="logBox">点“刷新日志”查看。</pre>
</div>

<div id="toast"></div>

<script>
if(location.search.indexOf('embed=1')>=0)document.body.className='embed';
function $(i){return document.getElementById(i);}
function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");}
function dot(on){return '<span class="dot '+(on?"on":"off")+'"></span>';}
function toast(m,ok){var t=$("toast");t.textContent=m;t.className=(ok===false?"bad":(ok===true?"ok":""));t.style.display="block";clearTimeout(window._tt);window._tt=setTimeout(function(){t.style.display="none";},4200);}
function post(u,b){return fetch(u,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b||{})}).then(function(r){return r.json();});}
function num(i){return parseFloat($(i).value||0);}
function chk(i){return !!$(i).checked;}
function setCk(i,v){$(i).checked=!!v;}
function setV(i,v){if(v!==null&&v!==undefined)$(i).value=v;}

function refresh(){
  fetch("/api/fmo/status").then(function(r){return r.json();}).then(function(d){
    if(!d||d.ok===false){$("bSvc").textContent="读取失败";$("bSvc").className="badge bad";return;}
    var sv=d.services||{},h="",bad=0,registered=!!(d.cert&&d.cert.exists);
    var names={"fmo-bridge":"FMO 桥接","mmdvmhost":"MMDVM 主机","dmrgateway":"DMR 网关"};
    for(var k in sv){
      var active=sv[k]==="active", label=names[k]||k;
      if(k==="fmo-bridge"&&!registered){
        h+='<div class="svc"><span class="dot pending"></span>'+esc(label)+'<span class="svc-state">等待注册</span></div>';
      }else{
        h+='<div class="svc">'+dot(active)+esc(label)+'<span class="svc-state">'+(active?"运行中":"未运行")+'</span></div>';
        if(!active)bad++;
      }
    }
    $("svcs").innerHTML=h||"无";
    $("bSvc").textContent=!registered?"等待注册":(bad?("异常 "+bad+" 项"):"全部运行中");
    $("bSvc").className="badge "+(!registered?"":(bad?"bad":"ok"));
    $("bTime").textContent=new Date().toLocaleTimeString();
    fill(d.cfg||{});
  }).catch(function(e){toast("网络错误: "+e,false);});
}
function fill(c){
  var s=c.server||{},cl=c.client||{},fm=c.fm||{},v=c.voice||{},ce=c.cert||{};
  setV("srvHost",s.mqtt_host);setV("srvRegPort",s.reg_port||8081);setV("srvMqttPort",s.mqtt_port);
  setV("cliCs",cl.callsign||"");
  $("vSrv").textContent=(s.callsign||"--")+" / "+(s.uid==null?"--":s.uid);
  var registered=!!ce.exists;
  if($("regState")){
    $("regState").textContent=registered?"已注册":"未注册";
    $("regState").className=registered?"badge ok":"badge";
  }
  if($("regBtn")) $("regBtn").textContent=registered?"重新签发证书":"一键注册";
  $("vCert").textContent=(ce.callsign||cl.callsign||"--")+" / "+(ce.uid==null?(cl.uid==null?"--":cl.uid):ce.uid);
  $("vCertExp").textContent=ce.notAfter||"--";
  $("vFp").textContent=s.certFingerprint||"--";
  setV("vCodec",v.codec||"radpcm");setV("vTopic",v.topic||"FMO/RAW");
  setCk("vRelay",v.relay_to_public);
  setV("fmRxGain",fm.rxgain);setV("fmTxGain",fm.txgain);
  if($("fmFreqView"))$("fmFreqView").textContent=(fm.tx_mhz||fm.rx_mhz)?("TX "+(fm.tx_mhz||"--")+" MHz / RX "+(fm.rx_mhz||"--")+" MHz（按当前 FM 通道自动读取）"):"未检测到已启用的 FM 通道";
  var d=fm.dsp||{},t=fm.tone||{},ct=fm.ctcss||{};
  setCk("dspOn",d.enable);setV("dspHp",d.hpf_hz);setV("dspShelfDb",d.shelf_db);
  setV("dspShelfHz",d.shelf_hz);setV("dspLimit",d.limit);
  setCk("dspExp",d.expander);setV("dspExpThr",d.expander_thr);
  setCk("dspAgc",d.agc!==false);setV("agcTarget",d.agc_target);
  setV("agcGmax",d.agc_max_gain_db);setV("agcGmin",d.agc_min_gain_db);
  setV("agcDn",d.agc_down_ms);setV("agcUp",d.agc_up_ms);
}

function cfgBody(){
  return {
    server:{mqtt_host:$("srvHost").value.trim(),reg_port:num("srvRegPort"),
            mqtt_port:num("srvMqttPort")},
    client:{callsign:$("cliCs").value.trim().toUpperCase(),uid:0},
    fm:{rxgain:num("fmRxGain"),txgain:num("fmTxGain"),
        dsp:{enable:chk("dspOn"),hpf_hz:num("dspHp"),shelf_db:num("dspShelfDb"),
             shelf_hz:num("dspShelfHz"),limit:num("dspLimit"),
             expander:chk("dspExp"),expander_thr:num("dspExpThr"),
             agc:chk("dspAgc"),agc_target:num("agcTarget"),
             agc_max_gain_db:num("agcGmax"),agc_min_gain_db:num("agcGmin"),
             agc_down_ms:num("agcDn"),agc_up_ms:num("agcUp")},
        tone:{enable:chk("toneOn"),freq:num("toneFreq"),ms:Math.round(num("toneMs")),
              delay_ms:Math.round(num("toneDelay")),gain:num("toneGain")},
        ctcss:{enable:chk("ctOn"),rx:chk("ctRx"),tx:chk("ctTx"),freq:num("ctFreq")}},
    voice:{codec:$("vCodec").value,topic:$("vTopic").value.trim(),
           relay_to_public:chk("vRelay")}
  };
}
function saveCfg(restart){
  toast("保存中…");
  post("/api/fmo/save",cfgBody()).then(function(r){
    if(!r.ok){toast("保存失败: "+(r.error||""),false);return;}
    toast("已保存"+(restart?"，正在重启桥接…":""),true);
    if(restart){doRestart("bridge");}else{setTimeout(refresh,700);}
  }).catch(function(e){toast("错误: "+e,false);});
}
function doRestart(what){
  toast("正在重启 "+what+" …");
  post("/api/fmo/restart",{what:what}).then(function(r){
    toast(r.ok?("已重启: "+((r.restarted||[]).join(", "))):("失败: "+(r.error||"")),r.ok);
    setTimeout(refresh,2500);
  }).catch(function(e){toast("错误: "+e,false);});
}
function doPing(){
  var h=$("srvHost").value.trim(), p=num("srvRegPort")||8081;
  if(!h){toast("请先填服务器地址",false);return;}
  toast("正在探测 "+h+":"+p+" …");
  post("/api/fmo/ping",{host:h,port:p}).then(function(r){
    if(!r.ok){toast("探测失败: "+(r.error||""),false);return;}
    var s=(r.info&&r.info.server)||{};
    toast("服务器: "+(s.callsign||"?")+" uid="+(s.uid==null?"?":s.uid)
      +" MQTT端口="+(s.mqtt_port||s.mqttPort||"?"),true);
  }).catch(function(e){toast("错误: "+e,false);});
}
function doRegister(){
  var h=$("srvHost").value.trim(), p=num("srvRegPort")||8081;
  var cs=$("cliCs").value.trim().toUpperCase(), uid=0;
  if(!cs){toast("请填写本机呼号",false);return;}
  if(!confirm("将为呼号 "+cs+" 申请本机 FMO 证书，确定继续？"))return;
  toast("正在注册，请稍候…");
  post("/api/fmo/register",{host:h,port:p,callsign:cs,uid:uid}).then(function(r){
    if(!r.ok){toast("注册失败: "+(r.error||""),false);return;}
    toast("注册成功: "+r.client.callsign+" uid="+r.client.uid,true);
    refresh();
  }).catch(function(e){toast("错误: "+e,false);});
}
function fillTones(sel, tones, common, val){
  var h = '<option value="off">关闭（不启用哑音）</option>';
  h += '<optgroup label="常用哑音">';
  for (var i=0;i<common.length;i++) h += '<option value="'+common[i]+'">'+common[i]+' Hz</option>';
  h += '</optgroup><optgroup label="全部 50 个标准哑音">';
  for (var j=0;j<tones.length;j++) h += '<option value="'+tones[j]+'">'+tones[j]+' Hz</option>';
  h += '</optgroup>';
  sel.innerHTML = h;
  sel.value = val || 'off';
  if (sel.selectedIndex < 0) sel.value = 'off';
}
function loadCtcss(){
  fetch("/api/fmo/ctcss").then(function(r){return r.json();}).then(function(r){
    if(!r.ok){toast("读取哑音失败: "+(r.error||""),false);return;}
    var cur=r.current||{};
    fillTones($("ctTone"), r.tones||[], r.common||[], cur.tone);
    setCk("ctNeedRx", cur.need_rx!==false);
    var t=cur.tone||"off";
    $("ctState").textContent = (t==="off") ? "未启用"
      : (t+" Hz" + (cur.need_rx ? "（接收校验）" : "（仅发射带音）"));
    $("ctState").className = "v";
  }).catch(function(e){});
}
function applyCtcss(){
  var t=$("ctTone").value, nr=chk("ctNeedRx");
  if(t==="off"){
    if(!confirm("设为关闭 —— 中继将不校验哑音、发出去也不带哑音。确定？"))return;
  }else if(nr){
    if(!confirm("收发哑音 "+t+" Hz，且【接收校验】：\n手台必须开启同频 CTCSS 才能打开中继，"
      +"否则按 PTT 无效。\n\n确定应用？"))return;
  }else{
    if(!confirm("收发哑音 "+t+" Hz，但【接收不校验】：\n有载波就转发，发射时带 "+t
      +" Hz 哑音。\n\n确定应用？"))return;
  }
  toast("正在写入并重启 MMDVM-Host …");
  post("/api/fmo/apply-ctcss",{tone:t,need_rx:nr}).then(function(r){
    if(!r.ok){toast("失败: "+(r.error||""),false);return;}
    toast("哑音已应用："+(t==="off"?"关闭":t+" Hz"+(r.current.need_rx?"（接收校验）":"（仅发射带音）"))
      +"，Host 已重启", true);
    setTimeout(loadCtcss,3200);
  }).catch(function(e){toast("错误: "+e,false);});
}
function loadTone(){
  fetch("/api/fmo/tailtone").then(function(r){return r.json();}).then(function(t){
    if(!t||!t.ok)return;
    setCk("toneOn",t.enable);setV("toneFreq",t.freq);setV("toneMs",t.ms);
    setV("toneDelay",t.delay_ms);setV("toneGain",t.gain);
    var c=$("toneChan");if(c)c.textContent=(t.channel?("当前 FM 通道 CH"+t.channel):"未检测到 FM 通道");
  }).catch(function(){});
}
function saveTone(){
  post("/api/fmo/tailtone/save",{enable:chk("toneOn"),freq:num("toneFreq"),ms:Math.round(num("toneMs")),
       delay_ms:Math.round(num("toneDelay")),gain:num("toneGain")}).then(function(r){
    if(!r.ok){toast("失败: "+(r.error||""),false);return;}
    var a=r.applied||{};
    toast("尾音已保存：CH"+r.channel+" / "+(a.freq||"")+"Hz / "+(a.ms||"")+"ms",true);
    setTimeout(loadTone,3500);
  }).catch(function(e){toast("错误: "+e,false);});
}
function toneTest(){
  post("/api/fmo/tone-test",{}).then(function(r){
    toast(r.ok?(r.hint||"已发送"):("失败: "+(r.error||"")),r.ok);
  }).catch(function(e){toast("错误: "+e,false);});
}
function loadLog(){
  $("logBox").textContent="加载中…";
  fetch("/api/fmo/logs?lines=150").then(function(r){return r.json();}).then(function(r){
    $("logBox").textContent=r.text||"(空)";
    $("logBox").scrollTop=$("logBox").scrollHeight;
  }).catch(function(e){$("logBox").textContent="错误: "+e;});
}

refresh(); loadCtcss(); loadTone();
setInterval(refresh,5000);
</script>
</body>
</html>'''
```

### `opt/mmdvm-web/multi2_js.py`

```python
# -*- coding: utf-8 -*-
"""硬件基础配置页前端（只做监控；规划/呼号已移到射频模式配置页）
   注意：主控制台的 freqCalc / MC_DYN_CH 依赖本文件里的 chGridStep 与 PLN 变量，
        所以这两个必须保留（名字不能改）。"""

MULTI2_JS = r"""
var PLN = {channels:0, sample_rate:300000, callsign:'', list:[]};   /* 主控制台读它（规划摘要） */
var CH_GAP = 25000;
function chGridStep(i, sr){
  sr = parseInt(sr,10) || parseInt((typeof PLN==='object'&&PLN&&PLN.sample_rate)||0,10) || 300000;
  var n = Math.max(1, Math.round(sr/CH_GAP));
  var mf = Math.max(1, Math.min(Math.floor(n/2)-1, 4));
  var p = i-1, b = (p<mf) ? p : (n-1)-(p-mf);
  return (b*2<=n) ? b : b-n;
}
var MC_HZ = (typeof window!=='undefined' && window.FREQ_HZ) ? window.FREQ_HZ : function(v){
  var s=String(v==null?'':v).trim().replace(/[^0-9.]/g,''); var f=parseFloat(s);
  if(!isFinite(f)||f<=0) return 0;
  return Math.round((s.indexOf('.')>=0||f<100000)? f*1000000 : f);
};
var MC_MHZ = (typeof window!=='undefined' && window.FREQ_MHZ) ? window.FREQ_MHZ : function(hz){
  hz=parseInt(hz,10); if(!isFinite(hz)||hz<=0) return '';
  return (hz/1000000).toFixed((hz%12500===0&&hz%25000!==0)?4:3);
};
function mcI(id){ return document.getElementById(id); }
function mcEsc(s){ return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
function mcDot(on){ return '<span style="display:inline-block;width:7px;height:7px;border-radius:9px;margin-right:5px;background:'+(on?'var(--green2)':'#ef4444')+'"></span>'; }
function mcKV(k,v){ return '<div class="fld" style="background:var(--card2);border:1px solid var(--border);border-radius:9px;padding:9px 11px">'
  + '<div class="hint" style="margin-bottom:3px">'+mcEsc(k)+'</div>'
  + '<div style="font-size:13.5px;font-weight:700;color:#dfe8f5">'+mcEsc(v)+'</div></div>'; }
function mcMsg(t, ok){
  var e=mcI('mcMsg'); if(e) e.textContent=t||'';
  if(t && typeof toast==='function') toast(t, ok);
}
function mcPlanLite(){
  return fetch('/api/mc/plan', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(p){
    if(p && p.ok!==false && p.list){ PLN=p; if(!PLN.sample_rate) PLN.sample_rate=300000; }
    return PLN;
  }).catch(function(){ return PLN; });
}
function mcRefreshAll(){
  mcPlanLite();
  mcModeLite(function(){
    fetch('/api/mc/channels', {credentials:'same-origin'}).then(function(r){ return r.json(); })
    .then(function(d){
      if(!d || d.ok===false){ mcMsg('读取失败'+String((d&&(d.error||d.out))||''), false); return; }
      if(typeof mcSeqBuild==='function') mcSeqBuild(d.channels||[]);
      mcRenderEng(d); mcRenderChs(d); mcRenderSvcs(d);
    }).catch(function(e){ mcMsg('网络错误: '+e, false); });
  });
}
/* 当前运行模式：常规模式（多通道射频收发）/ 集群模式（TETRA 集群） */
var MC_MODE_TXT = '--';
function mcModeLite(cb){
  fetch('/api/tetra/status', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(d && d.ok!==false){
      var t=(d.service==='active'), m=(d.mmdvm==='active');
      MC_MODE_TXT = (t&&!m) ? '集群模式（TETRA 集群）'
                  : (m&&!t) ? '常规模式（多通道射频收发）'
                  : (!t&&!m)? '空闲（当前没有射频在运行）' : '异常（两种模式同时活动）';
    }
    if(cb) cb();
  }).catch(function(){ if(cb) cb(); });
}
function mcRenderEng(d){
  var e=d.multi||{}, box=mcI('mcEngBox'); if(!box) return;
  var nch = parseInt((PLN&&PLN.channels)||0,10) || (d.channels||[]).length;
  box.innerHTML = mcKV('当前模式', MC_MODE_TXT)
    + mcKV('引擎状态', e.active?'运行中':'已停止')
    + mcKV('运行通道数', nch + ' / 配置 ' + (e.channels||'-'))
    + mcKV('采样率', e.rate? (e.rate+' Hz'):'--')
    + mcKV('SDR 类型', e.sdr||'--')
    + mcKV('RX 中心', e.rx? (MC_MHZ(e.rx)+' MHz'):'--')
    + mcKV('TX 中心', e.tx? (MC_MHZ(e.tx)+' MHz'):'--')
    + mcKV('RX 增益', (e.rxg===''||e.rxg==null)?'--':e.rxg+' dB')
    + mcKV('TX 增益', (e.txg===''||e.txg==null)?'--':e.txg+' dB');
  var b=mcI('mcChBadge'); if(b) b.textContent='IQ/Host '+(d.channels||[]).length+' 路';
}
function mcRenderChs(d){
  var cs=d.channels||[], box=mcI('mcChBox'); if(!box) return;
  var h='';
  for(var i=0;i<cs.length;i++){
    var c=cs[i];
    h+='<div class="fld" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2);margin-bottom:9px">'
      + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:7px">'
      +   '<b style="font-size:12.5px;color:#7dd3fc">射频通道 '+(typeof mcSeq==='function'?mcSeq(c.n):c.n)+'</b>'
      +   '<span class="hint">'+mcEsc(c.mode)+'</span>'
      +   '<span style="margin-left:auto" class="hint">'+mcDot(c.iq)+'IQ '+mcDot(c.host)+'Host</span>'
      + '</div>'
      + '<div class="form-grid" style="margin:0">'
      +   mcKV('RX 频率', c.rx? (MC_MHZ(c.rx)+' MHz') : '--')
      +   mcKV('TX 频率', c.tx? (MC_MHZ(c.tx)+' MHz') : '--')
      +   mcKV('Multi 端口', c.mport)+mcKV('Host 端口', c.hport)
      + '</div>'
      + '<div class="hint" style="margin-top:8px">最近通联：'+mcEsc(c.lastq||'（无）')+'</div>'
      + '<div class="mc-foot" style="margin:8px 0 0">'
      +   '<button class="btn btn-ghost btn-sm" onclick="mcRestartCh('+c.n+')">&#8634; 重启本通道</button>'
      + '</div></div>';
  }
  box.innerHTML = h || '<div class="sp-empty">未检测到通道配置</div>';
}
function mcRenderSvcs(d){
  var sv=d.services||[], box=mcI('mcSvcBox'); if(!box) return;
  var h='';
  for(var i=0;i<sv.length;i++){ h+=mcKV(sv[i].name, (sv[i].active?'● 运行':'○ 停止')); }
  box.innerHTML = h || '<div class="hint">无服务信息</div>';
  var b=mcI('mcSvcBadge');
  if(b){ var on=sv.filter(function(x){return x.active;}).length; b.textContent=on+' / '+sv.length+' 在运行'; }
  mcFillLogCh(d.channels||[]);
}
function mcFillLogCh(cs){
  var sel=mcI('mcLogCh'); if(!sel) return;
  var cur=sel.value, h='<option value="0">（引擎级）</option>';
  if(typeof mcSeqBuild==='function') mcSeqBuild(cs);
  for(var i=0;i<cs.length;i++){
    if(String(cs[i].mode||'NONE').toUpperCase()==='NONE') continue;
    h+='<option value="'+cs[i].n+'">'+((typeof mcSeq==='function')?mcSeq(cs[i].n):cs[i].n)+'. '+mcEsc(cs[i].mode)+'</option>';
  }
  sel.innerHTML=h;
  if(cur) sel.value=cur;
}
function mcPostJson(url, body, cb){
  fetch(url,{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},
             body:JSON.stringify(body||{})})
    .then(function(r){ return r.json(); }).then(function(d){ cb(d); })
    .catch(function(e){ mcMsg('网络错误: '+e, false); });
}
function mcRestartCh(n){
  mcMsg('通道 '+n+' 正在重启…');
  mcPostJson('/api/mc/restart',{ch:n},function(d){
    var ok=!!(d&&d.ok);
    mcMsg(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')), ok);
    if(ok) setTimeout(mcRefreshAll, 2500);
  });
}
function mcRestartEngine(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。')) return;
  mcMsg('正在重启引擎…');
  mcPostJson('/api/mc/multi-restart',{},function(d){
    var ok=!!(d&&d.ok);
    mcMsg(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')), ok);
    if(ok) setTimeout(mcRefreshAll, 6000);
  });
}
function mcLoadLog(){
  var ch=(mcI('mcLogCh')||{}).value||'0', svc=(mcI('mcLogSvc')||{}).value||'mmdvmhost',
      ln=(mcI('mcLogLines')||{}).value||'200';
  var box=mcI('mcLogBox'); if(box) box.textContent='加载中…';
  fetch('/api/mc/logs?ch='+encodeURIComponent(ch)+'&svc='+encodeURIComponent(svc)+'&lines='+encodeURIComponent(ln),
        {credentials:'same-origin'})
  .then(function(r){ return r.json(); })
  .then(function(d){
    if(box) box.textContent = (d&&(d.text||d.out)) || ('读取失败：'+String((d&&d.error)||''));
  }).catch(function(e){ if(box) box.textContent='网络错误: '+e; });
}
/* 本文件提供「射频模式配置」页诊断卡所需的监控函数（原独立监控页已并入该页） */
"""
```

### `opt/mmdvm-web/multi2_section.py`

```python
# -*- coding: utf-8 -*-
"""硬件基础配置（原 Multi 看板）—— 只留「监控/诊断」用途
   呼号（最有用的那个）与通道数量/频率规划已并入「射频模式配置」页 ✓
   DMR ID 已去掉（它只对 DMR 有效，归 DMR 模式页）"""

MULTI2_SECTION = r"""
    <section class="page proto-page" id="page-multi" style="display:none">

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(56,189,248,.12);color:#38bdf8">&#129520;</div>
            <div>
              <div class="card-title">硬件与引擎状态（MMDVM-Multi &middot; SX1255 共享 I/Q 前端）</div>
              <div class="card-sub">只读监控：引擎状态、采样率、中心频率、增益、各通道与服务运行情况</div>
            </div>
          </div>
          <span class="card-badge">只读监控</span>
        </div>

        <div class="fld full"><div class="hint" style="border:1px dashed var(--border-lit);border-radius:9px;padding:10px 12px;line-height:1.7">
          <b style="color:#fbbf24">本页只做监控。</b>
          <b>呼号</b>、<b>通道数量</b>、<b>收发频率</b>与<b>各通道协议</b>都已集中到
          「<b>&#128251; 射频模式配置</b>」页（原来的 DMR ID 属于 DMR 模式，已移出本页）。
          <div style="margin-top:9px">
            <button type="button" class="btn btn-green btn-sm" onclick="switchPage('rfmode')">&#8594; 前往「射频模式配置」</button>
          </div>
        </div></div>

        <div class="form-grid" id="mcEngBox" style="margin-top:8px">
          <div class="hint">正在读取引擎状态&hellip;</div>
        </div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-ghost btn-sm" onclick="mcRefreshAll()">&#8635; 立即刷新</button>
          <button class="btn btn-ghost btn-sm" onclick="mcRestartEngine()">&#8634; 重启 Multi 引擎</button>
          <span class="hint" id="mcMsg" style="margin-left:10px"></span>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--green-dim);color:var(--green2)">&#128225;</div>
            <div>
              <div class="card-title">各通道运行状态</div>
              <div class="card-sub">IQ 基带 / MMDVM-Host 是否在跑，以及最近一次通联</div>
            </div>
          </div>
          <span class="card-badge" id="mcChBadge">通道 --</span>
        </div>
        <div id="mcChBox"><div class="sp-empty">正在读取通道&hellip;</div></div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--amber-dim);color:var(--amber2)">&#128202;</div>
            <div>
              <div class="card-title">服务状态</div>
              <div class="card-sub">MMDVM-Multi / 各通道 / 网关 / Web 服务</div>
            </div>
          </div>
          <span class="card-badge" id="mcSvcBadge">--</span>
        </div>
        <div id="mcSvcBox" class="form-grid"><div class="hint">正在读取服务&hellip;</div></div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(148,163,184,.12);color:#94a3b8">&#128220;</div>
            <div>
              <div class="card-title">日志查看</div>
              <div class="card-sub">查看 MMDVM-Multi / 各通道 / 各网关的运行日志</div>
            </div>
          </div>
        </div>
        <div class="form-grid">
          <div class="fld"><label>通道</label><select id="mcLogCh"></select></div>
          <div class="fld"><label>服务</label>
            <select id="mcLogSvc">
              <option value="mmdvmhost">MMDVM-Host</option>
              <option value="mmdvm-iq">MMDVM-IQ</option>
              <option value="mmdvm-multi">MMDVM-Multi</option>
              <option value="dmrgateway">DMRGateway</option>
              <option value="ircddbgatewayd">ircDDBGateway (D-STAR)</option>
              <option value="ysfgateway">YSFGateway</option>
              <option value="p25gateway">P25Gateway</option>
              <option value="nxdngateway">NXDNGateway</option>
            </select></div>
          <div class="fld"><label>行数</label>
            <select id="mcLogLines">
              <option value="100">100 行</option>
              <option value="200" selected>200 行</option>
              <option value="500">500 行</option>
            </select></div>
        </div>
        <div class="mc-foot" style="margin:10px 0 0">
          <button class="btn btn-green btn-sm" onclick="mcLoadLog()">&#128203; 加载日志</button>
        </div>
        <pre id="mcLogBox" style="margin-top:10px;max-height:380px;overflow:auto">点击「加载日志」查看。</pre>
      </div>

    </section>
"""
```

### `opt/mmdvm-web/rfmode2_js.py`

```python
# -*- coding: utf-8 -*-
"""射频模式配置页前端（独立 <script> 块，与主脚本隔离）
   频率输入习惯：填 MHz（439.500 / 439.5125）→ 自动换算 Hz。
   本文件分块拼接，见同目录 make_rfmode2_js.py"""

RFMODE2_JS = r"""
/* ==================== 频率输入习惯（与主控制台同一套） ====================
   输入 439.500 -> 439500000 Hz ；输入 439.5125 -> 439512500 Hz
   显示 439.500 / 439.5125。主控制台已有 window.FREQ_HZ / FREQ_MHZ，这里做兜底。 */
var RFM_HZ = (typeof window !== 'undefined' && window.FREQ_HZ) ? window.FREQ_HZ : function(v){
  if(v===null||v===undefined) return 0;
  var s=String(v).trim().replace(/[^0-9.]/g,'');
  if(s==='') return 0;
  var f=parseFloat(s); if(!isFinite(f)||f<=0) return 0;
  var mhz=(s.indexOf('.')>=0||f<100000);
  return Math.round(mhz? f*1000000 : f);
};
var RFM_MHZ = (typeof window !== 'undefined' && window.FREQ_MHZ) ? window.FREQ_MHZ : function(hz){
  hz=parseInt(hz,10); if(!isFinite(hz)||hz<=0) return '';
  var d=(hz%12500===0 && hz%25000!==0)?4:3;
  return (hz/1000000).toFixed(d);
};

var RFM_MODES = ['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];
var RFM_COL = {'DMR':'#0ea5e9','D-STAR':'#8b5cf6','YSF':'#f97316','NXDN':'#2563eb',
               'P25':'#e11d48','FM':'#10b981','POCSAG':'#f59e0b','NONE':'#64748b'};
var RFM_MAX = 6;          /* ★ 只用 6 路通道（第 7 路用不上，已去掉） */
var RFM_PLAN = null;      /* 最近一次 /api/mc/plan 的结果 */
var RFM_CHS = [];         /* 最近一次 /api/mc/channels 的通道表 */
var RFM_ID = '';          /* 台站 DMR Id（保存 RF 时要回传，避免被清空） */

/* ---- 模式互斥规则：DMR 可重复选，其它模式只能选一次 ---- */
function rfmApplyModeFilter2(){
  var allSels = document.querySelectorAll('.rfm-mode2, .rfm-mode');
  /* 只对“已启用”的射频通道做互斥；未启用通道只是历史规划，不算占用。 */
  var sels = Array.prototype.filter.call(allSels, function(sel){ return !sel.disabled; });
  if(!sels.length) return;
  /* 先收集所有“已启用”通道当前选中的非 DMR 模式 */
  var used = {};
  sels.forEach(function(sel){
    var v = sel.value;
    if(v && v !== 'DMR' && v !== 'NONE') used[v] = true;
  });
  /* 再对每个 select 重新过滤 */
  sels.forEach(function(sel){
    var cur = sel.value;
    for(var k = 0; k < sel.options.length; k++){
      var opt = sel.options[k], m = opt.value;
      if(m === 'DMR' || m === 'NONE'){
        opt.disabled = false; opt.style.color = '';
      } else if(used[m] && m !== cur){
        opt.disabled = true; opt.style.color = '#555';
      } else {
        opt.disabled = false; opt.style.color = '';
      }
    }
  });
}
var RFM_RX = '', RFM_TX = '';   /* MMDVM-Host 里的频率原值（回传，不改动） */

function rfmQ(id){ return document.getElementById(id); }
function rfmSvg(m){
  var M=String(m||'').toUpperCase(), col=RFM_COL[M]||'#64748b', g='';
  if(M==='DMR') g='<rect x="9" y="15" width="22" height="4" rx="2" fill="#fff"/><rect x="9" y="23" width="14" height="4" rx="2" fill="#fff" opacity=".75"/>';
  else if(M==='D-STAR') g='<path d="M20 10l2.6 6.1 6.4.6-4.9 4.3 1.5 6.4L20 24l-5.6 3.4 1.5-6.4-4.9-4.3 6.4-.6z" fill="#fff"/>';
  else if(M==='YSF') g='<path d="M20 10l8 10-8 10-8-10z" fill="#fff"/>';
  else if(M==='NXDN') g='<path d="M13 27V13h4.2l5.6 8V13H27v14h-4.2l-5.6-8v8z" fill="#fff"/>';
  else if(M==='P25') g='<path d="M20 10l9 3.4v7.1c0 5-3.7 8.2-9 9.5-5.3-1.3-9-4.5-9-9.5v-7.1z" fill="#fff"/>';
  else if(M==='FM') g='<path d="M9 20c2.6-6 5.2-6 7.8 0s5.2 6 7.8 0 5.2-6 5.4 0" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>';
  else g='<circle cx="20" cy="20" r="6" fill="#fff" opacity=".7"/>';
  return '<svg viewBox="0 0 40 40" width="28" height="28" xmlns="http://www.w3.org/2000/svg" style="flex:0 0 28px"><rect x="2" y="2" width="36" height="36" rx="9" fill="'+col+'"/>'+g+'</svg>';
}
function rfmPost(url, body, cb){
  fetch(url, {method:'POST', credentials:'same-origin',
    headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})})
    .then(function(r){ return r.json(); })
    .then(function(d){ cb(d); })
    .catch(function(e){ if(typeof toast==='function') toast('失败：'+e,false); });
}
function rfmMsg(id, txt, ok){
  var e=rfmQ(id); if(e) e.textContent=txt||'';
  if(txt && typeof toast==='function') toast(txt, ok);
}
/* ==================== 硬件网格（与后端 channel-setup.py 一致） ====================
   ch1=中心, ch2=+25k, ch3=-25k, ch4=-50k, ch5=+75k …（SampleRate/25000 = PFB bin 数） */
function rfmGridStep(i, sr){
  sr = parseInt(sr,10) || parseInt((RFM_PLAN&&RFM_PLAN.sample_rate)||0,10) || 300000;
  var n = Math.max(1, Math.round(sr/25000));
  var mf = Math.max(1, Math.min(Math.floor(n/2), 4));
  var p = i-1, b = (p<mf) ? p : (n-1)-(p-mf);
  return (b*2<=n) ? b : b-n;
}
function rfmSpacingHz(){
  var v = parseFloat((rfmQ('rfmGap')||{}).value);
  return isFinite(v)&&v>0 ? Math.round(v*1000) : 25000;
}
function rfmCenterRx(){ return RFM_HZ((rfmQ('rfmCrx')||{}).value); }
function rfmCenterTx(){ return RFM_HZ((rfmQ('rfmCtx')||{}).value); }
function rfmChanRx(i){ return rfmCenterRx() + rfmGridStep(i)*rfmSpacingHz(); }
function rfmChanTx(i){ return rfmCenterTx() + rfmGridStep(i)*rfmSpacingHz(); }
function rfmNChSel(){
  var v = parseInt((rfmQ('rfmNCh')||{}).value,10);
  return (isFinite(v)&&v>=1) ? Math.min(RFM_MAX, v) : 6;
}

/* ==================== 台站参数（呼号 / 功率 / 增益） ==================== */
function rfmFlat(d){
  var flat={};
  Object.keys(d||{}).forEach(function(k){
    var v=d[k];
    if(v && typeof v==='object'){ Object.keys(v).forEach(function(k2){ flat[k2]=v[k2]; flat[String(k2).toLowerCase()]=v[k2]; }); }
    else { flat[k]=v; }
  });
  return flat;
}
function rfmPick(flat){
  for(var i=1;i<arguments.length;i++){
    var k=arguments[i], lk=String(k).toLowerCase();
    if(flat[k]!==undefined && flat[k]!==null && String(flat[k])!=='') return flat[k];
    if(flat[lk]!==undefined && flat[lk]!==null && String(flat[lk])!=='') return flat[lk];
  }
  return '';
}
function rfmLoadStation(){
  fetch('/api/config/RF', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    var f=rfmFlat(d);
    var c=rfmPick(f,'Callsign','callsign');
    if(c && c!=='NOCALL' && rfmQ('rfmCall')) rfmQ('rfmCall').value=String(c).toUpperCase();
    var p=rfmPick(f,'Power','power');
    if(p!=='' && rfmQ('rfmPower')) rfmQ('rfmPower').value=(String(p).trim()==='0'?'0':'7');
    var rx=rfmPick(f,'RxGain','rxgain');
    if(rx!=='' && rfmQ('rfmRxGain')) rfmQ('rfmRxGain').value=String(rx);
    var tx=rfmPick(f,'TxGain','txgain');
    if(tx!=='' && rfmQ('rfmTxGain')) rfmQ('rfmTxGain').value=String(tx);
    /* 缓存台站 Id 与 MMDVM-Host 频率原值：保存时原样回传，不动无关配置 */
    RFM_ID = String(rfmPick(f,'Id','id')||'');
    RFM_RX = String(rfmPick(f,'RXFrequency','rxfreq')||'');
    RFM_TX = String(rfmPick(f,'TXFrequency','txfreq')||'');
  }).catch(function(){});
}
function rfmPowerChanged(){
  var sel=rfmQ('rfmPower'), tg=rfmQ('rfmTxGain');
  if(!sel||!tg) return;
  tg.value = (String(sel.value).trim()==='0') ? 10 : 30;
}
function rfmSaveStation(){
  var call=String((rfmQ('rfmCall')||{}).value||'').trim().toUpperCase();
  var p=(rfmQ('rfmPower')||{}).value, rx=(rfmQ('rfmRxGain')||{}).value, tx=(rfmQ('rfmTxGain')||{}).value;
  if(!call){ rfmMsg('rfmStMsg','请先填写本机呼号', false); return; }
  if(call==='NOCALL'){ rfmMsg('rfmStMsg','呼号不能是 NOCALL', false); return; }
  rfmMsg('rfmStMsg','正在保存…');
  /* ★ 接口是 /api/save/RF（大写！），字段名与页面表单一致：
     callsign / id / rxfreq / txfreq / duplex / power / rxgain / txgain
     —— id 与频率用读回来的原值回传，不改动无关配置 */
  rfmPost('/api/save/RF',{callsign:call,
                          id: RFM_ID || String((RFM_PLAN&&RFM_PLAN.dmr_id)||''),
                          rxfreq: RFM_RX, txfreq: RFM_TX, duplex:'1',
                          power:String(p), rxgain:String(rx), txgain:String(tx)}, function(d){
    var ok=!!(d&&d.ok!==false);
    var t=String((d&&(d.message||d.out||d.error))||'');
    if(!ok){ rfmMsg('rfmStMsg','❌ '+t.slice(0,80), false); return; }
    var body=rfmPlanPayload(call);      /* 呼号同时同步到各网关（仅保存，不重启） */
    if(!body){ rfmMsg('rfmStMsg','✅ 呼号/功率/增益已保存'); return; }
    rfmPost('/api/mc/plan-save', body, function(r2){
      var ok2=!!(r2&&r2.ok!==false);
      rfmMsg('rfmStMsg', ok2? '✅ 已保存，并同步到各网关'
                            : '⚠ 已保存，但网关呼号同步失败：'+String((r2&&(r2.out||r2.error))||'').slice(0,60), ok2);
      if(RFM_PLAN) RFM_PLAN.callsign=call;
    });
  });
}
/* ==================== 信道规划：读取 / 渲染 ==================== */
function rfmFillNCh(n){
  var sel=rfmQ('rfmNCh'); if(!sel) return;
  var h='';
  for(var i=1;i<=RFM_MAX;i++){
    h+='<option value="'+i+'"'+(i===n?' selected':'')+'>'+i+' 路通道'+(i===1?'（单通道）':'（同时收发）')+'</option>';
  }
  sel.innerHTML=h;
}
function rfmPlanLoad(){
  fetch('/api/mc/plan', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false){
      var bx=rfmQ('rfmRows');
      if(bx) bx.innerHTML='<div class="sp-empty">读取规划失败：'+String((d&&(d.out||d.error))||'')+'</div>';
      return;
    }
    RFM_PLAN=d;
    var n=parseInt(d.channels||1,10)||1;
    rfmFillNCh(n);
    if(rfmQ('rfmCrx')) rfmQ('rfmCrx').value=RFM_MHZ(d.center_rx)||RFM_MHZ(434450000);
    if(rfmQ('rfmCtx')) rfmQ('rfmCtx').value=RFM_MHZ(d.center_tx)||RFM_MHZ(439450000);
    if(rfmQ('rfmGap')){
      var sp=parseInt(d.spacing||25000,10);
      rfmQ('rfmGap').value=(sp<20000)?'12.5':'25';
    }
    if(rfmQ('rfmCall') && d.callsign && !rfmQ('rfmCall').value) rfmQ('rfmCall').value=String(d.callsign).toUpperCase();
    rfmRenderRows();
    var m=rfmQ('rfmPlanMsg'); if(m) m.textContent='';
  }).catch(function(e){ if(typeof toast==='function') toast('读取规划失败：'+e,false); });
}
function rfmRenderRows(){
  var box=rfmQ('rfmRows'); if(!box) return;
  var n=rfmNChSel();
  var plan={}; ((RFM_PLAN&&RFM_PLAN.list)||[]).forEach(function(c){ plan[parseInt(c.n,10)]=c; });
  var h='<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:11px">';
  for(var i=1;i<=RFM_MAX;i++){
    var c=plan[i]||{n:i,mode:'NONE'};
    var on=(i<=n);
    var opts=RFM_MODES.map(function(m){
      return '<option value="'+m+'"'+(String(c.mode||'NONE').toUpperCase()===m?' selected':'')+'>'+m+'</option>';
    }).join('');
    h+='<div class="fld" data-n="'+i+'" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2);'+(on?'':'opacity:.45')+'">'
      +   '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">'
      +     '<span style="width:8px;height:8px;border-radius:9px;flex:0 0 8px;background:'+(on?'var(--green2)':'#64748b')+'"></span>'
      +     '<b style="font-size:12.5px;color:#7dd3fc">通道 '+i+'</b>'
      +     (on?''+'<span class="hint">已启用</span>':'<span class="hint">未启用</span>')
      +     '<span style="margin-left:auto;display:flex;align-items:center;gap:7px;color:#fff;font-weight:800;font-size:12.5px">'
      +     rfmSvg(c.mode)+String(c.mode||'NONE')+'</span>'
      +   '</div>'
      +   '<div style="display:flex;gap:7px;align-items:center;margin-bottom:7px">'
      +     '<span class="hint" style="flex:0 0 26px">RX</span>'
      +     '<input class="rfm-rx" type="text" inputmode="decimal" value="'+RFM_MHZ(rfmChanRx(i))+'" style="flex:1;min-width:0" onchange="rfmRowEdited('+i+')">'
      +     '<span class="rfm-fq hint" style="flex:0 0 96px;text-align:right">'+RFM_MHZ(rfmChanRx(i))+'</span>'
      +   '</div>'
      +   '<div style="display:flex;gap:7px;align-items:center;margin-bottom:9px">'
      +     '<span class="hint" style="flex:0 0 26px">TX</span>'
      +     '<input class="rfm-tx" type="text" inputmode="decimal" value="'+RFM_MHZ(rfmChanTx(i))+'" style="flex:1;min-width:0" onchange="rfmRowEdited('+i+')">'
      +     '<span class="rfm-fq hint" style="flex:0 0 96px;text-align:right">'+RFM_MHZ(rfmChanTx(i))+'</span>'
      +   '</div>'
      +   '<div style="display:flex;gap:6px;align-items:center">'
      +     '<select class="rfm-mode" style="flex:1;min-width:0"'+(on?'':' disabled')+' onchange="rfmApplyModeFilter2()">'+opts+'</select>'
      +     '<button class="btn btn-ghost btn-sm" onclick="rfmRowGrid('+i+')" title="按中心频率 + 硬件网格重算">按间距算</button>'
      +   '</div>'
      + '</div>';
  }
  h+='</div>';
  box.innerHTML=h;
  rfmPlanFreqs();
  rfmApplyModeFilter2();
}
function rfmPlanFreqs(){
  var box=rfmQ('rfmRows'); if(!box) return;
  var n=rfmNChSel();
  var cards=box.querySelectorAll('.fld[data-n]');
  Array.prototype.forEach.call(cards,function(cd){
    var i=parseInt(cd.getAttribute('data-n'),10), on=(i<=n);
    cd.style.opacity = on? '' : '.45';
    var r=cd.querySelector('.rfm-rx'), t=cd.querySelector('.rfm-tx');
    var md=cd.querySelector('.rfm-mode'); if(md) md.disabled=!on;
    var fq=cd.querySelectorAll('.rfm-fq');
    if(r) r.value=RFM_MHZ(rfmChanRx(i));
    if(t) t.value=RFM_MHZ(rfmChanTx(i));
    if(fq.length>=2){ fq[0].textContent=RFM_MHZ(rfmChanRx(i)); fq[1].textContent=RFM_MHZ(rfmChanTx(i)); }
  });
  if(rfmQ('rfmCrxHz')) rfmQ('rfmCrxHz').textContent='= '+rfmCenterRx()+' Hz';
  if(rfmQ('rfmCtxHz')) rfmQ('rfmCtxHz').textContent='= '+rfmCenterTx()+' Hz';
  rfmBanner();
}
function rfmCenterTyped(){ rfmPlanFreqs(); }
function rfmNChChanged(){ rfmRenderRows(); }
function rfmRowGrid(i){
  var cd=document.querySelector('#rfmRows .fld[data-n="'+i+'"]'); if(!cd) return;
  var r=cd.querySelector('.rfm-rx'), t=cd.querySelector('.rfm-tx');
  if(r) r.value=RFM_MHZ(rfmChanRx(i));
  if(t) t.value=RFM_MHZ(rfmChanTx(i));
  rfmPlanFreqs();
}
/* 用户改某一路 -> 以该路为准反推中心频率，其它通道按网格跟着走（与后端算法一致） */
function rfmRowEdited(i){
  var cd=document.querySelector('#rfmRows .fld[data-n="'+i+'"]'); if(!cd) return;
  var st=rfmGridStep(i), sp=rfmSpacingHz();
  var r=RFM_HZ((cd.querySelector('.rfm-rx')||{}).value);
  var t=RFM_HZ((cd.querySelector('.rfm-tx')||{}).value);
  if(r>0 && rfmQ('rfmCrx')) rfmQ('rfmCrx').value=RFM_MHZ(r-st*sp);
  if(t>0 && rfmQ('rfmCtx')) rfmQ('rfmCtx').value=RFM_MHZ(t-st*sp);
  rfmPlanFreqs();
}
/* ==================== 提交规划（必须带 center/spacing/sample_rate，后端按它们算每路频率） ==================== */
function rfmPlanPayload(callOverride){
  var cx=rfmCenterRx(), ct=rfmCenterTx();
  if(!cx||!ct) return null;
  var n=rfmNChSel(), sp=rfmSpacingHz();
  var old={}; ((RFM_PLAN&&RFM_PLAN.list)||[]).forEach(function(c){ old[parseInt(c.n,10)]=c; });
  var list=[];
  for(var i=1;i<=RFM_MAX;i++){
    var sel=document.querySelector('#rfmRows .fld[data-n="'+i+'"] .rfm-mode');
    var mode = sel ? sel.value : String((old[i]&&old[i].mode)||'NONE').toUpperCase();
    /* ★ 超出通道数的通道：模式照原样提交（后端只启停服务，不动保存的模式）
       —— 这样「通道数量」调小再调大时，各通道原来的协议还在，可以完整恢复多通道 */
    list.push({n:i, mode:mode, rx:cx+rfmGridStep(i)*sp, tx:ct+rfmGridStep(i)*sp});
  }
  var call = (callOverride!==undefined) ? callOverride
             : String((rfmQ('rfmCall')||{}).value||'').trim().toUpperCase();
  return {channels:n, center_rx:cx, center_tx:ct, spacing:sp,
          sample_rate:parseInt((RFM_PLAN&&RFM_PLAN.sample_rate)||150000,10)||150000,
          callsign: call || String((RFM_PLAN&&RFM_PLAN.callsign)||''),
          dmr_id: String((RFM_PLAN&&RFM_PLAN.dmr_id)||''),
          list:list};
}
function rfmPlanApply(onlySave){
  var body=rfmPlanPayload();
  if(!body){ rfmMsg('rfmPlanMsg','请先填写中心频率（MHz）', false); return; }
  if(!onlySave && !confirm('将写入 MMDVM-Multi.ini 与各通道配置，并重启受影响的通道。\n大约需要 20-60 秒。\n\n请遵守所在国家或地区关于无线电发射设备、频率使用和操作资格的法律法规。因违规使用造成的后果由操作者承担，与本软件及作者无关。\n\n继续？')) return;
  rfmMsg('rfmPlanMsg', onlySave?'正在保存规划…':'正在应用规划（约 20-60 秒）…');
  rfmPost('/api/mc/plan-'+(onlySave?'save':'apply'), body, function(d){
    var ok=!!(d&&d.ok!==false);
    var t=String((d&&(d.out||d.message||d.error))||'').trim();
    rfmMsg('rfmPlanMsg',(ok?'✅ ':'❌ ')+t.slice(0,120)+(ok?' ｜ 请遵守当地无线电发射法规，违规后果由操作者承担，与本软件无关。':''), ok);
    if(ok) setTimeout(function(){ rfmPlanLoad(); rfmLoad(); rfmModeStatus(); }, 2500);
  });
}
/* ==================== 运行模式：常规模式 <-> 集群模式 ====================
   常规模式 = MMDVM 多通道射频收发；集群模式 = TETRA 集群基站（互斥，同一块 SX1255）
   后端接口：/api/tetra/status 与 /api/tetra/switch {what:'mmdvm'|'tetra'}（失败自动回滚） */
function rfmModeStatus(){
  var el=rfmQ('rfmModeNow'); if(!el) return;
  fetch('/api/tetra/status', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false){ el.textContent='读取失败'; el.style.color='#ef4444'; return; }
    var tetra=(d.service==='active'), mm=(d.mmdvm==='active');
    var txt, col, badge;
    if(tetra && !mm){ txt='集群模式（TETRA 集群基站运行中，多通道已停止）'; col='var(--amber2)'; badge='集群模式'; }
    else if(mm && !tetra){ txt='常规模式（多通道射频收发运行中）'; col='var(--green2)'; badge='常规模式'; }
    else if(!mm && !tetra){ txt='当前没有射频在运行（点下面按钮选择模式）'; col='#94a3b8'; badge='空闲'; }
    else { txt='异常：TETRA 与多通道同时活动，请点一次切换按钮纠正'; col='#ef4444'; badge='冲突'; }
    el.textContent = txt + (d.sdr_owner ? (' ｜ SX1255 归属：'+d.sdr_owner) : '');
    el.style.color = col;
    var b=rfmQ('rfmModeBadge'); if(b) b.textContent=badge;
    var bm=rfmQ('rfmBtnMmdvm'), bt=rfmQ('rfmBtnTetra');   /* 当前模式按钮置灰防误点 */
    if(bm) bm.style.opacity=(mm && !tetra)? '.5' : '';
    if(bt) bt.style.opacity=(tetra && !mm)? '.5' : '';
  }).catch(function(e){ el.textContent='读取失败：'+e; el.style.color='#ef4444'; });
}
function rfmSwitchMode(what){
  var toTetra=(what==='tetra');
  var q = toTetra
    ? '把 SX1255 交给「集群模式（TETRA 集群）」？\n\n将停止全部多通道射频收发（MMDVM-Multi 与各通道），然后启动 TETRA 集群基站。'
    : '切回「常规模式（多通道射频收发）」？\n\n将停止 TETRA 集群基站，然后按「信道规划」启动多通道（引擎 + 各通道 IQ/Host）。';
  if(!confirm(q + '\n\n约需 20-40 秒，期间射频中断。继续？')) return;
  var msg = toTetra ? '正在切换到集群模式（TETRA 集群）…' : '正在切换回常规模式（多通道）…';
  var el=rfmQ('rfmModeNow'); if(el){ el.textContent=msg; el.style.color='#fbbf24'; }
  if(typeof toast==='function') toast(msg);
  rfmPost('/api/tetra/switch',{what:what},function(d){
    var ok=!!(d&&d.ok);
    var t=String((d&&(d.error||d.out))||'');
    if(typeof toast==='function')
      toast(ok ? (toTetra?'已切换到集群模式（TETRA 集群）':'已切换回常规模式（多通道）')
               : ('切换失败：'+t.slice(0,80)), ok);
    setTimeout(function(){ rfmModeStatus(); rfmLoad(); rfmPlanLoad(); }, 2500);
  });
}
/* ==================== 各通道状态（永远列出 1-6，含未启用） ==================== */
function rfmBanner(){
  var b=rfmQ('rfmBanner'); if(!b) return;
  var n=rfmNChSel(), arr=[];
  for(var i=1;i<=n;i++){ arr.push('CH'+i+' '+RFM_MHZ(rfmChanRx(i))+'/'+RFM_MHZ(rfmChanTx(i))); }
  b.textContent = arr.join('  ｜  ') || '（未配置）';
}
function rfmLoad(){
  fetch('/api/mc/channels', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false) return;
    var live={};
    (d.channels||[]).forEach(function(c){ live[parseInt(c.n,10)]=c; });
    RFM_CHS=d.channels||[];
    rfmApplyModeFilter2();
    var planList={}; ((RFM_PLAN&&RFM_PLAN.list)||[]).forEach(function(c){ planList[parseInt(c.n,10)]=c; });
    if(typeof mcSeqBuild==='function') mcSeqBuild(d.channels||[]);
    var box=rfmQ('rfmChans'); if(!box) return;
    var h='<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px">';
    for(var i=1;i<=RFM_MAX;i++){
      var c=live[i]||null;
      var mode=c?String(c.mode||'NONE'):String((planList[i]&&planList[i].mode)||'NONE');
      var rx=(c&&c.rx)?c.rx:((planList[i]&&planList[i].rx)||0);
      var tx=(c&&c.tx)?c.tx:((planList[i]&&planList[i].tx)||0);
      var run=!!(c&&c.iq&&c.host), on=!!c;
      var curMode=mode.toUpperCase();
      var usedElsewhere={};
      for(var j=1;j<=RFM_MAX;j++){
        if(j===i) continue;
        var c2=live[j]||null;
        var m2=c2?String(c2.mode||'NONE').toUpperCase():String((planList[j]&&planList[j].mode)||'NONE').toUpperCase();
        if(m2&&m2!=='DMR'&&m2!=='NONE') usedElsewhere[m2]=true;
      }
      var opts=RFM_MODES.map(function(m){
        var dis=(m!=='DMR'&&m!=='NONE'&&usedElsewhere[m]&&m!==curMode);
        return '<option value="'+m+'"'+(curMode===m?' selected':'')+(dis?' disabled style="color:#555"':'')+'>'+m+'</option>';
      }).join('');
      h+='<div class="fld" data-ch="'+i+'" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2);'+(on?'':'opacity:.5')+'">'
        +   '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">'
        +     '<span style="width:8px;height:8px;border-radius:9px;flex:0 0 8px;background:'+(run?'var(--green2)':(on?'var(--amber2)':'#64748b'))+'"></span>'
        +     '<b style="font-size:12.5px;color:#7dd3fc">通道 '+i+'</b>'
        +     (on?'':'<span class="hint">未启用</span>')
        +     '<span style="margin-left:auto;display:flex;align-items:center;gap:7px;color:#fff;font-weight:800;font-size:12.5px">'
        +     rfmSvg(mode)+mode+'</span>'
        +   '</div>'
        +   '<div class="hint" style="margin-bottom:8px">RX '+RFM_MHZ(rx)+' / TX '+RFM_MHZ(tx)+' MHz'
        +     (on?((c.iq?' ｜ IQ ✓':' ｜ IQ ✗')+(c.host?' Host ✓':' Host ✗')):'')+'</div>'
        +   '<div style="display:flex;gap:6px;align-items:center">'
        +     '<select class="rfm-mode2" style="flex:1;min-width:0"'+(on?'':' disabled')+' onchange="rfmApplyModeFilter2()">'+opts+'</select>'
        +     '<button class="btn btn-green btn-sm"'+(on?'':' disabled')+' onclick="rfmSwitch('+i+')">切换</button>'
        +     '<button class="btn btn-ghost btn-sm"'+(on?'':' disabled')+' onclick="rfmRestart('+i+')">重启</button>'
        +   '</div>'
        + '</div>';
    }
    h+='</div>';
    box.innerHTML=h;
    rfmBanner();
  }).catch(function(){});
}
function rfmSwitch(n){
  var card=document.querySelector('#rfmChans .fld[data-ch="'+n+'"]'); if(!card) return;
  var c2=card.querySelector('.rfm-mode2'); var mode=c2?c2.value:'NONE';
  if(!confirm('把射频通道 '+n+' 切换为 '+mode+' ？将重启该通道。')) return;
  if(typeof toast==='function') toast('通道 '+n+' 正在切换…');
  rfmPost('/api/mc/switch',{ch:n,mode:mode},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?('通道 '+n+' 已切换为 '+mode):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(function(){ rfmLoad(); rfmPlanLoad(); }, 1800);
  });
}
function rfmRestart(n){
  if(!confirm('重启射频通道 '+n+' 的基带与主机？')) return;
  rfmPost('/api/mc/restart',{ch:n},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(rfmLoad,1800);
  });
}
function rfmRestartAll(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。')) return;
  rfmPost('/api/mc/multi-restart',{},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(function(){ rfmLoad(); rfmPlanLoad(); }, 5000);
  });
}
document.addEventListener('change', function(e){
  if(e.target && e.target.id==='rfmPower') rfmPowerChanged();
});
(function(){ var s=rfmQ('rfmPower'); if(s) rfmPowerChanged(); })();
/* 兜底：规划读取失败时页面也能用（默认中心频率 + 通道数选择器 + 通道行） */
(function(){
  if(!rfmQ('rfmNCh')) return;
  if(!rfmQ('rfmNCh').innerHTML) rfmFillNCh(6);
  if(rfmQ('rfmCrx') && !rfmQ('rfmCrx').value) rfmQ('rfmCrx').value='434.450';
  if(rfmQ('rfmCtx') && !rfmQ('rfmCtx').value) rfmQ('rfmCtx').value='439.450';
  if(rfmQ('rfmRows') && !document.querySelector('#rfmRows .fld[data-n]')) rfmRenderRows();
})();
"""
```

### `opt/mmdvm-web/rfmode2_section.py`

```python
# -*- coding: utf-8 -*-
"""射频模式配置（统一页）—— 复用主控制台组件类，外观与 DMR 页一致
   ① 台站与射频参数：呼号 + 功率档位 + 收发增益（原「硬件基础配置」的呼号并到这里）
   ② 信道规划：通道数量(1-7) + 中心频率(MHz) + 间距(kHz) + 各通道模式/频率
   ③ 各通道状态与模式切换：永远列出 1-7 全部通道（含未启用），可随时调回多通道"""

RFMODE2_SECTION = r"""
    <section class="page proto-page" id="page-rfmode" style="display:none">

      <!-- ===== ① 运行模式（常规模式 <-> 集群模式） ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(244,114,182,.12);color:#f472b6">&#128256;</div>
            <div>
              <div class="card-title">运行模式切换（常规模式 &#8644; 集群模式）</div>
              <div class="card-sub">
                一块 SX1255 同一时刻只能被一个程序独占：
                <b>常规模式</b> = 多通道射频收发（MMDVM-Multi 各通道同时跑）；
                <b>集群模式</b> = TETRA 集群基站。
                两种模式互斥，这里一键切换（切换失败会自动回滚）
              </div>
            </div>
          </div>
          <span class="card-badge" id="rfmModeBadge">--</span>
        </div>

        <div class="freq-banner">
          <div class="freq-banner-ico">&#128256;</div>
          <div class="freq-banner-txt">
            <b>当前运行模式</b>
            <span id="rfmModeNow">读取中&hellip;</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="rfmModeStatus()">&#8635; 刷新</button>
        </div>

        <div class="form-grid" style="margin-top:12px">
          <div class="fld">
            <button class="btn btn-green" id="rfmBtnMmdvm" onclick="rfmSwitchMode('mmdvm')" style="width:100%">&#128257; 一键切换到常规模式（多通道射频收发）</button>
            <div class="hint">启动 MMDVM-Multi 引擎 + 各通道（IQ/Host），按下面「信道规划」的内容跑多路协议</div>
          </div>
          <div class="fld">
            <button class="btn btn-ghost" id="rfmBtnTetra" onclick="rfmSwitchMode('tetra')" style="width:100%">&#128257; 一键切换到集群模式（TETRA 集群）</button>
            <div class="hint">停止全部多通道射频收发，把 SX1255 交给 TETRA 集群基站</div>
          </div>
        </div>
        <div class="hint" style="margin-top:8px">
          切换约需 20&ndash;40 秒，期间射频会短暂中断；切回常规模式后多通道会按「信道规划」里的配置自动恢复。
        </div>
      </div>

      <!-- ===== ② 台站与射频参数 ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--green-dim);color:var(--green2)">&#128295;</div>
            <div>
              <div class="card-title">台站与射频参数（呼号 &middot; 功率 &middot; 增益）</div>
              <div class="card-sub">热点呼号写入 MMDVM-Host 与各网关；功率/增益写入 MMDVM-IQ.ini [Soapy]，全模式通用</div>
            </div>
          </div>
          <span class="card-badge">SX1255</span>
        </div>

        <div class="form-grid">
          <div class="fld"><label>本机呼号 (Callsign)</label>
            <input id="rfmCall" placeholder="NOCALL" maxlength="10" autocomplete="off">
            <div class="hint">一定要与 BrandMeister / 网关注册的呼号一致（会写进 MMDVM-Host 与各网关）</div></div>
          <div class="fld"><label>射频发射功率档位 (SX1255)</label>
            <select id="rfmPower">
              <option value="7">大功率 &#8212; 7 级 / +7 dBm / 5 mW（远距离、抗干扰）</option>
              <option value="0">小功率 &#8212; 0 级 / 0 dBm / 1 mW（近距离、更健康）</option>
            </select>
            <div class="hint">切换档位会自动把 TxGain 设为 30dB（大功率）或 10dB（小功率）</div></div>
          <div class="fld"><label>SX1255 硬件基带接收增益 (RxGain 0-60 dB)</label>
            <input id="rfmRxGain" type="number" min="0" max="60" value="50">
            <div class="hint">MMDVM-IQ.ini [Soapy]，推荐 50 dB（此值为当前配置，已自动读取）</div></div>
          <div class="fld"><label>SX1255 硬件基带发射增益 (TxGain 0-40 dB)</label>
            <input id="rfmTxGain" type="number" min="0" max="40" value="30">
            <div class="hint">大功率推荐 30dB / 小功率推荐 10dB</div></div>
        </div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="rfmSaveStation()">&#128190; 保存呼号 / 功率 / 增益</button>
          <span class="hint" id="rfmStMsg" style="margin-left:10px"></span>
        </div>
      </div>

      <!-- ===== ③ 信道规划 ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--amber-dim);color:var(--amber2)">&#128226;</div>
            <div>
              <div class="card-title">信道规划（多信道同时收发）</div>
              <div class="card-sub">先定通道数量与中心频率，再给每路选协议；频率可直接填 MHz —— 系统自动换算成 Hz 并写入 MMDVM-Multi.ini 与各通道配置</div>
            </div>
          </div>
          <span class="card-badge">统一频率架构</span>
        </div>

        <div class="freq-banner">
          <div class="freq-banner-ico">&#128225;</div>
          <div class="freq-banner-txt">
            <b>当前各通道实际频率</b>
            <span id="rfmBanner">读取中&hellip;</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="rfmPlanLoad()">&#8635; 重新读取</button>
        </div>

        <div class="form-grid" style="margin-top:12px">
          <div class="fld"><label>通道数量 (1 - 6)</label>
            <select id="rfmNCh" onchange="rfmNChChanged()"></select>
            <div class="hint">调小 = 停用多余通道（<b>配置会保留</b>）；以后调回大数字再保存即可恢复多通道</div></div>
          <div class="fld"><label>接收中心频率 (MHz)</label>
            <input id="rfmCrx" type="text" inputmode="decimal" placeholder="434.450" oninput="rfmCenterTyped()">
            <div class="hint" id="rfmCrxHz">例：434.450</div></div>
          <div class="fld"><label>发射中心频率 (MHz)</label>
            <input id="rfmCtx" type="text" inputmode="decimal" placeholder="439.450" oninput="rfmCenterTyped()">
            <div class="hint" id="rfmCtxHz">例：439.450</div></div>
          <div class="fld"><label>通道间距 (kHz)</label>
            <select id="rfmGap" onchange="rfmPlanFreqs()">
              <option value="25">25 kHz（标准，6 路以内用这个）</option>
              <option value="12.5">12.5 kHz（窄带）</option>
            </select>
            <div class="hint">SX1255 各通道按 25 kHz 网格排列，中心频率决定整体位置</div></div>
        </div>

        <div id="rfmRows" style="margin-top:12px"><div class="sp-empty">正在读取通道规划&hellip;</div></div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="rfmPlanApply(false)">&#9889; 保存并应用（写入配置 + 重启受影响通道）</button>
          <button class="btn btn-ghost btn-sm" onclick="rfmPlanApply(true)">仅保存规划</button>
          <span class="hint" id="rfmPlanMsg" style="margin-left:10px"></span>
        </div>
        <div class="hint" style="margin-top:9px;line-height:1.75">
          <b style="color:#fbbf24">频率输入习惯：</b>直接按 MHz 填 ——
          25 kHz 步进写 <b>439.500</b>（= 439500000 Hz），12.5 kHz 步进写 <b>439.5125</b>（= 439512500 Hz）。<br>
          改任意一路的频率时，会以「它」为准反推中心频率，其它通道按硬件网格跟着走；
          表格右侧显示的 MHz 就是设备真实收发频率，对讲机请照它设置。
        </div>
      </div>

      <!-- ===== ③ 各通道状态与切换 ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(56,189,248,.12);color:#38bdf8">&#128251;</div>
            <div>
              <div class="card-title">各通道状态与模式切换</div>
              <div class="card-sub">列出全部 1 - 7 路（含未启用），可随时切模式 / 重启；未启用的通道把「通道数量」调大后保存即可恢复</div>
            </div>
          </div>
          <span class="card-badge">MMDVM-Multi</span>
        </div>

        <div id="rfmChans"><div class="sp-empty">正在读取通道&hellip;</div></div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-ghost btn-sm" onclick="rfmLoad()">&#8635; 刷新状态</button>
          <button class="btn btn-ghost btn-sm" onclick="rfmRestartAll()">&#8634; 全部重启</button>
          <span class="hint" id="rfmMsg" style="margin-left:10px"></span>
        </div>
      </div>

      <!-- ===== ⑤ 引擎 / 服务 / 日志（诊断） ===== -->
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(148,163,184,.12);color:#94a3b8">&#128202;</div>
            <div>
              <div class="card-title">引擎 / 服务 / 日志（诊断）</div>
              <div class="card-sub">引擎实时参数、各服务运行状态、按通道 / 网关查看日志（诊断用）</div>
            </div>
          </div>
          <span class="card-badge" id="mcChBadge">--</span>
        </div>

        <div class="form-grid" id="mcEngBox"><div class="hint">正在读取引擎状态&hellip;</div></div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-ghost btn-sm" onclick="mcRefreshAll()">&#8635; 刷新状态</button>
          <button class="btn btn-ghost btn-sm" onclick="mcRestartEngine()">&#8634; 重启 Multi 引擎</button>
          <span class="hint" id="mcMsg" style="margin-left:10px"></span>
        </div>

        <div style="margin-top:14px">
          <div class="hint" style="margin-bottom:7px"><b style="color:#7dd3fc">服务状态</b> <span id="mcSvcBadge"></span></div>
          <div class="form-grid" id="mcSvcBox"><div class="hint">正在读取服务&hellip;</div></div>
        </div>

        <div class="form-grid" style="margin-top:14px">
          <div class="fld"><label>日志：通道</label><select id="mcLogCh"></select></div>
          <div class="fld"><label>服务</label>
            <select id="mcLogSvc">
              <option value="mmdvmhost">MMDVM-Host</option>
              <option value="mmdvm-iq">MMDVM-IQ</option>
              <option value="mmdvm-multi">MMDVM-Multi</option>
              <option value="dmrgateway">DMRGateway</option>
              <option value="ircddbgatewayd">ircDDBGateway (D-STAR)</option>
              <option value="ysfgateway">YSFGateway</option>
              <option value="p25gateway">P25Gateway</option>
              <option value="nxdngateway">NXDNGateway</option>
              <option value="tetra-bs">TETRA 集群基站</option>
              <option value="fmo-bridge">FMO Bridge（FM 联网桥）</option>
            </select></div>
          <div class="fld"><label>行数</label>
            <select id="mcLogLines">
              <option value="100">100 行</option>
              <option value="200" selected>200 行</option>
              <option value="500">500 行</option>
            </select></div>
        </div>
        <div class="mc-foot" style="margin:10px 0 0">
          <button class="btn btn-green btn-sm" onclick="mcLoadLog()">&#128203; 加载日志</button>
        </div>
        <pre id="mcLogBox" style="margin-top:10px;max-height:340px;overflow:auto">点击「加载日志」查看。</pre>
      </div>

    </section>
"""
```

### `opt/mmdvm-web/rfmode_js.py`

```python
# -*- coding: utf-8 -*-
"""射频模式配置页的前端（独立 <script> 块内容，与主脚本隔离）"""

RFMODE_SECTION_JS = r"""
var RFM_MODES = ['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];
var RFM_COL = {'DMR':'#0ea5e9','D-STAR':'#8b5cf6','YSF':'#f97316','NXDN':'#2563eb',
               'P25':'#e11d48','FM':'#10b981','POCSAG':'#f59e0b','NONE':'#64748b'};
function rfmQ(id){ return document.getElementById(id); }
function rfmMHz(hz){ hz = parseInt(hz,10)||0; return hz>0 ? (hz/1e6).toFixed(4) : '-'; }
function rfmSvg(m){
  var M=String(m||'').toUpperCase(), col=RFM_COL[M]||'#64748b', g='';
  if(M==='DMR') g='<rect x="9" y="15" width="22" height="4" rx="2" fill="#fff"/><rect x="9" y="23" width="14" height="4" rx="2" fill="#fff" opacity=".75"/>';
  else if(M==='D-STAR') g='<path d="M20 10l2.6 6.1 6.4.6-4.9 4.3 1.5 6.4L20 24l-5.6 3.4 1.5-6.4-4.9-4.3 6.4-.6z" fill="#fff"/>';
  else if(M==='YSF') g='<path d="M20 10l8 10-8 10-8-10z" fill="#fff"/>';
  else if(M==='NXDN') g='<path d="M13 27V13h4.2l5.6 8V13H27v14h-4.2l-5.6-8v8z" fill="#fff"/>';
  else if(M==='P25') g='<path d="M20 10l9 3.4v7.1c0 5-3.7 8.2-9 9.5-5.3-1.3-9-4.5-9-9.5v-7.1z" fill="#fff"/>';
  else if(M==='FM') g='<path d="M9 20c2.6-6 5.2-6 7.8 0s5.2 6 7.8 0 5.2-6 5.4 0" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>';
  else g='<circle cx="20" cy="20" r="6" fill="#fff" opacity=".7"/>';
  return '<svg viewBox="0 0 40 40" width="30" height="30" xmlns="http://www.w3.org/2000/svg" style="flex:0 0 30px"><rect x="2" y="2" width="36" height="36" rx="9" fill="'+col+'"/>'+g+'</svg>';
}
function rfmPost(url, body, cb){
  fetch(url, {method:'POST', credentials:'same-origin',
    headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})})
    .then(function(r){ return r.json(); })
    .then(function(d){ cb(d); })
    .catch(function(e){ if(typeof toast==='function') toast('失败：'+e,false); });
}
function rfmLoad(){
  fetch('/api/mc/channels', {credentials:'same-origin'}).then(function(r){ return r.json(); })
  .then(function(d){
    if(!d || d.ok===false) return;
    var chs=(d.channels||[]).filter(function(c){ return String(c.mode||'NONE').toUpperCase()!=='NONE'; });
    var b=rfmQ('rfmBanner');
    if(b) b.textContent = chs.map(function(c){
      return 'CH'+c.n+' '+c.mode+' '+rfmMHz(c.rx)+'/'+rfmMHz(c.tx);
    }).join('  ｜  ');
    var box=rfmQ('rfmChans');
    if(!box) return;
    var h='<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:11px">';
    chs.forEach(function(c,i){
      var run=!!(c.iq && c.host);
      var opts=RFM_MODES.map(function(m){
        return '<option value="'+m+'"'+(String(c.mode).toUpperCase()===m?' selected':'')+'>'+m+'</option>';
      }).join('');
      h += '<div class="fld" data-n="'+c.n+'" style="border:1px solid var(--border);border-radius:10px;padding:11px;background:var(--card2)">'
        + '<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">'
        + '<span style="width:8px;height:8px;border-radius:9px;flex:0 0 8px;background:'+(run?'var(--green2)':'var(--amber2)')+'"></span>'
        + '<b style="font-size:12.5px;color:#7dd3fc">通道 '+(i+1)+'</b>'
        + '<span style="margin-left:auto;display:flex;align-items:center;gap:7px;color:#fff;font-weight:800;font-size:13px">'
        + rfmSvg(c.mode) + String(c.mode) + '</span></div>'
        + '<div style="display:flex;gap:7px;align-items:center;margin-bottom:7px">'
        + '<span class="hint" style="flex:0 0 26px">RX</span>'
        + '<input class="rfm-rx" type="number" step="25000" value="'+(c.rx||0)+'" style="flex:1;min-width:0">'
        + '<span class="hint" style="flex:0 0 58px;text-align:right">'+rfmMHz(c.rx)+'</span></div>'
        + '<div style="display:flex;gap:7px;align-items:center;margin-bottom:9px">'
        + '<span class="hint" style="flex:0 0 26px">TX</span>'
        + '<input class="rfm-tx" type="number" step="25000" value="'+(c.tx||0)+'" style="flex:1;min-width:0">'
        + '<span class="hint" style="flex:0 0 58px;text-align:right">'+rfmMHz(c.tx)+'</span></div>'
        + '<div style="display:flex;gap:6px;align-items:center">'
        + '<select class="rfm-mode" style="flex:1;min-width:0">'+opts+'</select>'
        + '<button class="btn btn-green btn-sm" onclick="rfmSwitch('+c.n+')">切换</button>'
        + '<button class="btn btn-ghost btn-sm" onclick="rfmRestart('+c.n+')">重启</button>'
        + '</div></div>';
    });
    h += '</div>';
    box.innerHTML = h;
  }).catch(function(){});
}
function rfmSwitch(n){
  var card=document.querySelector('#rfmChans .fld[data-n="'+n+'"]');
  if(!card) return;
  var mode=card.querySelector('.rfm-mode').value;
  if(!confirm('把射频通道 '+n+' 切换为 '+mode+' ？将重启该通道。')) return;
  if(typeof toast==='function') toast('通道 '+n+' 正在切换…');
  rfmPost('/api/mc/switch',{ch:n,mode:mode},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?('通道 '+n+' 已切换为 '+mode):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(rfmLoad,1800);
  });
}
function rfmRestart(n){
  if(!confirm('重启射频通道 '+n+' 的基带与主机？')) return;
  rfmPost('/api/mc/restart',{ch:n},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(rfmLoad,1800);
  });
}
function rfmRestartAll(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。')) return;
  rfmPost('/api/mc/multi-restart',{},function(d){
    var ok=!!(d&&d.ok);
    if(typeof toast==='function') toast(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok) setTimeout(rfmLoad,5000);
  });
}
function rfmSaveFreq(){
  var cards=document.querySelectorAll('#rfmChans .fld[data-n]');
  var list=[], bad=null;
  Array.prototype.forEach.call(cards,function(cd){
    var n=parseInt(cd.getAttribute('data-n'),10);
    var rx=parseInt(cd.querySelector('.rfm-rx').value,10)||0;
    var tx=parseInt(cd.querySelector('.rfm-tx').value,10)||0;
    if(rx&&rx%25000!==0) bad='通道 '+n+' 接收频率非 25kHz 整数倍';
    if(tx&&tx%25000!==0) bad='通道 '+n+' 发射频率非 25kHz 整数倍';
    list.push({n:n,rx:rx,tx:tx});
  });
  if(bad){ if(typeof toast==='function') toast(bad,false); return; }
  var m=rfmQ('rfmMsg'); if(m) m.textContent='正在应用…';
  rfmPost('/api/mc/plan-apply',{channels:cards.length,list:list},function(res){
    var ok=!!(res&&res.ok!==false);
    var t=String((res&&(res.message||res.out||res.error))||'');
    if(m) m.textContent=(ok?'✅ ':'❌ ')+t.slice(0,100);
    if(typeof toast==='function') toast(ok?'频率已应用':'失败：'+t.slice(0,60),ok);
    if(ok) setTimeout(rfmLoad,2500);
  });
}
function rfmPowerChanged(){
  var sel=rfmQ('rfmPower'), tg=rfmQ('rfmTxGain');
  if(!sel||!tg) return;
  tg.value = (sel.value==='0') ? 10 : 30;
}
function rfmSavePower(){
  var p=(rfmQ('rfmPower')||{}).value, rx=(rfmQ('rfmRxGain')||{}).value, tx=(rfmQ('rfmTxGain')||{}).value;
  var m=rfmQ('rfmPwrMsg'); if(m) m.textContent='正在保存…';
  rfmPost('/api/save/rf',{power:p, rxgain:rx, txgain:tx},function(d){
    var ok=!!(d&&d.ok!==false);
    var t=String((d&&(d.message||d.out||d.error))||'');
    if(m) m.textContent=(ok?'✅ 已保存':'❌ ')+t.slice(0,90);
    if(typeof toast==='function') toast(ok?'功率设置已保存':'失败：'+t.slice(0,60),ok);
  });
}
document.addEventListener('change', function(e){
  if(e.target && e.target.id==='rfmPower') rfmPowerChanged();
});
(function(){ var s=rfmQ('rfmPower'); if(s) rfmPowerChanged(); })();
"""
```

### `opt/mmdvm-web/rfmode_page.py`

```python
# -*- coding: utf-8 -*-
"""射频模式配置（单页精简版）：
   · 6 个宫格，2 行 × 3 列
   · 每个宫格：图标+模式名 + RX/TX 可编辑频率 + 切模式/重启
   · 顶部：引擎状态 + 重新读取 / 全部重启 / 保存并应用频率
   · 已删除：标题、通道/SDR 徽标、硬件基础配置、运行日志 页签"""

RFMODE_HTML = r"""
<style>
:root{--mono:Consolas,"Cascadia Mono","DejaVu Sans Mono",monospace}
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#eaf2ff;font:13.5px/1.6 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
.bar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
.bar .spacer{flex:1}
.badge{font-size:11px;padding:4px 12px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4;font-weight:600}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.btn{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:7px 14px;
  border-radius:8px;font-size:12.5px;font-weight:650;font-family:inherit;transition:.15s;white-space:nowrap}
.btn:hover{border-color:#3b5686;color:#fff}
.btn:active{transform:translateY(1px)}
.btn.g{background:#0e2f22;border-color:#1d6b4a;color:#a7f3d0}
.btn.sm{padding:5px 10px;font-size:11.5px}
.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}
@media(max-width:980px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:620px){.grid{grid-template-columns:1fr}}
.ch{background:linear-gradient(180deg,#111d33,#0c1526);border:1px solid #1e2b44;border-radius:12px;
  padding:14px;transition:.16s}
.ch:hover{border-color:#2a3d5c}
.ch.on{border-color:rgba(16,185,129,.42)}
.ch .hd{display:flex;align-items:center;gap:9px;margin-bottom:11px}
.ch .dot{width:8px;height:8px;border-radius:99px;flex:0 0 8px}
.ch .n{font-size:13px;font-weight:800;color:#7dd3fc;font-family:var(--mono)}
.ch .mode{margin-left:auto;display:flex;align-items:center;gap:8px;font-weight:800;font-size:14px;
  color:#fff;font-family:var(--mono)}
.ch .mode svg{width:34px;height:34px;flex:0 0 34px}
.frow{display:flex;align-items:center;gap:8px;margin-top:7px}
.frow label{flex:0 0 30px;font-size:10px;color:#6f86a6;font-weight:700;letter-spacing:.5px}
.frow input{flex:1;min-width:0;background:#0a1322;border:1px solid #2a3a56;color:#dfe8f5;
  padding:6px 9px;border-radius:8px;font-size:12.5px;font-family:var(--mono)}
.frow input:focus{outline:none;border-color:#22d3ee}
.frow .mhz{flex:0 0 74px;font-size:11px;color:#8fa6c4;font-family:var(--mono);text-align:right}
.ops{display:flex;gap:7px;align-items:center;margin-top:11px}
.ops select{flex:1;min-width:0;background:#0a1322;border:1px solid #2a3a56;color:#dfe8f5;
  padding:6px 9px;border-radius:8px;font-size:12.5px;font-family:inherit}
.hint{font-size:11px;color:#6f86a6}
</style>

<div>
  <div class="bar">
    <span class="badge" id="bEng">引擎 —</span>
    <span class="spacer"></span>
    <button class="btn" onclick="load()">⟳ 重新读取</button>
    <button class="btn" onclick="restartAll()">⟲ 全部重启</button>
    <button class="btn g" onclick="saveFreq()">⚡ 保存并应用频率</button>
  </div>

  <div class="grid" id="grid"><div class="hint">正在读取通道…</div></div>

  <div class="bar" style="margin-top:14px;border-top:1px solid #1e2b44;padding-top:12px">
    <span class="badge">🔧 全局射频载波与硬件基础配置 (RF &amp; Station)</span>
    <span class="spacer"></span>
    <button class="btn" onclick="openHw()">打开</button>
    <button class="btn" onclick="embedHw()">本页内嵌</button>
  </div>
  <div id="hwBox" style="margin-top:10px"></div>

  <div class="hint" id="msg" style="margin-top:10px"></div>
</div>
"""

RFMODE_JS = r"""
var $=function(id){return document.getElementById(id);};
var MODES=['DMR','D-STAR','YSF','P25','NXDN','FM','POCSAG','NONE'];
var DATA=null;
function icon(m){
  var m2=String(m||'').toUpperCase();
  var col={DMR:'#0ea5e9','D-STAR':'#8b5cf6',YSF:'#f97316',NXDN:'#2563eb',P25:'#e11d48',FM:'#10b981',POCSAG:'#f59e0b'}[m2]||'#64748b';
  var g='';
  if(m2==='DMR')g='<rect x="9" y="15" width="22" height="4" rx="2" fill="#fff"/><rect x="9" y="23" width="14" height="4" rx="2" fill="#fff" opacity=".75"/>';
  else if(m2==='D-STAR')g='<path d="M20 10l2.6 6.1 6.4.6-4.9 4.3 1.5 6.4L20 24l-5.6 3.4 1.5-6.4-4.9-4.3 6.4-.6z" fill="#fff"/>';
  else if(m2==='YSF')g='<path d="M20 10l8 10-8 10-8-10z" fill="#fff"/>';
  else if(m2==='NXDN')g='<path d="M13 27V13h4.2l5.6 8V13H27v14h-4.2l-5.6-8v8z" fill="#fff"/>';
  else if(m2==='P25')g='<path d="M20 10l9 3.4v7.1c0 5-3.7 8.2-9 9.5-5.3-1.3-9-4.5-9-9.5v-7.1z" fill="#fff"/>';
  else if(m2==='FM')g='<path d="M9 20c2.6-6 5.2-6 7.8 0s5.2 6 7.8 0 5.2-6 5.4 0" stroke="#fff" stroke-width="3" fill="none" stroke-linecap="round"/>';
  else if(m2==='POCSAG')g='<rect x="13" y="11" width="14" height="18" rx="3" fill="#fff"/><rect x="16" y="14" width="8" height="6" rx="1" fill="'+col+'"/>';
  else g='<circle cx="20" cy="20" r="6" fill="#fff" opacity=".7"/>';
  return '<svg viewBox="0 0 40 40" width="34" height="34" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="2" width="36" height="36" rx="9" fill="'+col+'"/>'+g+'</svg>';
}
function mhz(hz){hz=parseInt(hz,10)||0;return hz>0?(hz/1e6).toFixed(4):'—';}
function esc(s){return String(s==null?'':s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function toast(msg,ok){
  var t=$('toast');
  if(!t){t=document.createElement('div');t.id='toast';document.body.appendChild(t);}
  t.textContent=String(msg==null?'':msg);
  t.style.cssText='position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);z-index:99999;'
    +'background:rgba(13,21,32,.97);border:1px solid '+(ok===false?'rgba(239,68,68,.6)':'#2b3f57')+';'
    +'color:#eaf2ff;padding:18px 32px;border-radius:13px;text-align:center;max-width:min(560px,86vw);'
    +'font-size:15px;font-weight:600;line-height:1.6;display:block;opacity:0;transition:opacity .3s ease';
  requestAnimationFrame(function(){t.style.opacity='1';});
  clearTimeout(window.__t1);clearTimeout(window.__t2);
  window.__t1=setTimeout(function(){t.style.opacity='0';},2000);
  window.__t2=setTimeout(function(){t.style.display='none';},2400);
}
function api(path,opts){
  var o=opts||{};
  return fetch(path,{credentials:'same-origin',method:o.method||'GET',
    headers:o.body?{'Content-Type':'application/json'}:undefined,
    body:o.body?JSON.stringify(o.body):undefined}).then(function(r){return r.json();});
}
function load(){
  api('/api/mc/channels').then(function(d){
    if(!d||d.ok===false)return;
    DATA=d;
    var eng=d.multi||{};
    var b=$('bEng');if(b){b.textContent='引擎 '+(eng.active?'运行中':'停止');b.className='badge '+(eng.active?'ok':'bad');}
    render(d);
  }).catch(function(e){toast('读取失败：'+e,false);});
}
function render(d){
  var chs=(d.channels||[]).filter(function(c){return String(c.mode||'NONE').toUpperCase()!=='NONE';});
  $('grid').innerHTML = chs.map(function(c,i){
    var run=!!(c.iq&&c.host);
    var opts=MODES.map(function(m){return '<option value="'+m+'"'+(String(c.mode).toUpperCase()===m?' selected':'')+'>'+m+'</option>';}).join('');
    return '<div class="ch '+(run?'on':'')+'" data-n="'+c.n+'">'
      +'<div class="hd"><span class="dot" style="background:'+(run?'#10b981':'#fbbf24')+'"></span>'
      +'<span class="n">通道 '+(i+1)+'</span>'
      +'<span class="mode">'+icon(c.mode)+esc(c.mode)+'</span></div>'
      +'<div class="frow"><label>RX</label>'
      +'<input type="number" step="25000" class="frx" value="'+(c.rx||0)+'">'
      +'<span class="mhz">'+mhz(c.rx)+' MHz</span></div>'
      +'<div class="frow"><label>TX</label>'
      +'<input type="number" step="25000" class="ftx" value="'+(c.tx||0)+'">'
      +'<span class="mhz">'+mhz(c.tx)+' MHz</span></div>'
      +'<div class="ops"><select class="fmode">'+opts+'</select>'
      +'<button class="btn sm g" onclick="doSwitch('+c.n+')">切换</button>'
      +'<button class="btn sm" onclick="doRestart('+c.n+')">重启</button></div>'
      +'</div>';
  }).join('') || '<div class="hint">无启用通道</div>';
}
function doSwitch(n){
  var card=document.querySelector('.ch[data-n="'+n+'"]');
  if(!card)return;
  var mode=card.querySelector('.fmode').value;
  if(!confirm('把射频通道 '+n+' 切换为 '+mode+' ？将重启该通道。'))return;
  toast('通道 '+n+' 正在切换…');
  api('/api/mc/switch',{method:'POST',body:{ch:n,mode:mode}}).then(function(d){
    var ok=!!(d&&d.ok);
    toast(ok?('通道 '+n+' 已切换为 '+mode):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok)setTimeout(load,1800);
  }).catch(function(e){toast('失败：'+e,false);});
}
function doRestart(n){
  if(!confirm('重启射频通道 '+n+' 的基带与主机？'))return;
  toast('通道 '+n+' 正在重启…');
  api('/api/mc/restart',{method:'POST',body:{ch:n}}).then(function(d){
    var ok=!!(d&&d.ok);
    toast(ok?('通道 '+n+' 已重启'):('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok)setTimeout(load,1800);
  }).catch(function(e){toast('失败：'+e,false);});
}
function restartAll(){
  if(!confirm('重启 MMDVM-Multi 引擎与全部通道？约 20~40 秒。'))return;
  toast('正在重启全部通道…');
  api('/api/mc/multi-restart',{method:'POST',body:{}}).then(function(d){
    var ok=!!(d&&d.ok);
    toast(ok?'引擎与全部通道已重启':('失败：'+String((d&&(d.error||d.out))||'')),ok);
    if(ok)setTimeout(load,5000);
  }).catch(function(e){toast('失败：'+e,false);});
}
function saveFreq(){
  var msg=$('msg');
  var cards=document.querySelectorAll('#grid .ch');
  var list=[]; var bad=null;
  cards.forEach(function(card){
    var n=parseInt(card.getAttribute('data-n'),10);
    var rx=parseInt(card.querySelector('.frx').value,10)||0;
    var tx=parseInt(card.querySelector('.ftx').value,10)||0;
    if(rx&&rx%25000!==0)bad='通道 '+n+' 接收频率非 25kHz 整数倍';
    if(tx&&tx%25000!==0)bad='通道 '+n+' 发射频率非 25kHz 整数倍';
    list.push({n:n,rx:rx,tx:tx});
  });
  if(bad){toast(bad,false);return;}
  if(msg)msg.textContent='正在应用（约 30~60 秒）…';
  api('/api/mc/plan-apply',{method:'POST',body:{channels:cards.length,list:list}}).then(function(res){
    var ok=!!(res&&res.ok!==false);
    var t=String((res&&(res.message||res.out||res.error))||'');
    if(msg)msg.textContent=(ok?'✅ ':'❌ ')+t.slice(0,120);
    toast(ok?'频率已应用':'失败：'+t.slice(0,60),ok);
    if(ok)setTimeout(load,2500);
  }).catch(function(e){if(msg)msg.textContent='网络错误：'+e;});
}
document.addEventListener('DOMContentLoaded', load);
if(document.readyState!=='loading')load();
function openHw(){ location.href='/multi'; }
function embedHw(){
  var box=$('hwBox');if(!box)return;
  box.innerHTML='<div class="hint">嵌入式页面已停用。请<button class="sm primary" onclick="openHw()">在新窗口打开多通道控制台</button></div>';
}
"""
```

### `opt/mmdvm-web/rfmode_section.py`

```python
# -*- coding: utf-8 -*-
"""射频模式配置（内联 SPA 页）—— 完全复用主控制台的组件类，外观与 DMR 页一致
   ① 射频通道：6 个通道的模式与频率
   ② 功率调整（RF & Station）：发射功率档位 + 基带收发增益  ← 用户要保留的模块"""

RFMODE_SECTION = r"""
    <section class="page proto-page" id="page-rfmode" style="display:none">

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--green-dim);color:var(--green2)">&#128251;</div>
            <div>
              <div class="card-title">射频模式配置</div>
              <div class="card-sub">6 个射频通道的模式与收发频率（与其它模式页共用同一套频率架构）</div>
            </div>
          </div>
          <span class="card-badge">MMDVM-Multi</span>
        </div>

        <div class="freq-banner">
          <div class="freq-banner-ico">&#128225;</div>
          <div class="freq-banner-txt">
            <b>当前各通道频率 <span style="color:var(--green2);font-size:10.5px;margin-left:6px">统一频率架构</span></b>
            <span id="rfmBanner">读取中…</span>
          </div>
          <button class="btn btn-ghost btn-sm" onclick="rfmLoad()">&#8635; 重新读取</button>
        </div>

        <div id="rfmChans"><div class="sp-empty">正在读取通道…</div></div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="rfmSaveFreq()">&#9889; 保存并应用频率</button>
          <button class="btn btn-ghost btn-sm" onclick="rfmRestartAll()">&#8634; 全部重启</button>
          <span class="hint" id="rfmMsg" style="margin-left:10px"></span>
        </div>
      </div>

      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:var(--amber-dim);color:var(--amber2)">&#128295;</div>
            <div>
              <div class="card-title">功率调整（RF &amp; Station）</div>
              <div class="card-sub">SX1255 发射功率档位与硬件基带收发增益</div>
            </div>
          </div>
          <span class="card-badge">SX1255</span>
        </div>

        <div class="form-grid">
          <div class="fld"><label>射频发射功率档位 (SX1255)</label>
            <select id="rfmPower">
              <option value="7">大功率 &#8212; 7 级 / +7 dBm / 5 mW（远距离、抗干扰）</option>
              <option value="0">小功率 &#8212; 0 级 / 0 dBm / 1 mW（近距离、更健康）</option>
            </select>
            <div class="hint">切换档位会自动把 TxGain 设为 30dB（大功率）或 10dB（小功率）</div>
          </div>
          <div class="fld"><label>SX1255 硬件基带接收增益 (RxGain 0-60 dB)</label>
            <input id="rfmRxGain" type="number" min="0" max="60" value="0">
            <div class="hint">写入 MMDVM-IQ.ini [Soapy]</div>
          </div>
          <div class="fld"><label>SX1255 硬件基带发射增益 (TxGain 0-40 dB)</label>
            <input id="rfmTxGain" type="number" min="0" max="40" value="30">
            <div class="hint">大功率推荐 30dB / 小功率推荐 10dB</div>
          </div>
        </div>

        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="rfmSavePower()">&#128190; 保存功率设置</button>
          <span class="hint" id="rfmPwrMsg" style="margin-left:10px"></span>
        </div>
      </div>

    </section>
"""
```

### `opt/mmdvm-web/rfmode_standalone.py`

```python
# -*- coding: utf-8 -*-
"""把 rfmode_page 的片段组装成【独立页面】（像 /tetra 那样，自带样式与脚本）
   —— 独立页面的脚本即使出错，也完全不影响主控制台 ✓"""
import importlib.util
import os

_spec = importlib.util.spec_from_file_location(
    "rfmode_frag", "/opt/mmdvm-web/rfmode_page.py")
_mod = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(_mod)

_CSS = ""
_HTML = _mod.RFMODE_HTML
# 片段里第一段是 <style>…</style>，抽出来单独放 <head>
import re as _re
_m = _re.search(r"<style>(.*?)</style>", _HTML, _re.S)
if _m:
    _CSS = _m.group(1)
    _HTML = _HTML[:_m.start()] + _HTML[_m.end():]

RFMODE_STANDALONE = (
    "<!DOCTYPE html>\n"
    "<html lang=\"zh-CN\">\n<head>\n"
    "<meta charset=\"utf-8\">\n"
    "<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
    "<title>射频模式配置 · MMDVM-Multi</title>\n"
    "<style>\n"
    "*{box-sizing:border-box;margin:0;padding:0}\n"
    "body{background:#0b1220;color:#dfe8f5;font:14.5px/1.6 \"Segoe UI\",\"Microsoft YaHei\",sans-serif;padding:14px}\n"
    "body.embed{padding:6px 10px 10px}\n"
    "body.embed .rfm-hd h1{display:none}\n"
    ":root{--mono:Consolas,\"Cascadia Mono\",\"DejaVu Sans Mono\",monospace}\n"
    + _CSS +
    "\n</style>\n</head>\n<body>\n"
    + _HTML +
    "\n<script>\n"
    # 独立页面里没有主控制台的 toast()，这里给一个最简版（用主主题的居中弹窗样式）
    "function toast(msg, kind){\n"
    "  var t = document.getElementById('toast');\n"
    "  if(!t){ t = document.createElement('div'); t.id='toast'; document.body.appendChild(t); }\n"
    "  t.textContent = String(msg == null ? '' : msg);\n"
    "  t.style.cssText = 'position:fixed;left:50%;top:50%;transform:translate(-50%,-50%) scale(.96);"
    "z-index:99999;background:rgba(13,21,32,.97);border:1px solid #2b3f57;color:#eaf2ff;"
    "padding:20px 34px;border-radius:14px;text-align:center;max-width:min(560px,86vw);"
    "font-size:16px;font-weight:600;line-height:1.6;display:block;opacity:0;pointer-events:none;"
    "transition:opacity .3s ease,transform .3s ease';\n"
    "  requestAnimationFrame(function(){ t.style.opacity='1'; t.style.transform='translate(-50%,-50%) scale(1)'; });\n"
    "  clearTimeout(window.__rtt1); clearTimeout(window.__rtt2);\n"
    "  window.__rtt1 = setTimeout(function(){ t.style.opacity='0'; t.style.transform='translate(-50%,-50%) scale(.96)'; }, 2000);\n"
    "  window.__rtt2 = setTimeout(function(){ t.style.display='none'; }, 2400);\n"
    "}\n"
    + _mod.RFMODE_JS +
    "\n// 独立页面启动\ndocument.addEventListener('DOMContentLoaded', function(){ load(); });\n"
    "if(document.readyState !== 'loading'){ load(); }\n"
    "</script>\n</body>\n</html>\n"
)

if __name__ == "__main__":
    open("/tmp/_rfmode_chk.html", "w", encoding="utf-8").write(RFMODE_STANDALONE)
    print("CSS %d 字节 / HTML片段 %d / JS %d / 整页 %d 字节" % (
        len(_CSS), len(_HTML), len(_mod.RFMODE_JS), len(RFMODE_STANDALONE)))
    print("关键元素: " + ", ".join(
        "%s=%d" % (k, RFMODE_STANDALONE.count(k))
        for k in ("rfmLoad", "rfmSwitch", "rfmLoadFreq", "rfmSaveFreq", "rfmLoadLog",
                  "<style>", "</style>", "<script>", "</script>", "<body>", "</body>")))
```

### `opt/mmdvm-web/tetra_adv_backend.py`

```python
# -*- coding: utf-8 -*-
"""注入到 /usr/local/libexec/tetra-api.py 的片段：
   · _sec_setk  段感知的 TOML 写入（原来的 setk 会误改同名键，如 [health].enabled）
   · _toml_val  数字/布尔/字符串/数组 的 TOML 字面量
   · _post_toml 把前端传来的高级参数写进 config.toml（含 [cell_info.home_mode_display] 子表）
   · 包装 write_toml / load_ui，并在 cmd_save 里合并 body["adv"]
"""

TETRA_BACKEND_PATCH = r'''

# ==================== TETRA_ADV 高级参数（由 Web spec 生成，2026-09-13 补）====================
# 字段: "段.键" -> 默认值（与 Web 表单同源）
ADV_DEFAULTS = __ADV_DEF__
# 这些键按 TOML 数组写
ADV_ARRAY = __ADV_ARRAY__
# 这个键属于子表（追加块写法）
ADV_SUBTABLE = {"cell_info.home_mode_display.text": ("cell_info.home_mode_display", "text")}


def _toml_val(v):
    """把 Python/字符串值转成 TOML 字面量"""
    if isinstance(v, bool):
        return "true" if v else "false"
    if isinstance(v, (int, float)):
        return str(v)
    s = str(v).strip()
    low = s.lower()
    if low in ("true", "false"):
        return low
    if s.startswith("[") and s.endswith("]"):
        return s                                  # 已经是数组字面量
    if s == "":
        return '""'
    if re.fullmatch(r"-?\d+", s):
        return s
    if re.fullmatch(r"-?\d+\.\d+", s):
        return s
    return '"%s"' % s.replace('"', '\\"')


def _sec_setk(text, section, key, val):
    """只在 [section] 段内设置 key=val（段不存在则追加该段）"""
    lines = text.split("\n")
    out, cur, done, seen = [], None, False, False
    for ln in lines:
        m = re.match(r"^\s*\[(.+?)\]\s*$", ln)
        if m:
            if cur == section and not done:
                out.append("%s = %s" % (key, val)); done = True
            cur = m.group(1)
            if cur == section:
                seen = True
            out.append(ln)
            continue
        if cur == section and re.match(r"^\s*%s\s*=" % re.escape(key), ln):
            out.append("%s = %s" % (key, val)); done = True
            continue
        out.append(ln)
    if cur == section and not done:
        out.append("%s = %s" % (key, val)); done = True
    if not done:
        if not seen:
            out += ["", "[%s]" % section]
        out.append("%s = %s" % (key, val))
    return "\n".join(out)


def _apply_adv_toml(s, adv):
    """把高级参数写进 toml 文本"""
    adv = adv or {}
    for full, dv in ADV_DEFAULTS.items():
        if full not in adv:
            continue
        v = adv[full]
        if v is None or str(v).strip() == "":
            continue                                  # 留空 = 不改这一项
        if full in ADV_SUBTABLE:
            sec, key = ADV_SUBTABLE[full]
            s = _sec_setk(s, sec, key, _toml_val(v))
            continue
        sec, key = full.split(".", 1)
        if key in ADV_ARRAY:
            try:
                arr = v if isinstance(v, (list, tuple)) else json.loads(v)
                flat = []
                for it in arr:
                    if isinstance(it, (list, tuple)):
                        flat.append("[%s]" % ", ".join(str(int(x)) for x in it))
                    else:
                        flat.append(str(int(it)))
                val = "[%s]" % ", ".join(flat)
            except Exception:
                continue
        else:
            val = _toml_val(v)
        s = _sec_setk(s, sec, key, val)
    return s


def _post_toml(adv):
    """write_toml 之后：读回文件、写入高级参数、再写回"""
    try:
        if not os.path.exists(TOML):
            return
        txt = open(TOML, encoding="utf-8").read()
        new = _apply_adv_toml(txt, adv)
        if new != txt:
            open(TOML, "w", encoding="utf-8").write(new)
    except Exception:
        pass


# --- 包装 write_toml：保存时顺带写高级参数 ---
if "write_toml" in globals() and not globals().get("_ADV_WRAPPED"):
    _ADV_WRAPPED = True
    _orig_write_toml = write_toml

    def write_toml(d):
        r = _orig_write_toml(d)
        try:
            _post_toml((d or {}).get("adv"))
        except Exception:
            pass
        return r

# --- 包装 load_ui：补上 adv 键与默认值（供页面填写） ---
if "load_ui" in globals() and not globals().get("_ADV_LOADWRAP"):
    _ADV_LOADWRAP = True
    _orig_load_ui = load_ui

    def load_ui():
        d = _orig_load_ui()
        if isinstance(d, dict):
            a = d.setdefault("adv", {})
            if not isinstance(a, dict):
                a = d["adv"] = {}
            for k, v in ADV_DEFAULTS.items():
                a.setdefault(k, v)
        return d
'''

TETRA_BACKEND_CMD_SAVE_HOOK = '''    if isinstance(body.get("adv"), dict):
        d.setdefault("adv", {})
        if isinstance(d["adv"], dict):
            d["adv"].update(body["adv"])
'''
```

### `opt/mmdvm-web/tetra_adv_spec.py`

```python
# -*- coding: utf-8 -*-
"""TETRA 高级参数 spec（来自桌面《Tetra配置文件.txt》官方文档）
   一张表同时生成：① 页面表单 ② 前端 JS ③ 后端 load_ui/cmd_save/write_toml
   字段格式: (段, 键, 控件 id, 标签, 类型, 默认值)
   类型: num=数字 chk=开关 text=文本 sel=下拉(O|opt1|opt2...) area=多行文本"""

TETRA_ADV = [
    # ---- 小区 / 网络（cell_info）----
    ("cell_info", "timezone", "tz", "时区（用于时间广播 D-NWRK-BROADCAST）", "text", "Asia/Shanghai"),
    ("cell_info", "freq_offset", "foff", "载波频偏 (Hz)", "sel", "0|0|6250|-6250|12500"),
    ("cell_info", "reverse_operation", "revop", "收发反置（上行高于下行）", "chk", "false"),
    ("cell_info", "location_area", "la", "位置区 Location Area", "num", "2"),
    ("cell_info", "colour_code", "ccode", "色码 Colour Code (0-63)", "num", "1"),
    ("cell_info", "hangtime_secs", "hang", "组呼保持时长 (秒 0-300)", "num", "5"),
    ("cell_info", "call_timeout_secs", "ctmo", "最大通话时长 (秒, 0=不限)", "num", "600"),
    ("cell_info", "ul_inactivity_secs", "ulino", "上行不活动超时 (秒 1-30)", "num", "3"),
    ("cell_info", "periodic_registration_secs", "perreg", "周期注册间隔 T351 (秒, 0=禁用)", "num", "0"),
    ("cell_info", "voice_service", "voice", "小区提供语音业务", "chk", "true"),
    ("cell_info", "system_wide_services", "syswide", "系统级业务 (SYSINFO 广播)", "chk", "true"),
    ("cell_info", "release_group_on_same_speaker_retake", "relgrp",
     "抢权时重建组呼（老式摩托罗拉兼容）", "chk", "true"),
    ("cell_info", "local_ssi_ranges", "lssi", "本小区 SSI 范围（每行 起,止；留空=不限制）", "area", "0,90"),
    # ---- 安全 / 白名单（security）----
    ("security", "issi_whitelist", "wlist", "ISSI 白名单（每行一个；留空=开放注册）", "area", ""),
    ("security", "whitelist_mode", "wmode", "白名单模式", "sel", "open|open|enforce"),
    # ---- 重启恢复（recovery）----
    ("recovery", "enabled", "recen", "重启后主动恢复终端（缓存重放）", "chk", "false"),
    ("recovery", "reactive_enabled", "recrx", "发现未知电台时命令其重新注册", "chk", "true"),
    # ---- SDR 增益 / 天线（phy_io.soapysdr，SX1255 实测单元）----
    ("phy_io.soapysdr", "rx_gain_lna", "glna", "RX 增益 LNA (dB, SX1255)", "num", "42"),
    ("phy_io.soapysdr", "rx_gain_pga", "gpga", "RX 增益 PGA (dB, SX1255)", "num", "16"),
    ("phy_io.soapysdr", "tx_gain_dac", "gdac", "TX 增益 DAC (dB, SX1255)", "num", "9"),
    ("phy_io.soapysdr", "tx_gain_mixer", "gmix", "TX 增益 MIXER (dB, SX1255)", "num", "30"),
    ("phy_io.soapysdr", "rx_antenna", "rant", "RX 天线（默认 RX）", "text", ""),
    ("phy_io.soapysdr", "tx_antenna", "tant", "TX 天线（默认 TX）", "text", ""),
    # ---- 电台待机屏文本（子表 cell_info.home_mode_display.text）----
    ("cell_info.home_mode_display", "text", "hometxt",
     "电台待机屏显示文本（主模式显示 PID 220）", "text", ""),
]

# 需要按数组写入 TOML 的多行字段
ARRAY_KEYS = {"local_ssi_ranges", "issi_whitelist"}


def adv_default(typ, dv):
    """取默认值：sel 型 dv='O|opt1|opt2...'，默认 = 第 1 个选项"""
    if typ == "sel":
        parts = dv.split("|")
        return parts[1] if len(parts) > 1 else (parts[0] if parts else "")
    return dv


def sel_options(dv):
    parts = dv.split("|")
    return parts[1:] if len(parts) > 1 else parts


def adv_js():
    """生成前端 JS（独立 <script> 块）：填写/收集高级参数 + 修页面闪动"""
    tbl = ",\n  ".join('["%s","%s","%s","%s"]' % (s, k, u, t) for s, k, u, lab, t, d in TETRA_ADV)
    return r"""
/* ==================== TETRA 高级参数（TETRA_ADV）====================
   · 字段表与后端读写由同一份 spec 生成
   · 同时修复「TETRA 集群配置内容闪动」：数据没变化时不再重写表单 */
var TADV = [
  %s
];
function tA(id){ return document.getElementById(id); }
function tAdvMsg(t, ok){
  var e=tA('tAdvMsg'); if(e) e.textContent=t||'';
  if(t && typeof toast==='function') toast(t, ok);
}
/* 后端 -> 表单 */
function tAdvFill(cfg){
  var adv = (cfg && cfg.adv) || {};
  for(var i=0;i<TADV.length;i++){
    var sec=TADV[i][0], key=TADV[i][1], id=TADV[i][2], typ=TADV[i][3];
    var el=tA(id); if(!el) continue;
    var v=adv[sec+"."+key];
    if(v===undefined || v===null) continue;
    if(typ==='chk') el.value = (v===true || String(v).toLowerCase()==='true') ? 'true' : 'false';
    else el.value = String(v);
  }
}
/* 表单 -> 后端 */
function tAdvCollect(){
  var out={}, bad=null;
  for(var i=0;i<TADV.length;i++){
    var sec=TADV[i][0], key=TADV[i][1], id=TADV[i][2], typ=TADV[i][3];
    var el=tA(id); if(!el) continue;
    var v=String(el.value||'').trim();
    if(typ==='area'){
      var rows=[];
      v.split(/\r?\n/).forEach(function(ln){
        ln=ln.trim(); if(!ln) return;
        rows.push(ln);
      });
      if(sec==='cell_info' && key==='local_ssi_ranges' && rows.length){
        rows=rows.map(function(r){ return r.split(/[,，]/).map(function(x){ return parseInt(x,10)||0; }); });
        v=JSON.stringify(rows);
      } else if(rows.length){
        v=JSON.stringify(rows.map(function(r){ return parseInt(r,10)||0; }));
      } else { v=''; }
    } else if(typ==='num'){
      if(v===''){ v=''; } else { v=String(parseInt(v,10)); }
    }
    out[sec+"."+key]=v;
  }
  return {adv: out};
}
/* 包装原有 fill / collect / refresh（不改动原脚本，防闪动） */
(function(){
  if(typeof fill === 'function'){
    var _f = fill;
    window.fill = function(cfg, st){
      var sig='';
      try { sig = JSON.stringify(cfg); } catch(e){}
      if(sig && sig === window.__tetraSig){          /* 数据没变：只刷时间戳，不重写表单 */
        var b=tA('bTime'); if(b) b.textContent=new Date().toLocaleTimeString();
        return;
      }
      window.__tetraSig = sig;
      _f(cfg, st);
      try { tAdvFill(cfg); } catch(e){}
    };
  }
  if(typeof collect === 'function'){
    var _c = collect;
    window.collect = function(){
      var o=_c();
      try { var a=tAdvCollect(); for(var k in a.adv) o[k]=a.adv[k]; o.adv=a.adv; } catch(e){}
      return o;
    };
  }
  /* 重新装一个更温和的定时刷新：不在本页 / 正在输入时不刷新 */
  try { var _i=0; while(_i++ < 100000){ clearInterval(_i); } } catch(e){}
  setInterval(function(){
    var pg=tA('page-tetra');
    if(!pg || pg.style.display === 'none') return;
    var ae=document.activeElement;
    if(ae && pg.contains(ae) && /INPUT|SELECT|TEXTAREA/.test(ae.tagName||'')) return;
    if(typeof refresh === 'function') refresh();
  }, 30000);
})();
""" % tbl


def adv_card_html():
    """生成卡片 HTML（复用主控制台组件类）"""
    rows = []
    for sec, key, uid, label, typ, dv in TETRA_ADV:
        if typ == "chk":
            ctl = '<select id="%s"><option value="true">开启</option><option value="false">关闭</option></select>' % uid
        elif typ == "sel":
            ctl = ('<select id="%s">%s</select>'
                   % (uid, "".join('<option value="%s">%s</option>' % (o, o) for o in sel_options(dv))))
        elif typ == "area":
            ctl = ('<textarea id="%s" rows="3" style="width:100%%;font-family:Consolas,monospace;'
                   'font-size:12px" placeholder="每行一个"></textarea>' % uid)
        else:
            ctl = '<input id="%s" type="text">' % uid
        hint = "%s.%s" % (sec, key)
        rows.append('          <div class="fld"><label>%s</label>%s<div class="hint">%s</div></div>'
                    % (label, ctl, hint))
    return """
      <div class="card">
        <div class="card-head">
          <div class="card-head-left">
            <div class="h-ico" style="background:rgba(56,189,248,.12);color:#38bdf8">&#9881;</div>
            <div>
              <div class="card-title">TETRA 高级参数（按官方配置文档补充）</div>
              <div class="card-sub">来自 FlowStation 官方 config.toml 文档：小区业务/时隙参数、ISSI 白名单、
                重启恢复、SX1255 各级增益与天线、电台待机屏文本。保存后写入 config.toml 并重启基站生效</div>
            </div>
          </div>
          <span class="card-badge">config.toml</span>
        </div>
        <div class="form-grid">
%s
        </div>
        <div class="mc-foot" style="margin:12px 0 0">
          <button class="btn btn-green btn-sm" onclick="saveCfg()">&#128190; 保存并写入 config.toml</button>
          <button class="btn btn-ghost btn-sm" onclick="loadCfg()">&#8635; 重新读取</button>
          <span class="hint" id="tAdvMsg" style="margin-left:10px"></span>
        </div>
        <div class="hint" style="margin-top:8px">
          说明：<b>SSI 范围 / 白名单</b>每行一条 —— 范围写 <code>0,90</code>，白名单写单个数字（如
          <code>2260571</code>）。留空即取消该限制（白名单为空 = 任何电台都能注册，即开放网络）。
        </div>
      </div>
""" % "\n".join(rows)
```

### `opt/mmdvm-web/tetra_page.py`

```python
# -*- coding: utf-8 -*-
"""TETRA 集群配置页（内嵌到主控制台 /tetra，通过 iframe?embed=1 加载）"""

TETRA_HTML = r'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>TETRA 集群配置</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#0b1220;color:#dfe8f5;font:14px/1.55 "Segoe UI","Microsoft YaHei",sans-serif;padding:14px}
body.embed{padding:6px 10px 10px}
body.embed .top{display:none}
.top{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:14px}
.top h1{font-size:18px;font-weight:650}
.badge{font-size:12px;padding:3px 9px;border-radius:99px;background:#17233a;border:1px solid #26354f;color:#8fa6c4}
.badge.ok{background:#0e2f22;border-color:#1d6b4a;color:#4ade80}
.badge.bad{background:#331616;border-color:#7f2222;color:#f87171}
.spacer{flex:1}
button{cursor:pointer;border:1px solid #2a3a56;background:#16264a;color:#cfe0f5;padding:6px 12px;border-radius:7px;font-size:13px;font-family:inherit}
button:hover{background:#1d3260}
button.sm{padding:4px 9px;font-size:12px}
button.primary{background:#14532d;border-color:#1d6b4a;color:#bbf7d0}
button.danger{background:#3a1a1a;border-color:#6e2626;color:#fca5a5}
.card{background:#101a2e;border:1px solid #1e2b44;border-radius:11px;padding:14px;margin-bottom:14px}
.card>h2{font-size:14px;font-weight:640;color:#9fb6d6;margin-bottom:11px}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
.kv{background:#0c1526;border:1px solid #1b2840;border-radius:8px;padding:9px 11px}
.kv .k{font-size:11.5px;color:#7d93b2;letter-spacing:.4px;text-transform:uppercase}
.kv .v{font-size:15px;font-weight:600;margin-top:3px;word-break:break-all}
.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.row+.row{margin-top:9px}
.hint{font-size:12.5px;color:#7d93b2;margin-top:8px}
.warn{font-size:12.5px;color:#fbbf24;margin-top:8px}
input,select{background:#0c1526;border:1px solid #2a3a56;color:#dfe8f5;padding:6px 9px;border-radius:7px;font-size:13px;font-family:inherit;min-width:92px;width:100%}
input[type=checkbox]{min-width:0;width:15px;height:15px;accent-color:#22c55e}
.dot{width:9px;height:9px;border-radius:99px;background:#4b5563;display:inline-block}
.dot.on{background:#22c55e;box-shadow:0 0 7px #22c55e}
.dot.off{background:#ef4444}
.tch{background:#0c1526;border:1px solid #1b2840;border-radius:10px;padding:12px;margin-bottom:10px}
.tch h3{font-size:14px;color:#7dd3fc;margin-bottom:9px;display:flex;align-items:center;gap:8px}
.tsrow{display:flex;gap:14px;flex-wrap:wrap;margin-top:9px;padding-top:9px;border-top:1px dashed #1e2b44}
.tsrow label{display:flex;align-items:center;gap:5px;font-size:13px}
pre{background:#070d18;border:1px solid #1b2840;border-radius:8px;padding:11px;font:12.5px/1.5 Consolas,monospace;color:#9fb6d6;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all}
#toast{display:none;position:fixed;left:50%;transform:translateX(-50%);bottom:22px;padding:10px 18px;border-radius:9px;font-size:13.5px;background:#1d3260;border:1px solid #3b5686;z-index:99}
#toast.ok{background:#0e2f22;border-color:#1d6b4a;color:#bbf7d0}
#toast.bad{background:#331616;border-color:#7f2222;color:#fca5a5}
.tbl{width:100%;border-collapse:collapse;font-size:13px}
.tbl th{text-align:left;color:#7d93b2;font-weight:600;font-size:11.5px;text-transform:uppercase;padding:5px 7px;border-bottom:1px solid #1e2b44}
.tbl td{padding:5px 7px;border-bottom:1px dashed #16223a;word-break:break-all}
.tbl tr:hover td{background:#0e1729}
.oka{color:#4ade80}
.bada{color:#f87171}
.frameWrap{border:1px solid #1e2b44;border-radius:10px;overflow:hidden;background:#070d18}
.frameWrap iframe{width:100%;height:660px;border:0;display:block;background:#070d18}
</style>
</head>
<body>
<div class="top"><h1>TETRA 集群配置</h1>
  <span id="bSvc" class="badge">读取中…</span>
  <span id="bBin" class="badge">--</span>
  <span class="spacer"></span><span id="bTime" class="badge">--</span>
</div>
<div class="card">
  <h2>运行控制　<span class="hint" style="display:inline">一个 SX1255 只能被一个程序独占：跑 TETRA 时会自动停掉 MMDVM 全部通道</span></h2>
  <div class="row">
    <button class="sm primary" onclick="doSwitch('tetra')">切到 TETRA（停 MMDVM）</button>
    <button class="sm" onclick="doSwitch('mmdvm')">切回 MMDVM（停 TETRA）</button>
    <button class="sm" onclick="doAct('restart')">重启 TETRA</button>
    <button class="sm danger" onclick="doAct('stop')">停止 TETRA</button>
    <button class="sm" onclick="refresh()">刷新</button>
  </div>
  <div class="grid" style="margin-top:10px">
    <div class="kv"><div class="k">TETRA 服务</div><div class="v" id="vSvc">--</div></div>
    <div class="kv"><div class="k">MMDVM 引擎</div><div class="v" id="vMmdvm">--</div></div>
    <div class="kv"><div class="k">基站二进制</div><div class="v" id="vBin">--</div></div>
  </div>
</div>

<div class="card">
  <h2>网络标识</h2>
  <div class="grid">
    <label class="kv"><div class="k">MCC 国家码</div><input id="mcc" type="number" value="460"></label>
    <label class="kv"><div class="k">MNC 网络码</div><input id="mnc" type="number" value="0"></label>
    <label class="kv"><div class="k">色码 Colour Code (0-63)</div><input id="cc" type="number" value="1"></label>
    <label class="kv"><div class="k">本机呼号</div><input id="cs" value="NOCALL"></label>
    <label class="kv"><div class="k">频段</div><input id="bandv" value="400 MHz" readonly style="color:#7d93b2"></label>
    <label class="kv"><div class="k">采样率 (sps)</div><input id="rate" type="number" step="1000" value="300000"></label>
    <label class="kv"><div class="k">PPM 时钟校正</div><input id="ppm" type="number" step="0.5" value="0"></label>
  </div>
  <div class="hint">频段固定为 400 MHz（业余 430–440 MHz）。频率由「统一频率架构」指派，无需在此设置。</div>
</div>

<div class="card">
  <h2>服务器联网（Brew / TetraPack）<span class="hint" style="display:inline;margin-left:8px">TETRA 的联网服务，接入后可与其他基站/用户互通</span></h2>
  <div class="row">
    <label class="row" style="gap:6px"><input id="bwOn" type="checkbox">
      <span class="lbl" style="min-width:0">启用服务器联网</span></label>
  </div>
  <div class="grid" style="margin-top:10px">
    <label class="kv"><div class="k">服务器地址</div><input id="bwHost" placeholder="core.example.net"></label>
    <label class="kv"><div class="k">服务器端口</div><input id="bwPort" type="number" value="9000"></label>
    <label class="kv"><div class="k">用户名（SSID：DMR ID + 两位后缀）</div><input id="bwUser" placeholder="460000000"></label>
    <label class="kv"><div class="k">密码</div><input id="bwPass" type="password"></label>
    <label class="kv"><div class="k">重连间隔 (秒)</div><input id="bwDelay" type="number" value="15"></label>
  </div>
  <div class="row" style="margin-top:10px">
    <label class="row" style="gap:6px"><input id="bwTls" type="checkbox">
      <span class="lbl" style="min-width:0">使用 TLS（wss:// / https://）</span></label>
  </div>
  <div class="hint">参照 TETRA Homebrew Protocol（Brew）。用户名一般是「DMR ID + 两位热点后缀」，密码为热点密码，
    需先在服务器侧注册。保存后写入 <code>config.toml</code> 的 <code>[brew]</code> 段，重启 TETRA 生效。</div>
</div>

<div class="card">
  <h2>TETRA 射频通道<span class="hint" style="display:inline;margin-left:8px">单载波 25 kHz；双载波时额外占用紧邻 25 kHz。由系统指派，不可修改</span></h2>
  <div class="row" style="margin-bottom:10px">
    <label class="row" style="gap:6px"><input id="dcar" type="checkbox" onchange="var c=this.checked;$('secRow').style.display=c?'':'none'">
      <span class="lbl" style="min-width:0">启用双载波（附加紧邻的 25 kHz，共 50 kHz）</span></label>
  </div>
  <div id="secRow" class="grid" style="display:none;margin-bottom:10px">
    <label class="kv"><div class="k">次载波号 (×25kHz)</div><input id="secarrier" type="number" readonly style="color:#7d93b2"></label>
  </div>
  <div id="chans"></div>
  <div class="hint">载波号与收发频率来自「统一频率架构」（接收/发射中心 + 25kHz 栅格），与 MMDVM 各模式共用同一频差。</div>
  <div class="warn">⚠️ TS1 是控制信令时隙（不上通话面板）；TS2 / TS3 / TS4 为通话时隙，会显示在「实时通联监听」看板中。</div>
  <div class="row" style="margin-top:6px">
    <button class="sm primary" onclick="saveCfg()">保存并写入 config.toml</button>
    <button class="sm" onclick="loadCfg()">重新读取</button>
  </div>
</div>

<div class="card">
  <h2>服务日志</h2>
  <div class="row"><button class="sm" onclick="loadLog()">刷新日志</button></div>
  <pre id="logBox">点“刷新日志”查看。</pre>
</div>

<!-- TETRA_ADV_V1 高级参数 -->
<div class="card">
  <h2>⚙ 高级参数（基站内部设置）<span class="hint" style="display:inline;margin-left:8px">从原厂面板挑出的常用项；保存会走「完整重初始化重启」，不会把射频链卡死</span></h2>
  <div class="row">
    <button class="sm" onclick="advLoad()">读取当前值</button>
    <button class="sm primary" onclick="advSave()">保存并应用（安全重启）</button>
    <button class="sm" onclick="safeRestart()">安全重启基站</button>
    <span class="spacer"></span><span class="badge" id="advState">--</span>
  </div>
  <div class="grid" style="margin-top:10px">
    <label class="kv"><div class="k">位置区 Location Area</div><input id="ad_cell_info_location_area" type="number"></label>
    <label class="kv"><div class="k">色码 Colour Code</div><input id="ad_cell_info_colour_code" type="number"></label>
    <label class="kv"><div class="k">频偏 Freq Offset (Hz)</div><input id="ad_cell_info_freq_offset" type="number"></label>
    <label class="kv"><div class="k">反向双工 Reverse</div>
      <label class="row" style="gap:6px"><input id="ad_cell_info_reverse_operation" type="checkbox"><span class="hint">UL 在上</span></label></label>
    <label class="kv"><div class="k">通话保持 Hangtime (s)</div><input id="ad_cell_info_hangtime_secs" type="number"></label>
    <label class="kv"><div class="k">呼叫超时 (s)</div><input id="ad_cell_info_call_timeout_secs" type="number"></label>
    <label class="kv"><div class="k">上行静默释放 (s)</div><input id="ad_cell_info_ul_inactivity_secs" type="number"></label>
    <label class="kv"><div class="k">周期登记 (s，0=关)</div><input id="ad_cell_info_periodic_registration_secs" type="number"></label>
    <label class="kv"><div class="k">语音业务</div>
      <label class="row" style="gap:6px"><input id="ad_cell_info_voice_service" type="checkbox"></label></label>
    <label class="kv"><div class="k">全网业务</div>
      <label class="row" style="gap:6px"><input id="ad_cell_info_system_wide_services" type="checkbox"></label></label>
    <label class="kv"><div class="k">本地 SSI 范围</div><input id="ad_cell_info_local_ssi_ranges" placeholder="[[0, 90]]"></label>
    <label class="kv"><div class="k">白名单模式</div><input id="ad_security_whitelist_mode" placeholder="open / restricted"></label>
    <label class="kv"><div class="k">白名单 ISSI</div><input id="ad_security_issi_whitelist" placeholder="[0]"></label>
    <label class="kv"><div class="k">允许未鉴权去附着</div>
      <label class="row" style="gap:6px"><input id="ad_security_honour_unauthenticated_detach" type="checkbox"></label></label>
    <label class="kv"><div class="k">主动恢复(开机缓存回放)</div>
      <label class="row" style="gap:6px"><input id="ad_recovery_enabled" type="checkbox"></label></label>
    <label class="kv"><div class="k">被动恢复(见机催注册)</div>
      <label class="row" style="gap:6px"><input id="ad_recovery_reactive_enabled" type="checkbox"></label></label>
    <label class="kv"><div class="k">RX LNA 增益</div><input id="ad_phy_io.soapysdr_rx_gain_lna" type="number"></label>
    <label class="kv"><div class="k">RX PGA 增益</div><input id="ad_phy_io.soapysdr_rx_gain_pga" type="number"></label>
    <label class="kv"><div class="k">TX DAC 增益</div><input id="ad_phy_io.soapysdr_tx_gain_dac" type="number"></label>
    <label class="kv"><div class="k">TX MIXER 增益</div><input id="ad_phy_io.soapysdr_tx_gain_mixer" type="number"></label>
    <label class="kv"><div class="k">采样率 (sps)</div><input id="ad_phy_io.soapysdr_sample_rate" type="number" step="25000"></label>
    <label class="kv"><div class="k">PPM 校准</div><input id="ad_phy_io.soapysdr_ppm_err" type="number" step="0.1"></label>
    <label class="kv"><div class="k">RX 天线</div><input id="ad_phy_io.soapysdr_rx_antenna" placeholder="RX"></label>
    <label class="kv"><div class="k">TX 天线</div><input id="ad_phy_io.soapysdr_tx_antenna" placeholder="TX"></label>
  </div>
  <div id="advBox" style="margin-top:10px"></div>
  <div class="hint">这些值都直接写进 <code>/opt/tetra/config.toml</code>；保存前做静态校验（TOML + 频点自洽），失败自动回滚；重启走「MMDVM→TETRA」完整重现初始化 ✓</div>
</div>

<!-- TETRA_AST_V1 SIP 电话 -->
<div class="card">
  <h2>📞 SIP 电话（TETRA ↔ SIP 桥）<span class="hint" style="display:inline;margin-left:8px" id="astHint"></span></h2>
  <div class="row">
    <span class="badge" id="astState">--</span>
    <span class="badge" id="astReg">--</span>
    <span class="badge" id="astDialogs">--</span>
    <span class="spacer"></span>
    <button class="sm" onclick="astLoad()">刷新</button>
    <button class="sm" onclick="openAst()">打开面板</button>
  </div>
  <div class="grid" style="margin-top:10px">
    <label class="kv"><div class="k">启用 SIP 桥</div>
      <label class="row" style="gap:6px"><input id="asEnabled" type="checkbox"><span class="hint">勾选后保存生效</span></label></label>
    <label class="kv"><div class="k">服务器地址</div><input id="asHost" placeholder="sip.example.com"></label>
    <label class="kv"><div class="k">服务器端口</div><input id="asPort" type="number"></label>
    <label class="kv"><div class="k">本地号码 / 分机</div><input id="asUser" placeholder="808"></label>
    <label class="kv"><div class="k">鉴权用户</div><input id="asAuth" placeholder="808"></label>
    <label class="kv"><div class="k">密码（留空=不改）</div><input id="asPass" type="password" placeholder="••••••"></label>
    <label class="kv"><div class="k">realm</div><input id="asRealm" placeholder="asterisk"></label>
    <label class="kv"><div class="k">编码</div><input id="asCodec" placeholder="PCMU"></label>
    <label class="kv"><div class="k">外呼前缀</div><input id="asPrefix" placeholder="9"></label>
    <label class="kv"><div class="k">SIP 监听地址</div><input id="asBind" placeholder="0.0.0.0"></label>
    <label class="kv"><div class="k">SIP 端口</div><input id="asBindPort" type="number"></label>
    <label class="kv"><div class="k">RTP 端口范围</div><input id="asRtp" placeholder="10000-30000"></label>
    <label class="kv"><div class="k">contact_host（本机 IP）</div><input id="asContact" placeholder="192.168.1.10"></label>
    <label class="kv"><div class="k">走 SIP 的 TETRA 号码（逗号分隔）</div><input id="asNums" placeholder="600,601"></label>
  </div>
  <div class="row" style="margin-top:10px">
    <button class="sm primary" onclick="astSave()">保存并应用 SIP 配置</button>
    <span class="hint">保存 → 写入 config.toml 的 [asterisk] 段 → 干跑校验（不合格自动回滚）→ 基站在跑则自动重启</span>
  </div>
  <div id="astBox" style="margin-top:10px"></div>
  <div class="hint">TETRA 终端拨 <b>外呼前缀+号码</b> 走 SIP；对端 SIP 拨打本题内号码时按 service_numbers 路由到 TETRA。</div>
</div>

<!-- 联网服务器（Brew）管理 -->
<div class="card">
  <h2>🌐 联网服务器（Brew）<span class="hint" style="display:inline;margin-left:8px" id="netSrvHint"></span></h2>
  <div id="netSrvList" class="row" style="flex-wrap:wrap;gap:10px">
    <span class="hint">加载中...</span>
  </div>
  <div class="row" style="margin-top:10px">
    <button class="sm primary" onclick="netSrvAdd()">＋ 添加服务器</button>
    <button class="sm" onclick="netSrvLoad()">刷新列表</button>
  </div>
</div>

<!-- TETRA_WL_V1 白名单 -->
<div class="card">
  <h2>🔒 白名单（只允许已登记的 ISSI 入网）<span class="hint" style="display:inline;margin-left:8px" id="wlHint"></span></h2>
  <div class="row">
    <span class="badge" id="wlMode">--</span>
    <button class="sm" onclick="wlSet('open')">设为开放（任何人可入网）</button>
    <button class="sm danger" onclick="wlSet('restricted')">设为受限（仅白名单）</button>
    <button class="sm" onclick="wlLoad()">刷新</button>
  </div>
  <div class="row">
    <input id="wlIssi" placeholder="ISSI 数字，如 0000000" style="max-width:200px">
    <button class="sm primary" onclick="wlAdd()">加入白名单</button>
    <button class="sm" onclick="wlDel()">从白名单移除</button>
  </div>
  <div id="wlBox" style="margin-top:10px">（点击刷新读取）</div>
  <div class="hint">写入 config.toml 的 <code>security.whitelist_mode</code> 与 <code>security.issi_whitelist</code>；修改后自动干跑校验（不合格回滚），基站在跑时自动重启生效。白名单为空时不允许设为受限（否则谁都进不来）。</div>
</div>

<!-- TETRA_PROFILES_V1 配置方案 -->
<div class="card">
  <h2>🗂 配置方案（频点/参数档案）<span class="hint" style="display:inline;margin-left:8px">把一整套 config.toml 存成方案，随时一键切换</span></h2>
  <div class="row">
    <input id="pfName" placeholder="方案名，如 10MHz-439.4" style="max-width:240px">
    <button class="sm primary" onclick="pfSave()">把当前配置存为方案</button>
    <button class="sm" onclick="pfLoad()">刷新方案列表</button>
  </div>
  <div id="pfBox" style="margin-top:10px">（点击刷新读取）</div>
  <div class="hint">激活方案 = 用该方案覆盖 /opt/tetra/config.toml → 先干跑校验（不合格自动回滚）→ 基站在跑时自动重启生效。删除会把文件移到 /tmp 备查。</div>
</div>

<!-- TETRA_DASH_V1 内置面板融合 -->
<div class="card">
  <h2>🖥 基站内置面板（FlowStation Dashboard）<span class="hint" style="display:inline;margin-left:8px" id="dashHint">TETRA 运行时可用</span></h2>
  <div class="row">
    <button class="sm primary" onclick="openDash()">在新窗口打开面板</button>
    <button class="sm" onclick="dashLoad()">刷新面板数据</button>
    <span class="spacer"></span><span class="badge" id="dashState">--</span>
  </div>
  <div class="frameWrap" style="margin-top:10px"><iframe id="dashFrame" src="about:blank" title="TETRA 内置面板"></iframe></div>
  <div class="hint">基站自带面板（独立端口 8080）：<b>通话记录</b>、SDS 短消息、白名单、WiFi、系统、在线更新、DAPNET、Telegram 等。</div>
</div>

<div class="card">
  <h2>📡 基站实时信息（面板公开 API）</h2>
  <div class="grid" id="bsInfo">（TETRA 运行时读取）</div>
  <div class="hint" id="bsNote"></div>
</div>

<div class="card">
  <h2>💬 SDS 短消息（最近 12 条）</h2>
  <div id="sdsBox">（TETRA 运行时读取）</div>
</div>

<div class="card">
  <h2>📱 终端 / 注册表</h2>
  <div id="csBox">（TETRA 运行时读取）</div>
</div>

<div class="card">
  <h2>⚙ 生效值 vs 页面值</h2>
  <div id="diffBox">（TETRA 运行时读取）</div>
</div>

<div id="toast"></div>
<script>
if(location.search.indexOf('embed=1')>=0)document.body.className='embed';
function $(i){return document.getElementById(i);}
function toast(m,ok){var t=$("toast");t.textContent=m;t.className=(ok===false?"bad":(ok===true?"ok":""));t.style.display="block";clearTimeout(window._tt);window._tt=setTimeout(function(){t.style.display="none";},4200);}
function post(u,b){return fetch(u,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b||{})}).then(function(r){return r.json();});}
function num(i){return parseFloat($(i).value||0);}
function mhz(hz){return (hz/1e6).toFixed(4);}
var CFG=null;
function renderChans(cfg,st){
  var cNum = cfg.carrier || 0;
  var ts = cfg.ts || {};
  var tsp = [];
  if (ts.ts2) tsp.push("TS2"); if (ts.ts3) tsp.push("TS3"); if (ts.ts4) tsp.push("TS4");
  var cObj = (st && st.carrier) || {};
  var rx = cObj.rx, tx = cObj.tx;
  var h = '<div class="tch"><h3><span class="dot on"></span>'
    + '<span>主载波</span><span class="spacer"></span>'
    + (rx && tx ? '<span style="font-size:12.5px;color:#9fb6d6">下行 '+mhz(tx)+' ／ 上行 '+mhz(rx)+' MHz</span>' : '')
    + '</h3><div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr))">'
    + '<div class="kv"><div class="k">载波号 (×25kHz)</div><div class="v">'+cNum+'</div></div>'
    + '<div class="kv"><div class="k">TS1 控制时隙</div><div class="v" style="font-size:13px;color:#7d93b2">固定占用（不上通话面板）</div></div>'
    + '<div class="kv"><div class="k">通话时隙</div><div class="v" style="font-size:14px">'+(tsp.join(" / ")||"（无）")+'</div></div>'
    + '</div></div>';
  /* 次载波（双载波模式） */
  if (cfg.dual_carrier && st && st.secondary) {
    var sec = st.secondary;
    var stsp = [];
    var sts = sec.ts || cfg.ts || {};
    if (sts.ts2) stsp.push("TS2"); if (sts.ts3) stsp.push("TS3"); if (sts.ts4) stsp.push("TS4");
    h += '<div class="tch"><h3><span class="dot on"></span>'
      + '<span>次载波（双载波）</span><span class="spacer"></span>'
      + (sec.rx && sec.tx ? '<span style="font-size:12.5px;color:#9fb6d6">下行 '+mhz(sec.tx)+' ／ 上行 '+mhz(sec.rx)+' MHz</span>' : '')
      + '</h3><div class="grid" style="grid-template-columns:repeat(auto-fit,minmax(150px,1fr))">'
      + '<div class="kv"><div class="k">载波号 (×25kHz)</div><div class="v">'+(sec.carrier||'-')+'</div></div>'
      + '<div class="kv"><div class="k">TS1 控制时隙</div><div class="v" style="font-size:13px;color:#7d93b2">固定占用（不上通话面板）</div></div>'
      + '<div class="kv"><div class="k">通话时隙</div><div class="v" style="font-size:14px">'+(stsp.join(" / ")||"（无）")+'</div></div>'
      + '</div></div>';
  }
  $("chans").innerHTML = h;
}
function fill(cfg,st){
  $("mcc").value=cfg.mcc; $("mnc").value=cfg.mnc; $("cc").value=cfg.color_code;
  $("cs").value=cfg.callsign||"";
  $("rate").value=cfg.sample_rate; $("ppm").value=cfg.ppm_err;
  var b=cfg.brew||{};
  $("bwOn").checked=!!b.enable; $("bwHost").value=b.host||""; $("bwPort").value=b.port||9000;
  $("bwUser").value=b.username||""; $("bwPass").value=b.password||"";
  $("bwDelay").value=b.reconnect_delay_secs||15; $("bwTls").checked=b.tls!==false;
  $("dcar").checked=!!cfg.dual_carrier;
  $("secarrier").value=cfg.secondary_carrier||"";
  $("secRow").style.display=cfg.dual_carrier?"":"none";
  renderChans(cfg,st);
}
function refresh(){
  fetch("/api/tetra/status").then(function(r){return r.json();}).then(function(d){
    if(!d.ok){toast("读取失败",false);return;}
    CFG=d.cfg;
    $("bSvc").textContent="TETRA "+d.service; $("bSvc").className="badge "+(d.service==="active"?"ok":"");
    $("bBin").textContent=d.binary?"基站已编译":"基站未编译"; $("bBin").className="badge "+(d.binary?"ok":"bad");
    $("bTime").textContent=new Date().toLocaleTimeString();
    $("vSvc").textContent=d.service; $("vMmdvm").textContent=d.mmdvm;
    $("vBin").textContent=d.binary?"就绪":"缺失";
    fill(d.cfg,d);
  }).catch(function(e){toast("网络错误: "+e,false);});
}
function collect(){
  var d = {mcc:num("mcc"), mnc:num("mnc"), color_code:num("cc"),
          callsign:$("cs").value.trim().toUpperCase(), sample_rate:num("rate"),
          ppm_err:num("ppm"), freq_band:4,
          dual_carrier:$("dcar").checked,
          brew:{enable:$("bwOn").checked, host:$("bwHost").value.trim(),
                port:parseInt($("bwPort").value)||9000, tls:$("bwTls").checked,
                username:$("bwUser").value.trim(), password:$("bwPass").value,
                reconnect_delay_secs:parseInt($("bwDelay").value)||15}};
  if (d.dual_carrier) { d.secondary_carrier = num("secarrier"); }
  return d;
}
function saveCfg(){
  toast("保存中…");
  post("/api/tetra/save",collect()).then(function(r){
    toast(r.ok?("已保存。"+(r.toml||"")):("失败: "+(r.error||"")),r.ok);
    if(r.ok) setTimeout(refresh,800);
  }).catch(function(e){toast("错误: "+e,false);});
}
function doAct(a){
  toast("执行 "+a+" …");
  post("/api/tetra/"+a,{}).then(function(r){
    toast(r.ok?("完成（服务 "+(r.service||"?")+"）"):("失败: "+(r.error||"")),r.ok);
    var delay = (a==="restart") ? 8000 : 3000;
    setTimeout(refresh,delay);
    if(r.ok && a==="restart") setTimeout(dashLoad,delay+2000);
  }).catch(function(e){toast("错误: "+e,false);});
}
function doSwitch(w){
  if(!confirm(w==="tetra"?"将停止 MMDVM 全部通道并启动 TETRA 基站，继续？":"将停止 TETRA 并启动 MMDVM 全部通道，继续？"))return;
  toast("正在切换…");
  post("/api/tetra/switch",{what:w}).then(function(r){
    toast(r.ok?("已切换：TETRA="+r.tetra+"  MMDVM="+r.mmdvm):("失败: "+(r.error||"")),r.ok);
    setTimeout(refresh,6000);
    if(r.ok) setTimeout(dashLoad,8000);
  }).catch(function(e){toast("错误: "+e,false);});
}
function loadCfg(){ refresh(); toast("已重新读取",true); }
function loadLog(){
  $("logBox").textContent="加载中…";
  fetch("/api/tetra/logs?lines=150").then(function(r){return r.json();}).then(function(r){
    $("logBox").textContent=r.text||"(空)";
  }).catch(function(e){$("logBox").textContent="错误: "+e;});
}
/* ===== TETRA_DASH_V1 内置面板融合 ===== */
var DASH = null;
function dashUrl(){ return location.protocol + "//" + location.hostname + ":8080/"; }
function openDash(){ window.open(dashUrl(), "_blank"); }
function esc(s){ return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); }
function kvHtml(k,v){ return '<div class="kv"><div class="k">'+k+'</div><div class="v">'+v+'</div></div>'; }
function dashLoad(){
  fetch("/api/tetra/dash").then(function(r){return r.json();}).then(function(d){ DASH=d; renderDash(d); })
   .catch(function(e){ var n=$("bsNote"); if(n) n.textContent="面板数据读取失败: "+e; });
}
function renderDash(d){
  var on = !!d.running;
  var ds = $("dashState");
  ds.textContent = on ? ("面板在线" + (d.sdr && d.sdr.holder ? (" · "+d.sdr.holder) : ""))
                      : ("TETRA " + ((d.sdr && d.sdr.owner) || "未运行"));
  ds.className = "badge " + (on ? "ok" : "bad");
  var f = $("dashFrame");
  if (f){
    if (on && String(f.src).indexOf(":8080") < 0) f.src = dashUrl();
    if (!on && String(f.src).indexOf(":8080") >= 0) f.src = "about:blank";
  }
  $("dashHint").textContent = on ? ("设备真实面板：" + dashUrl()) : (d.note || "TETRA 未运行");
  var b = d.btsinfo || {}, s = d.system || {}, dc = d.dualcarrier || {}, g = "";
  if (b && b.main_carrier != null){
    g += kvHtml("主载波", esc(b.main_carrier));
    g += kvHtml("下行 / 上行", mhz(b.tx_freq_hz)+" / "+mhz(b.rx_freq_hz)+" MHz");
    g += kvHtml("双工间隔", b.shift_hz ? (Math.abs(b.shift_hz)/1e6).toFixed(3)+" MHz" : "-");
    g += kvHtml("MCC / MNC", esc(b.mcc)+" / "+esc(b.mnc));
    g += kvHtml("白名单", esc(b.whitelist_count)+(b.whitelist_restricted?"（受限）":"（开放）"));
    g += kvHtml("双载波", (dc.enabled?"已启用":"未启用")+(dc.active?" · 运行中":""));
    g += kvHtml("基站版本", esc(d.version||"-"));
  } else { g = '<div class="hint">'+(on?"面板暂无数据":"TETRA 未运行，先点「切到 TETRA」")+'</div>'; }
  if (s && s.hostname){
    g += kvHtml("主机", esc(s.hostname));
    g += kvHtml("SDR", esc(s.sdr_name||"-"));
    g += kvHtml("CPU", (s.cpu_pct==null?"-":s.cpu_pct+"%")+(s.cpu_temp_c==null?"":" · "+Number(s.cpu_temp_c).toFixed(1)+"°C"));
    g += kvHtml("内存", (s.ram_used_mb==null?"-":s.ram_used_mb+" / "+(s.ram_total_mb||"?")+" MB"));
  }
  $("bsInfo").innerHTML = g; $("bsNote").textContent = d.note || "";
  var sd = (d.sds_log||[]).slice(-12).reverse(), h = "";
  if (sd.length){
    h = '<table class="tbl"><tr><th>时间</th><th>向</th><th>源</th><th>目的</th><th>组</th><th>内容</th></tr>';
    for (var i=0;i<sd.length;i++){ var x = sd[i];
      h += '<tr><td>'+esc(x.ts)+'</td><td>'+(x.direction==="rx"?"收":"发")+'</td><td>'+esc(x.source_issi)
         + '</td><td>'+esc(x.dest_issi)+'</td><td>'+(x.is_group?"是":"否")+'</td><td>'+esc(x.text||"")+'</td></tr>'; }
    h += '</table>';
  } else { h = '<div class="hint">'+(on?"暂无 SDS 记录":"TETRA 未运行")+'</div>'; }
  $("sdsBox").innerHTML = h;
  var cs = d.callsigns, arr = [], h2 = "";
  if (cs && typeof cs === "object"){ for (var k in cs){ arr.push([k, cs[k]]); } }
  if (arr.length){
    h2 = '<table class="tbl"><tr><th>ISSI</th><th>信息</th></tr>';
    for (var j=0;j<arr.length;j++){ h2 += '<tr><td>'+esc(arr[j][0])+'</td><td>'
      + esc(typeof arr[j][1]==="object" ? JSON.stringify(arr[j][1]) : arr[j][1]) + '</td></tr>'; }
    h2 += '</table>';
  } else { h2 = '<div class="hint">'+(on?"暂无已注册终端":"TETRA 未运行")+'</div>'; }
  $("csBox").innerHTML = h2;
  var ef = d.effective || {};
  if (ef && ef.mcc != null){
    var rows = ['<tr><th>项目</th><th>页面设置</th><th>设备生效</th><th>状态</th></tr>'];
    function row(n,p,v){ var same = (String(p) === String(v));
      rows.push('<tr><td>'+n+'</td><td>'+esc(p)+'</td><td>'+esc(v)+'</td><td class="'+(same?"oka":"bada")+'">'+(same?"一致":"不一致")+'</td></tr>'); }
    row("MCC", CFG&&CFG.mcc, ef.mcc); row("MNC", CFG&&CFG.mnc, ef.mnc);
    row("频段", CFG&&CFG.freq_band, ef.freq_band);
    row("主载波(界面)", CFG&&CFG.carrier, ef.carrier_ui);
    row("双工间隔(MHz)", CFG&&CFG.duplex_spacing_mhz, ef.spacing_mhz);
    row("载波号(工具)", "-", ef.carrier);
    row("双工索引", "-", ef.duplex_spacing);
    row("下行(MHz)", "-", ef.tx_freq ? (ef.tx_freq/1e6).toFixed(4) : "-");
    row("上行(MHz)", "-", ef.rx_freq ? (ef.rx_freq/1e6).toFixed(4) : "-");
    row("Brew 用户名", CFG&&CFG.brew&&CFG.brew.username, ef.brew_username);
    row("Brew 启用", CFG&&CFG.brew&&CFG.brew.enable, ef.brew_enabled);
    $("diffBox").innerHTML = '<table class="tbl">'+rows.join("")+'</table><div class="hint">「设备生效」来自设备上的 /opt/tetra/config.toml；显示“不一致”时点上面的「保存并写入 config.toml」重写一次（保存自带校验与回滚）。</div>';
  } else { $("diffBox").innerHTML = '<div class="hint">'+(on?"暂无数据":"TETRA 未运行")+'</div>'; }
}
/* ===== TETRA_PROFILES_V1 配置方案 ===== */
function pfLoad(){
  fetch("/api/tetra/profiles").then(function(r){return r.json();}).then(function(d){ pfRender(d); })
   .catch(function(e){ $("pfBox").innerHTML = '<div class="hint">方案读取失败: '+e+'</div>'; });
}
function pfRender(d){
  var ps = (d && d.profiles) || [], h = "";
  if (!ps.length){ h = '<div class="hint">还没有方案。把配置调好后点「把当前配置存为方案」即可创建。</div>'; }
  else {
    h = '<table class="tbl"><tr><th>方案</th><th>下行 / 上行 (MHz)</th><th>间隔</th><th>MCC/MNC</th><th>大小</th><th>状态</th><th>操作</th></tr>';
    for (var i=0;i<ps.length;i++){ var p = ps[i];
      var dl = p.tx ? mhz(p.tx) : "-", ul = p.rx ? mhz(p.rx) : "-";
      var sp = p.custom ? (p.custom/1e6).toFixed(3)+" MHz(自定义)" : (p.idx!=null ? ("索引 "+p.idx) : "-");
      h += '<tr><td>'+esc(p.name)+'</td><td>'+dl+' / '+ul+'</td><td>'+sp+'</td><td>'+esc(p.mcc)+'/'+esc(p.mnc)
         + '</td><td>'+esc(p.bytes)+' B</td><td class="'+(p.active?"oka":"")+'">'+(p.active?"生效中":"待用")+'</td><td>'
         + (p.active ? '<span class="hint">—</span>'
                     : '<button class="sm" onclick="pfAct(this.getAttribute(\'data-n\'))" data-n="'+esc(p.name)+'">激活</button> '
                       + '<button class="sm danger" onclick="pfDel(this.getAttribute(\'data-n\'))" data-n="'+esc(p.name)+'">删除</button>')
         + '</td></tr>';
    }
    h += '</table>';
  }
  var ef = (d && d.current) || {};
  h += '<div class="hint">当前生效：下行 '+(ef.tx_freq?mhz(ef.tx_freq):"-")+' / 上行 '+(ef.rx_freq?mhz(ef.rx_freq):"-")
     + ' MHz · 间隔 '+(ef.spacing_mhz!=null?ef.spacing_mhz:"-")+' MHz · 目录 '+((d&&d.dir)||"-")+'</div>';
  $("pfBox").innerHTML = h;
}
function pfSave(){
  var n = $("pfName").value.trim();
  if (!n){ toast("请先填写方案名", false); return; }
  toast("保存方案中…");
  post("/api/tetra/profile-save", {name:n}).then(function(r){
    toast(r.ok ? ("已保存方案 "+(r.saved||n)) : ("失败: "+(r.error||"")), r.ok);
    if (r.ok){ $("pfName").value = ""; pfLoad(); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
function pfAct(n){
  var run = (DASH && DASH.running);
  if (!confirm("切换到方案 "+n+"？\n\n会用该方案覆盖 /opt/tetra/config.toml"+(run?"，并重启基站":"")+"（约 10-20 秒）")) return;
  toast("激活方案中…");
  post("/api/tetra/profile-activate", {name:n}).then(function(r){
    toast(r.ok ? ("已激活 "+n+(r.restarted?"（基站已重启）":"")) : ("失败: "+(r.error||"")), r.ok);
    if (r.ok){ pfLoad(); setTimeout(dashLoad, 3000); setTimeout(refresh, 3000); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
function pfDel(n){
  if (!confirm("删除方案 "+n+"？（文件会移到 /tmp 备查）")) return;
  post("/api/tetra/profile-delete", {name:n}).then(function(r){
    toast(r.ok ? ("已删除 "+n) : ("失败: "+(r.error||"")), r.ok); pfLoad();
  }).catch(function(e){ toast("错误: "+e, false); });
}
/* ===== TETRA_WL_V1 白名单 ===== */
function wlLoad(){
  fetch("/api/tetra/whitelist").then(function(r){return r.json();}).then(function(d){ wlRender(d); })
   .catch(function(e){ $("wlBox").innerHTML = '<div class="hint">白名单读取失败: '+e+'</div>'; });
}
function wlRender(d){
  d = d || {};
  var m = $("wlMode");
  m.textContent = (d.restricted ? "受限（仅白名单）" : "开放");
  m.className = "badge " + (d.restricted ? "ok" : "");
  $("wlHint").textContent = "共 " + (d.count||0) + " 个已登记 ISSI";
  var ids = d.issi || [], h = "";
  if (ids.length){
    h = '<table class="tbl"><tr><th>ISSI</th><th>说明</th></tr>';
    for (var i=0;i<ids.length;i++){ h += '<tr><td>'+ids[i]+'</td><td>已登记</td></tr>'; }
    h += '</table>';
  } else { h = '<div class="hint">白名单为空（当前为开放模式，任何人都能入网）。</div>'; }
  $("wlBox").innerHTML = h;
}
function wlPost(op, arg){
  return post("/api/tetra/whitelist", {op:op, arg:arg||""});
}
function wlSet(mode){
  if (mode === "restricted" && !confirm("设为受限后，未登记 ISSI 的终端将无法入网。继续？")) return;
  toast("正在设置白名单模式…");
  wlPost("set", mode).then(function(r){
    toast(r.ok ? ("已设为 " + (mode==="restricted"?"受限":"开放") + (r.restarted?"（基站已重启）":"")) : ("失败: "+(r.error||"")), r.ok);
    if (r.ok){ wlRender(r); setTimeout(dashLoad, 3000); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
function wlAdd(){
  var v = $("wlIssi").value.trim();
  if (!v){ toast("请输入 ISSI", false); return; }
  wlPost("add", v).then(function(r){
    toast(r.ok ? ("已加入 " + v + (r.restarted?"（基站已重启）":"")) : ("失败: "+(r.error||"")), r.ok);
    if (r.ok){ $("wlIssi").value = ""; wlRender(r); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
function wlDel(){
  var v = $("wlIssi").value.trim();
  if (!v){ toast("请输入 ISSI", false); return; }
  if (!confirm("把 " + v + " 从白名单移除？")) return;
  wlPost("del", v).then(function(r){
    toast(r.ok ? ("已移除 " + v + (r.restarted?"（基站已重启）":"")) : ("失败: "+(r.error||"")), r.ok);
    if (r.ok){ $("wlIssi").value = ""; wlRender(r); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
/* ===== TETRA_AST_V1 SIP 电话 ===== */
var AST = null;
function openAst(){ window.open(location.protocol + "//" + location.hostname + ":8080/", "_blank"); }
function astPut(id, v){ var e = $(id); if (e && v !== undefined && v !== null) { e.value = v; } }
function astLoad(){
  fetch("/api/tetra/asterisk-get").then(function(r){return r.json();}).then(function(d){ AST = d; astRender(d); })
   .catch(function(e){ $("astBox").innerHTML = '<div class="hint">SIP 状态读取失败: '+e+'</div>'; });
}
function astRender(d){
  d = d || {};
  var cfg = d.config || {}, p = d.panel || {}, pr = p.runtime || {};
  var on = !!cfg.enabled, live = (pr.register_status || "disabled");
  var bs = $("astState"); bs.textContent = on ? "配置：已启用" : "配置：未启用";
  bs.className = "badge " + (on ? "ok" : "");
  var br = $("astReg"); br.textContent = "注册：" + (d.running ? live : "（TETRA 未运行）");
  br.className = "badge " + (live === "registered" ? "ok" : (d.running ? "bad" : ""));
  var bd = $("astDialogs"); bd.textContent = "通话中：" + (pr.active_dialogs == null ? "-" : pr.active_dialogs);
  bd.className = "badge";
  $("astHint").textContent = (cfg.local_user ? (cfg.local_user + "@" + (cfg.remote_host||"?") + ":" + (cfg.remote_port||"?")) : "")
                          + (d.note ? (" · " + d.note) : "");
  astPut("asHost", cfg.remote_host); astPut("asPort", cfg.remote_port);
  astPut("asUser", cfg.local_user); astPut("asAuth", cfg.auth_user);
  astPut("asRealm", cfg.realm); astPut("asCodec", cfg.codec);
  astPut("asPrefix", cfg.outbound_prefix); astPut("asBind", cfg.bind_addr);
  astPut("asBindPort", cfg.bind_port); astPut("asContact", cfg.contact_host);
  astPut("asRtp", (cfg.rtp_port_min && cfg.rtp_port_max) ? (cfg.rtp_port_min + "-" + cfg.rtp_port_max) : "");
  astPut("asNums", (cfg.service_numbers || "").replace(/[\[\]"' ]/g, ""));
  $("asEnabled").checked = !!cfg.enabled;
  var h = "";
  if (d.running && p){
    h += '<table class="tbl"><tr><th>运行态</th><th>值</th></tr>';
    h += '<tr><td>注册状态</td><td class="'+(live==="registered"?"oka":"bada")+'">'+esc(live)+'</td></tr>';
    h += '<tr><td>活动通话</td><td>'+esc(pr.active_dialogs)+'</td></tr>';
    h += '<tr><td>编码 / 对端</td><td>'+esc(pr.codec||"")+' / '+esc(pr.remote||"")+'</td></tr>';
    h += '<tr><td>最后收 / 发</td><td>'+esc(pr.last_rx||"-")+' / '+esc(pr.last_tx||"-")+'</td></tr>';
    h += '<tr><td>最后错误</td><td class="'+(pr.last_error?"bada":"")+'">'+esc(pr.last_error||"-")+'</td></tr>';
    h += '<tr><td>SIP 监听 / RTP</td><td>'+esc(pr.sip_listen||"")+' / '+esc(pr.rtp_port_range||"")+'</td></tr>';
    h += '</table>';
  } else { h = '<div class="hint">'+(d.note||"TETRA 未运行")+'</div>'; }
  $("astBox").innerHTML = h;
}
function astSave(){
  var rtp = ($("asRtp").value || "").split("-");
  var body = {enabled: $("asEnabled").checked,
              remote_host: $("asHost").value.trim(), remote_port: $("asPort").value.trim(),
              local_user: $("asUser").value.trim(), auth_user: $("asAuth").value.trim(),
              realm: $("asRealm").value.trim(), codec: $("asCodec").value.trim(),
              outbound_prefix: $("asPrefix").value.trim(), bind_addr: $("asBind").value.trim(),
              bind_port: $("asBindPort").value.trim(), contact_host: $("asContact").value.trim(),
              service_numbers: ($("asNums").value || "").split(",").map(function(x){return x.trim();}).filter(function(x){return x;})};
  if (rtp.length === 2){ body.rtp_port_min = rtp[0].trim(); body.rtp_port_max = rtp[1].trim(); }
  var pw = $("asPass").value; if (pw) { body.password = pw; }
  toast("正在保存 SIP 配置…");
  post("/api/tetra/asterisk-save", body).then(function(r){
    toast(r.ok ? ("SIP 配置已保存" + (r.restarted ? "（基站已重启）" : "")) : ("失败: " + (r.error||"")), r.ok);
    if (r.ok){ astRender(r); $("asPass").value = ""; setTimeout(dashLoad, 3000); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
/* ===== TETRA_ADV_V1 高级参数 ===== */
var ADV = null;
function advEl(key, id){
  var e = document.getElementById(id);
  if (!e) return;
  if (ADV && ADV.values && ADV.values[key] !== undefined && ADV.values[key] !== null) {
    var v = String(ADV.values[key]).replace(/^"|"$/g, "");
    if (e.type === "checkbox") { e.checked = (v === "true" || v === "1"); }
    else { e.value = v; }
  }
}
function advLoad(){
  fetch("/api/tetra/adv-get").then(function(r){return r.json();}).then(function(d){
    ADV = d;
    if (!d || d.ok === false){ $("advBox").innerHTML = '<div class="hint">读取失败: '+esc((d&&d.error)||"")+'</div>'; return; }
    ["cell_info.location_area","cell_info.colour_code","cell_info.freq_offset",
     "cell_info.reverse_operation","cell_info.hangtime_secs","cell_info.call_timeout_secs",
     "cell_info.ul_inactivity_secs","cell_info.periodic_registration_secs",
     "cell_info.voice_service","cell_info.system_wide_services","cell_info.local_ssi_ranges",
     "security.whitelist_mode","security.issi_whitelist","security.honour_unauthenticated_detach",
     "recovery.enabled","recovery.reactive_enabled",
     "phy_io.soapysdr.rx_gain_lna","phy_io.soapysdr.rx_gain_pga","phy_io.soapysdr.tx_gain_dac",
     "phy_io.soapysdr.tx_gain_mixer","phy_io.soapysdr.sample_rate","phy_io.soapysdr.ppm_err",
     "phy_io.soapysdr.rx_antenna","phy_io.soapysdr.tx_antenna"].forEach(function(k){
      advEl(k, "ad_" + k.replace(/\./g, "."));
    });
    $("advState").textContent = "已读取 " + (d.fields ? d.fields.length : 0) + " 项";
    $("advState").className = "badge ok";
  }).catch(function(e){ $("advBox").innerHTML = '<div class="hint">读取异常: '+e+'</div>'; });
}
function advSave(){
  var body = {};
  var keys = ["cell_info.location_area","cell_info.colour_code","cell_info.freq_offset",
              "cell_info.reverse_operation","cell_info.hangtime_secs","cell_info.call_timeout_secs",
              "cell_info.ul_inactivity_secs","cell_info.periodic_registration_secs",
              "cell_info.voice_service","cell_info.system_wide_services","cell_info.local_ssi_ranges",
              "security.whitelist_mode","security.issi_whitelist","security.honour_unauthenticated_detach",
              "recovery.enabled","recovery.reactive_enabled",
              "phy_io.soapysdr.rx_gain_lna","phy_io.soapysdr.rx_gain_pga","phy_io.soapysdr.tx_gain_dac",
              "phy_io.soapysdr.tx_gain_mixer","phy_io.soapysdr.sample_rate","phy_io.soapysdr.ppm_err",
              "phy_io.soapysdr.rx_antenna","phy_io.soapysdr.tx_antenna"];
  for (var i=0;i<keys.length;i++){
    var e = document.getElementById("ad_" + keys[i]);
    if (!e) continue;
    if (e.type === "checkbox") body[keys[i]] = e.checked ? "true" : "false";
    else if (String(e.value).trim() !== "") body[keys[i]] = String(e.value).trim();
  }
  if (!confirm("保存高级参数并安全重启基站？（约 20-30 秒，期间终端会短暂失联）")) return;
  toast("正在保存并安全重启…");
  post("/api/tetra/adv-save", body).then(function(r){
    toast(r.ok ? (r.note || "已保存") : ("失败: " + (r.error||"")), r.ok);
    if (r.ok){ advLoad(); setTimeout(dashLoad, 4000); setTimeout(refresh, 4000); }
  }).catch(function(e){ toast("错误: "+e, false); });
}
function safeRestart(){
  if (!confirm("安全重启基站？（MMDVM→TETRA 完整重初始化，约 20-30 秒）")) return;
  toast("安全重启中…");
  post("/api/tetra/safe-restart", {}).then(function(r){
    toast(r.ok ? "基站已安全重启" : ("失败: " + (r.error||"")), r.ok);
    setTimeout(function(){ advLoad(); dashLoad(); }, 6000);
  }).catch(function(e){ toast("错误: "+e, false); });
}
/* ★ 性能优化：错开初始加载 + 降低轮询频率，减少树莓派每秒子进程数 */
refresh();
setTimeout(dashLoad, 2000);
setTimeout(astLoad, 4000);
setTimeout(wlLoad, 6000);
setTimeout(pfLoad, 8000);
setTimeout(advLoad, 10000);
setTimeout(netSrvLoad, 12000);
setInterval(refresh, 12000);
setInterval(dashLoad, 45000);
setInterval(advLoad, 120000);
setInterval(astLoad, 120000);
setInterval(wlLoad, 120000);
setInterval(pfLoad, 120000);
setInterval(netSrvLoad, 120000);

/* ========== 联网服务器管理 ========== */
function netSrvLoad(){
  fetch('/api/tetra/servers').then(r=>r.json()).then(d=>{
    var el=document.getElementById('netSrvList');
    if(!d.ok||!d.servers||!d.servers.length){
      el.innerHTML='<span class="hint">暂无服务器配置</span>';
      document.getElementById('netSrvHint').textContent='';
      return;
    }
    var active=d.active_server||'';
    document.getElementById('netSrvHint').textContent='当前: '+active;
    var html='';
    d.servers.forEach(function(s){
      var isActive=s.id===active;
      html+='<div style="border:1px solid '+(isActive?'#10b981':'#374151')+';border-radius:8px;padding:10px 14px;min-width:200px;background:'+(isActive?'rgba(16,185,129,.08)':'rgba(255,255,255,.02)')+'">';
      html+='<div style="font-weight:600;margin-bottom:4px">'+esc(s.name||s.id)+'</div>';
      html+='<div class="hint">'+esc(s.host||'(无地址)')+(s.port?':'+s.port:'')+'</div>';
      if(s.username) html+='<div class="hint">用户: '+esc(s.username)+'</div>';
      html+='<div style="margin-top:8px">';
      if(isActive){
        html+='<span class="badge" style="background:#10b981;color:#fff">✓ 当前使用</span>';
      } else {
        html+='<button class="sm primary" onclick="netSrvSwitch(\''+s.id+'\')">切换</button> ';
      }
      html+='<button class="sm" onclick="netSrvEdit(\''+s.id+'\')">编辑</button> ';
      html+='<button class="sm danger" onclick="netSrvDel(\''+s.id+'\')">删除</button>';
      html+='</div></div>';
    });
    el.innerHTML=html;
  }).catch(function(e){
    document.getElementById('netSrvList').innerHTML='<span class="hint" style="color:#f87171">加载失败: '+e.message+'</span>';
  });
}

function netSrvSwitch(id){
  if(!confirm('确定要切换到服务器 '+id+' 吗？\n切换后 TETRA 服务将自动重启。')) return;
  post('/api/tetra/server-switch',{id:id}).then(function(d){
    if(d.ok){ alert('已切换到: '+(d.active_server||id)); netSrvLoad(); }
    else alert('切换失败: '+(d.error||'未知错误'));
  });
}

function netSrvAdd(){
  var name=prompt('服务器名称:');
  if(!name) return;
  var host=prompt('服务器地址 (host):');
  if(!host) return;
  var port=prompt('端口 (默认443):','443');
  port=parseInt(port)||443;
  var user=prompt('用户名 (可留空):','');
  var pass=prompt('密码 (可留空):','');
  var tls=confirm('是否使用 TLS/SSL 加密？');
  post('/api/tetra/server-add',{name:name,host:host,port:port,username:user,password:pass,tls:tls}).then(function(d){
    if(d.ok){ alert('添加成功: '+(d.added||name)); netSrvLoad(); }
    else alert('添加失败: '+(d.error||'未知错误'));
  });
}

function netSrvEdit(id){
  alert('编辑功能开发中，如需修改请删除后重新添加。');
}

function netSrvDel(id){
  if(!confirm('确定要删除服务器 '+id+' 吗？')) return;
  post('/api/tetra/server-delete',{id:id}).then(function(d){
    if(d.ok){ alert('已删除'); netSrvLoad(); }
    else alert('删除失败: '+(d.error||'未知错误'));
  });
}
</script>
</body>
</html>'''
```

### `usr/local/bin/arch-data-bootstrap.sh`

```bash
#!/bin/bash
# ARCH 首次启动/定期数据库下载：DMR ID、各模式 hosts、反射器列表。
set -u
LOG=/var/log/arch-data-bootstrap.log
STATE=/var/lib/mmdvm/data-bootstrap.status
mkdir -p /var/lib/mmdvm /usr/local/etc/hosts /etc/mmdvm
exec >>"$LOG" 2>&1
echo "[$(date '+%F %T')] 开始更新 ARCH 数据库"
fail=0
/usr/local/bin/update-hosts.sh || fail=$((fail+1))
# 常规情况下 Pi-Star 已提供 DMR ID 和各模式主机文件；仅在缺失时走备用源。
if [ ! -s /usr/local/etc/DMRIds.dat ]; then
  /usr/local/bin/update-database.sh dmrid || fail=$((fail+1))
fi
need_hosts=0
for f in DMR_Hosts.txt YSFHosts.txt P25Hosts.txt NXDNHosts.txt DStar_Hosts.json; do
  [ -s "/usr/local/etc/hosts/$f" ] || need_hosts=1
done
if [ "$need_hosts" -eq 1 ]; then
  /usr/local/bin/update-database.sh hosts || fail=$((fail+1))
fi
/usr/local/libexec/hosts-build.py -v || fail=$((fail+1))
python3 - <<'PY'
import json
try:
    src='/usr/local/etc/hosts/DMR_Hosts.json'
    dst='/etc/mmdvm/bm-masters.json'
    data=json.load(open(src,encoding='utf-8'))
    out=[]
    for x in data if isinstance(data,list) else []:
        out.append({'id':str(x.get('id','')),'net':'BM','label':x.get('label') or x.get('host',''),
                    'host':x.get('host',''),'port':int(x.get('port') or 62031),
                    'pw':x.get('pw',''),'region':x.get('region',''),'country':x.get('country','')})
    json.dump(out,open(dst,'w',encoding='utf-8'),ensure_ascii=False,indent=2)
except Exception:
    pass
PY
status=ok
[ "$fail" -eq 0 ] || status=partial
printf 'STATUS=%s\nUPDATED=%s\nFAIL=%s\n' "$status" "$(date '+%F %T')" "$fail" > "$STATE"
echo "[$(date '+%F %T')] 更新结束：$status（失败项 $fail）"
exit 0
```

### `usr/local/bin/arch-fw-online.sh`

```bash
#!/bin/bash
# ARCH hotspot online firmware helper
# Usage: arch-fw-online.sh check [manifest_url]
#        arch-fw-online.sh update [manifest_url]
set -u
MODE="${1:-check}"
MANIFEST="${2:-https://arch.hljrsa.cn/downloads/firmware/mmdvm-multi.json}"
PKG=/tmp/mmdvm-fw.pkg
EXTRACT=/tmp/mmdvm-fw-extract
TMP=/tmp/arch-fw-online

current_version() {
  if [ -s /etc/mmdvm/firmware-version ]; then
    head -1 /etc/mmdvm/firmware-version
  elif [ -s /etc/mmdvm/install-state.conf ]; then
    awk -F= '/^VERSION=/{print $2; exit}' /etc/mmdvm/install-state.conf
  else
    echo '-'
  fi
}

log() { printf '%s\n' "$*"; }

fetch_manifest() {
  rm -rf "$TMP"; mkdir -p "$TMP"
  if ! curl -fsSL --retry 2 --connect-timeout 15 -m 60 -A 'ARCH-Hotspot/2.3' -o "$TMP/manifest.json" "$MANIFEST"; then
    log 'STATUS=error'
    log "MESSAGE=无法下载固件清单：$MANIFEST"
    return 1
  fi
  if ! jq -e . "$TMP/manifest.json" >/dev/null 2>&1; then
    log 'STATUS=error'
    log 'MESSAGE=固件清单 JSON 格式错误'
    return 1
  fi
  VERSION=$(jq -r '.version // ""' "$TMP/manifest.json")
  URL=$(jq -r '.url // ""' "$TMP/manifest.json")
  SHA256=$(jq -r '.sha256 // ""' "$TMP/manifest.json")
  SIZE=$(jq -r '.size // 0' "$TMP/manifest.json")
  NOTES=$(jq -r '.notes // ""' "$TMP/manifest.json")
  return 0
}

if [ "$MODE" = "check" ]; then
  log "MANIFEST_URL=$MANIFEST"
  log "CURRENT_VERSION=$(current_version)"
  if fetch_manifest; then
    log "LATEST_VERSION=${VERSION:--}"
    log "DOWNLOAD_URL=${URL:--}"
    log "SHA256=${SHA256:--}"
    log "SIZE=${SIZE:-0}"
    log "NOTES=${NOTES:--}"
    if [ -n "${URL:-}" ] && [ -n "${VERSION:-}" ] && [ "${VERSION}" != "$(current_version)" ]; then
      log 'UPDATE_AVAILABLE=yes'
    else
      log 'UPDATE_AVAILABLE=no'
    fi
  fi
  exit 0
fi

if [ "$MODE" != "update" ]; then
  log "未知模式：$MODE"
  exit 2
fi

log "===== ARCH 在线固件升级开始 $(date '+%F %T') ====="
log "MANIFEST_URL=$MANIFEST"
if ! fetch_manifest; then exit 1; fi
log "LATEST_VERSION=${VERSION:--}"
log "DOWNLOAD_URL=${URL:--}"
if [ -z "${URL:-}" ] || [ -z "${VERSION:-}" ]; then
  log 'STATUS=no_release'
  log 'MESSAGE=在线固件通道已建立，但当前清单没有已发布固件。'
  exit 0
fi
if [ -z "${SHA256:-}" ]; then
  log 'STATUS=error'
  log 'MESSAGE=清单缺少 SHA256，拒绝安装。'
  exit 1
fi

log "下载：$URL"
rm -f "$PKG"
if ! curl -fL --retry 2 --connect-timeout 20 -m 900 -A 'ARCH-Hotspot/2.3' -o "$PKG" -w 'HTTP=%{http_code} SIZE=%{size_download}\n' "$URL"; then
  log 'STATUS=error'
  log 'MESSAGE=固件下载失败。'
  exit 1
fi
log "校验 SHA256：$SHA256"
if ! echo "$SHA256  $PKG" | sha256sum -c -; then
  log 'STATUS=error'
  log 'MESSAGE=SHA256 校验失败，已停止安装。'
  exit 1
fi

log '备份当前引擎...'
cp -a /usr/local/bin/MMDVM-Multi "/tmp/MMDVM-Multi.before.$(date +%Y%m%d-%H%M%S)" 2>/dev/null && log '已备份到 /tmp'
rm -rf "$EXTRACT"; mkdir -p "$EXTRACT"
case "$PKG" in
  *.deb)
    log '使用 dpkg 安装...'
    dpkg -i "$PKG" 2>&1 | tail -25 ;;
  *.zip)
    log '解压 zip...'
    unzip -o "$PKG" -d "$EXTRACT" >/dev/null 2>&1 || { log 'STATUS=error'; log 'MESSAGE=zip 解压失败'; exit 1; } ;;
  *)
    log '解压 tar...'
    tar -xzf "$PKG" -C "$EXTRACT" 2>/dev/null || tar -xf "$PKG" -C "$EXTRACT" 2>/dev/null || { log 'STATUS=error'; log 'MESSAGE=不支持的固件包格式'; exit 1; } ;;
esac

BIN=$(find "$EXTRACT" -type f -name MMDVM-Multi | head -1)
SH=$(find "$EXTRACT" -type f -name '*.sh' | head -1)
if [ -n "$BIN" ]; then
  log "安装新引擎：$BIN"
  install -m 755 "$BIN" /usr/local/bin/MMDVM-Multi || { log 'STATUS=error'; log 'MESSAGE=替换引擎失败'; exit 1; }
  systemctl restart --no-block mmdvm-multi
  sleep 4
  log "mmdvm-multi 状态：$(systemctl is-active mmdvm-multi 2>/dev/null)"
  mkdir -p /etc/mmdvm
  printf '%s\n' "$VERSION" > /etc/mmdvm/firmware-version
  log 'STATUS=success'
  log "MESSAGE=在线固件升级完成：$VERSION"
elif [ -n "$SH" ]; then
  log "执行包内安装脚本：$SH"
  chmod +x "$SH"
  ( cd "$(dirname "$SH")" && bash "$(basename "$SH")" ) 2>&1 | tail -50
  mkdir -p /etc/mmdvm
  printf '%s\n' "$VERSION" > /etc/mmdvm/firmware-version
  log 'STATUS=success'
  log "MESSAGE=在线固件升级脚本执行完成：$VERSION"
else
  log 'STATUS=error'
  log 'MESSAGE=固件包内没有 MMDVM-Multi 或安装脚本。'
  exit 1
fi
```

### `usr/local/bin/arch-sync-fm-qso.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Keep the local FMO bridge's QSO channel in sync with the live multi-channel plan."""
import json, os, shutil, subprocess, sys, time

PLAN = '/etc/mmdvm/channels.json'
CFG = '/opt/fmo-bridge/config.json'
INI_EDIT = '/usr/local/libexec/ini-edit.py'


def run(args, timeout=30):
    try:
        return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
    except Exception as e:
        return None


def active(unit):
    r = run(['systemctl', 'is-active', unit], 5)
    return bool(r and r.stdout.strip() == 'active')


def load_plan():
    try:
        with open(PLAN, encoding='utf-8') as f:
            d = json.load(f)
        return d if isinstance(d, dict) else {}
    except Exception:
        return {}


def pick_fm(plan):
    limit = int(plan.get('channels') or 0)
    candidates = []
    for item in plan.get('list') or []:
        try:
            n = int(item.get('n'))
        except Exception:
            continue
        if limit and n > limit:
            continue
        if str(item.get('mode') or '').upper() == 'FM':
            candidates.append((n, item))
    for n, item in candidates:
        if active('mmdvm-iq@%d.service' % n) and active('mmdvmhost@%d.service' % n):
            return n, item
    return candidates[0] if candidates else (None, None)


def write_cfg(cfg, plan, n, item):
    fm = cfg.setdefault('fm', {})
    fm['channel'] = int(n)
    try:
        if item.get('tx') is not None:
            fm['tx_mhz'] = round(float(item['tx']) / 1e6, 4)
    except Exception:
        pass
    try:
        if item.get('rx') is not None:
            fm['rx_mhz'] = round(float(item['rx']) / 1e6, 4)
    except Exception:
        pass
    bak = CFG + '.bak.sync-channel-' + time.strftime('%Y%m%d-%H%M%S')
    try:
        shutil.copy2(CFG, bak)
    except Exception:
        pass
    tmp = CFG + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)
        f.write('\n')
    os.replace(tmp, CFG)


def fix_host_gateway(n):
    path = '/etc/MMDVM-Host-%d.ini' % n
    if not os.path.exists(path):
        return False
    changed = False
    for key, value in (('GatewayAddress', '127.0.0.1'), ('GatewayPort', '49880')):
        r = run(['python3', INI_EDIT, path, 'set', 'FM Network', key, value], 15)
        if r is None or r.returncode != 0:
            continue
    try:
        text = open(path, encoding='utf-8', errors='ignore').read()
        changed = ('GatewayAddress=127.0.0.1' in text and 'GatewayPort=49880' in text)
    except Exception:
        pass
    return changed


def main():
    plan = load_plan()
    n, item = pick_fm(plan)
    if n is None:
        run(['systemctl', 'stop', 'fmo-bridge.service'], 30)
        print('FM_QSO_CHANNEL=none; fmo-bridge stopped')
        return 0
    try:
        with open(CFG, encoding='utf-8') as f:
            cfg = json.load(f)
    except Exception as e:
        print('FM_QSO_SYNC_ERROR=%s' % e)
        return 1
    write_cfg(cfg, plan, n, item or {})
    fix_host_gateway(n)
    run(['systemctl', 'enable', '--now', 'fmo-bridge.service'], 40)
    run(['systemctl', 'restart', 'fmo-bridge.service'], 40)
    print('FM_QSO_CHANNEL=%d' % n)
    return 0

if __name__ == '__main__':
    sys.exit(main())
```

### `usr/local/bin/backup.sh`

```bash
#!/bin/bash
set -euo pipefail
D=/var/backups/mmdvm; mkdir -p "$D"; F="$D/arch-hotspot-$(date +%Y%m%d-%H%M%S).tar.gz"
tar -czf "$F" /etc/MMDVM-*.ini /etc/DMRGateway.ini /etc/mmdvm /opt/fmo-bridge/config.json 2>/dev/null || true
echo "备份完成：$F"
```

### `usr/local/bin/channel-mode.sh`

```bash
#!/bin/bash
set -euo pipefail
exec /usr/local/libexec/channel-setup.py set "$1" "$2"
```

### `usr/local/bin/diagnose.sh`

```bash
#!/bin/bash
set -u
echo "ARCH 热点诊断"
echo "主机: $(hostname)  时间: $(date '+%F %T')"
for u in mmdvm-multi mmdvm-web dmrgateway fmo-bridge nginx mosquitto; do printf '%-18s %s\n' "$u" "$(systemctl is-active "$u" 2>/dev/null)"; done
for i in 1 2 3 4 5 6 7; do printf '信道 %d: IQ=%s Host=%s\n' "$i" "$(systemctl is-active mmdvm-iq@$i 2>/dev/null)" "$(systemctl is-active mmdvmhost@$i 2>/dev/null)"; done
echo "网络监听:"; ss -lun 2>/dev/null | grep -E ':(3334|3335|4810|4820|49880|62031|62032)\b' || true
```

### `usr/local/bin/dmrid-update-wrapper.sh`

```bash
#!/bin/bash
exec /usr/local/bin/update-database.sh dmrid
```

### `usr/local/bin/sys-manage.sh`

```bash
#!/bin/bash
# sys-manage.sh —— Web 控制台「系统设置」的唯一特权入口（mmdvm-web 通过 sudo -n 调用）
set -u
ACT="${1:-}"; shift || true

# ==================== WiFi 辅助（NetworkManager / wpa_supplicant 双后端）====================
# 兼容：树莓派 3（多为 wpa_supplicant + dhcpcd）与树莓派 4/5（NetworkManager）
_wifi_iface() {
    for d in /sys/class/net/*/wireless; do
        [ -e "$d" ] || continue
        basename "$(dirname "$d")"; return 0
    done
    iw dev 2>/dev/null | awk '/Interface/{print $2; exit}'
}
_wifi_backend() {
    if command -v nmcli >/dev/null 2>&1 && systemctl is-active --quiet NetworkManager 2>/dev/null; then
        echo nm; return
    fi
    if command -v wpa_cli >/dev/null 2>&1; then
        if systemctl is-active --quiet wpa_supplicant 2>/dev/null || pgrep -x wpa_supplicant >/dev/null 2>&1; then
            echo wpa; return
        fi
        echo wpa-off; return
    fi
    echo none
}
_wifi_band() {
    f="${1:-0}"
    case "$f" in ''|*[!0-9]*) echo "-"; return ;; esac
    if [ "$f" -ge 2400 ] && [ "$f" -le 2500 ]; then echo "2.4G"
    elif [ "$f" -ge 4900 ]; then echo "5G"
    else echo "-"; fi
}
_wifi_radio_state() {
    if command -v nmcli >/dev/null 2>&1 && systemctl is-active --quiet NetworkManager 2>/dev/null; then
        s=$(nmcli radio wifi 2>/dev/null | tr 'A-Z' 'a-z')
        [ -n "$s" ] && { echo "$s"; return; }
    fi
    if rfkill list wifi 2>/dev/null | grep -q "Soft blocked: yes"; then echo disabled; else echo enabled; fi
}
_wifi_radio_on() {
    if command -v nmcli >/dev/null 2>&1 && systemctl is-active --quiet NetworkManager 2>/dev/null; then
        [ "$(nmcli radio wifi 2>/dev/null | tr 'A-Z' 'a-z')" = "disabled" ] && nmcli radio wifi on >/dev/null 2>&1
    fi
    rfkill unblock wifi 2>/dev/null || true
    sleep 1
}
_wpa_conf() { echo "/etc/wpa_supplicant/wpa_supplicant.conf"; }
_wifi_saved_count() {
    if command -v nmcli >/dev/null 2>&1 && systemctl is-active --quiet NetworkManager 2>/dev/null; then
        nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2=="802-11-wireless"' | wc -l
    else
        grep -c 'network=' "$(_wpa_conf)" 2>/dev/null || echo 0
    fi
}
_nm_terse() {
    # nmcli 的 terse 输出用冒号分隔，且把值里的冒号转义成 \:
    # 本函数统一转成 | 分隔（先保护 \: ，再转，再还原），兼容不支持
    # --separator 的 nmcli 构建（Debian 的 nmcli 1.52 就没有该选项）
    nmcli -t "$@" 2>/dev/null | sed 's/\\:/__C__/g' | tr ':' '|' | sed 's/__C__/:/g'
}
_wifi_conn_of_ssid() {   # $1=SSID -> nm 连接名
    nmcli -t -f NAME,802-11-wireless.ssid connection show 2>/dev/null \
      | awk -F: -v s="$1" '$2==s{print $1; exit}'
}

# ==================== WiFi 凭据备份 / 开机自愈 ====================
# 备份文件：/etc/wpa_supplicant/wpa_supplicant.conf（含 country=CN），
# 一旦 NM 配置丢失（本项目遇到过），wifi-restore 可据此重建，避免"开机不自动连"。
_wpa_backup_add() {   # $1=SSID $2=密码(可空) $3=隐藏(0/1)
    local conf=/etc/wpa_supplicant/wpa_supplicant.conf ssid="$1" pw="$2" hid="${3:-0}"
    [ -n "$ssid" ] || return 0
    mkdir -p /etc/wpa_supplicant
    [ -f "$conf" ] || printf 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\nupdate_config=1\ncountry=CN\n' > "$conf"
    python3 - "$conf" "$ssid" "$pw" "$hid" <<'PYWPA'
import re, sys
conf, ssid, pw, hid = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
try:
    s = open(conf, encoding="utf-8", errors="ignore").read()
except Exception:
    s = "ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\nupdate_config=1\ncountry=CN\n"
if "country=" not in s:
    s = "country=CN\n" + s
blocks = re.findall(r"network=\{[^}]*\}", s, re.S)
keep = []
for b in blocks:
    m = re.search(r'ssid="([^"]*)"', b)
    if m and m.group(1) == ssid:
        continue
    keep.append(b)
rest = re.sub(r"network=\{[^}]*\}", "", s, flags=re.S).strip()
blk = ['network={', '\tssid="%s"' % ssid]
if pw:
    blk.append('\tpsk="%s"' % pw)
else:
    blk.append('\tkey_mgmt=NONE')
if hid == "1":
    blk.append('\tscan_ssid=1')
blk.append('}')
out = rest + "\n" + "\n".join(keep + ["\n".join(blk)]) + "\n"
open(conf, "w", encoding="utf-8").write(out)
print("备份凭据: %s" % ssid)
PYWPA
    chmod 600 "$conf" 2>/dev/null || true
}
_wpa_backup_list() {   # 输出 "SSID<TAB>密码"（只读，供恢复用）
    python3 - <<'PYLIST'
import re
try:
    s = open("/etc/wpa_supplicant/wpa_supplicant.conf", encoding="utf-8", errors="ignore").read()
except Exception:
    raise SystemExit(0)
for b in re.findall(r"network=\{[^}]*\}", s, re.S):
    sid = re.search(r'ssid="([^"]*)"', b)
    psk = re.search(r'psk="([^"]*)"', b)
    if sid and sid.group(1):
        print("%s\t%s" % (sid.group(1), psk.group(1) if psk else ""))
PYLIST
}
_prefix2mask() {   # $1=前缀长度 -> 点分掩码
    python3 - "$1" <<'PYM'
import sys
try:
    p = int(sys.argv[1])
    m = (0xFFFFFFFF << (32 - p)) & 0xFFFFFFFF if p > 0 else 0
    print("%d.%d.%d.%d" % ((m >> 24) & 255, (m >> 16) & 255, (m >> 8) & 255, m & 255))
except Exception:
    print("")
PYM
}
_mask2prefix() {   # $1=点分掩码或前缀 -> 前缀长度
    python3 - "$1" <<'PYM'
import sys
a = sys.argv[1].strip()
try:
    if "." not in a:
        print(int(a)); raise SystemExit(0)
    m = 0
    for x in a.split("."):
        m = (m << 8) | (int(x) & 255)
    print(bin(m).count("1"))
except Exception:
    print("")
PYM
}
_wifi_fix_profile() {   # $1=连接名（规范化：自动连接 / 无限重试 / 优先级）
    local cn="$1"
    [ -n "$cn" ] || return 0
    nmcli connection modify "$cn" connection.autoconnect yes >/dev/null 2>&1 || true
    nmcli connection modify "$cn" connection.autoconnect-priority 10 >/dev/null 2>&1 || true
    nmcli connection modify "$cn" connection.autoconnect-retries 0 >/dev/null 2>&1 || true
    nmcli connection modify "$cn" ipv4.may-fail no >/dev/null 2>&1 || true
    nmcli connection modify "$cn" ipv6.may-fail no >/dev/null 2>&1 || true
}

# ==================== 后台任务（系统更新 / 固件更新）====================
# 用 systemd-run 起临时单元，Web 请求立刻返回；进度写日志文件
JOB_LOG_DIR=/var/log
FW_URL_FILE=/etc/mmdvm/fw-url
FW_DEFAULT_URL="https://arch.hljrsa.cn/downloads/firmware/mmdvm-multi.json"

_fw_url() {
    if [ -s "$FW_URL_FILE" ]; then cat "$FW_URL_FILE"; else echo "$FW_DEFAULT_URL"; fi
}
_job_start() {   # $1=单元名 $2=日志文件 $3...=要执行的命令
    local unit="$1" log="$2"; shift 2
    : > "$log"
    {
        echo "===== $(date '+%F %T') 开始：$unit ====="
        echo "命令：$*"
        echo
    } >> "$log" 2>&1
    systemctl reset-failed "$unit" 2>/dev/null || true
    systemd-run --unit="$unit" --collect --quiet \
        --property=StandardOutput="append:$log" \
        --property=StandardError="append:$log" \
        --property=WorkingDirectory=/ \
        "$@" >/dev/null 2>&1
    if [ $? -ne 0 ]; then echo "后台任务启动失败（systemd-run 不可用？）"; return 1; fi
    echo "已在后台开始（日志 $log）"
}
_job_status() {  # $1=单元名 $2=日志文件
    local unit="$1" log="$2" st
    st=$(systemctl is-active "$unit" 2>/dev/null || true)
    case "$st" in
        active|activating) echo "STATE=running";;
  hw-info)
      /usr/local/bin/hw-detect.sh hw-info
      ;;
  hw-force-max)
      /usr/local/bin/hw-detect.sh hw-force-max ${1:-0}
      ;;
        *)                 echo "STATE=idle";;
    esac
    if [ -s "$log" ]; then
        echo "--- 日志尾部（$log）---"
        tail -n 25 "$log"
    else
        echo "STATE=idle"
        echo "（还没有日志）"
    fi
}
_apt_count() {
    apt-get -s upgrade 2>/dev/null | awk '/^[0-9]+ upgraded/{print $1; exit}' || echo 0
}

case "$ACT" in
  info)
      echo "HOSTNAME=$(hostname)"
      echo "MODEL=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null)"
      echo "KERNEL=$(uname -r)"
      echo "UPTIME=$(uptime -p 2>/dev/null || uptime)"
      echo "TEMP=$(vcgencmd measure_temp 2>/dev/null | sed 's/temp=//')"
      echo "DISK=$(df -h / | awk 'NR==2{print $3"/"$2" ("$5")"}')"
      echo "MEM=$(free -h | awk '/Mem:/{print $3"/"$2}')"
      echo "TIME=$(date '+%Y-%m-%d %H:%M:%S %Z')"
      echo "TZ=$(timedatectl show -p Timezone --value 2>/dev/null)"
      ;;
  net)
      # ★ 简洁键值输出（系统设置页「网络状态」直接渲染）
      echo "HOSTNAME=$(hostname)"
      for IF in eth0 wlan0; do
          T=$(nmcli -t -f DEVICE,TYPE dev status 2>/dev/null | awk -F: -v d="$IF" '$1==d{print $2; exit}')
          [ -n "$T" ] || continue
          U=$(echo "$IF" | tr 'a-z' 'A-Z')
          ST=$(nmcli -t -f DEVICE,STATE dev status 2>/dev/null | awk -F: -v d="$IF" '$1==d{print $2; exit}')
          IP=$(ip -4 -o addr show "$IF" 2>/dev/null | awk '{print $4}' | head -1)
          GW=$(ip -4 route show default dev "$IF" 2>/dev/null | awk '{print $3; exit}')
          echo "${U}_TYPE=$T"
          echo "${U}_STATE=${ST:-unknown}"
          echo "${U}_IP=${IP:--}"
          echo "${U}_GW=${GW:--}"
          if [ "$T" = "wifi" ]; then
              WS=$(nmcli -t -f IN-USE,SSID,SIGNAL dev wifi 2>/dev/null | awk -F: '$1=="*"{print $2" ("$3"%)"; exit}')
              echo "WIFI_SSID=${WS:--}"
          fi
      done
      # \u2605 \u8fc7\u6ee4 IPv6 \u94fe\u8def\u672c\u5730\u5730\u5740\uff08fe80:: \u4e0e %iface\uff09\uff0c\u6700\u591a\u4fdd\u7559 4 \u4e2a
      echo "DNS=$(awk '/^nameserver/{print $2}' /etc/resolv.conf 2>/dev/null \
              | grep -viE '^(fe80:|ff0|::1$)' | grep -v '%' | head -4 | tr '\n' ',' | sed 's/,$//')"
      ;;
  wifi-list|wifi-scan)
      # 输出：INUSE|SSID|信号%|加密|信道|频率MHz|频段   （按信号从强到弱，同名只留最强）
      BE="$(_wifi_backend)"; IF="$(_wifi_iface)"
      if [ "$BE" = "nm" ]; then
          RAW=$(_nm_terse -f IN-USE,SSID,SIGNAL,SECURITY,CHAN,FREQ \
                dev wifi list --rescan yes)
          printf '%s\n' "$RAW" | awk -F'|' '
              NF>=5 && $2!="" {
                  f = (NF>=6 ? $6+0 : 0); c = (NF>=5 ? $5+0 : 0)
                  b = (c>=1 && c<=14) ? "2.4G" : (c>=32 ? "5G" : (f>=2400&&f<=2500 ? "2.4G" : (f>=4900 ? "5G" : "-")))
                  if (!(($2) in best) || $3+0 > sig[$2]) { best[$2]=1; sig[$2]=$3+0;
                    line[$2]=sprintf("%s|%s|%s|%s|%s|%s|%s", $1, $2, $3, ($4==""?"开放":$4), (c?c:"-"), (f?f:"-"), b) }
              }
              END { for (k in line) print line[k] }' | sort -t'|' -k3 -nr
      elif [ "$BE" = "wpa" ]; then
          wpa_cli -i "$IF" scan >/dev/null 2>&1
          sleep 5
          wpa_cli -i "$IF" scan_results 2>/dev/null | awk -F'\t' '
              NR>1 && $5!="" {
                  f=$2+0; b=(f>=2400&&f<=2500)?"2.4G":(f>=4900?"5G":"-")
                  sec=($4 ~ /WPA3/ ? "WPA3" : ($4 ~ /WPA2/ ? "WPA2" : ($4 ~ /WPA/ ? "WPA" : ($4 ~ /WEP/ ? "WEP" : "开放"))))
                  lvl=($3+0)+100; if (lvl<0) lvl=0; if (lvl>100) lvl=100
                  if (!(($5) in best) || $3+0 > sig[$5]) { best[$5]=1; sig[$5]=$3+0;
                    line[$5]=sprintf(" |%s|%s|%s|%s|%s|%s", $5, lvl, sec, "-", f, b) }
              }
              END { for (k in line) print line[k] }' | sort -t'|' -k3 -nr
      else
          echo "未检测到 WiFi 后端（NetworkManager 未运行且无 wpa_supplicant）"
      fi
      ;;
  wifi-status)
      BE="$(_wifi_backend)"; IF="$(_wifi_iface)"
      echo "BACKEND=$BE"
      echo "IFACE=${IF:--}"
      echo "RADIO=$(_wifi_radio_state)"
      if [ "$BE" = "nm" ]; then
          CON=$(nmcli -t -f NAME,TYPE connection show --active 2>/dev/null | awk -F: '$2=="802-11-wireless"{print $1; exit}')
          echo "CONN=${CON:--}"
          if [ -n "${CON:-}" ]; then
              echo "SSID=$(nmcli -g 802-11-wireless.ssid connection show "$CON" 2>/dev/null)"
              echo "AUTOCONNECT=$(nmcli -g connection.autoconnect connection show "$CON" 2>/dev/null)"
              echo "PRIORITY=$(nmcli -g connection.autoconnect-priority connection show "$CON" 2>/dev/null)"
              echo "KEYMGMT=$(nmcli -g 802-11-wireless-security.key-mgmt connection show "$CON" 2>/dev/null)"
          fi
          _nm_terse -f IN-USE,SSID,SIGNAL,FREQ,RATE,SECURITY dev wifi list \
            | awk -F'|' '$1=="*"{print "SIGNAL="$3"%"; print "FREQ="$4; print "RATE="$5; print "WIFISEC="$6; exit}'
      else
          ST=$(wpa_cli -i "$IF" status 2>/dev/null)
          printf '%s\n' "$ST" | awk -F= '/^ssid=/{print "SSID="$2} /^wpa_state=/{print "WPASTATE="$2} /^ip_address=/{print "IP="$2}'
      fi
      FR=$(_nm_terse -f IN-USE,FREQ dev wifi list | awk -F'|' '$1=="*"{print $2; exit}')
      if [ -n "${FR:-}" ]; then
          echo "FREQ=$FR"; echo "BAND=$(_wifi_band "$(printf '%s' "$FR" | tr -dc 0-9)")"
      fi
      if [ -z "${IP:-}" ]; then
          IPLINE=$(ip -4 -o addr show "$IF" 2>/dev/null | awk '{print $4}' | head -1)
          echo "IP=${IPLINE:--}"
      fi
      echo "GW=$(ip -4 route 2>/dev/null | awk '/^default/{print $3; exit}')"
      echo "SAVED=$(_wifi_saved_count)"
      echo "BANDS=$(iwlist "$IF" freq 2>/dev/null | grep -c GHz)"
      ;;

  wifi-saved)
      # 输出：连接名|SSID|自动连接|优先级|加密
      BE="$(_wifi_backend)"; IF="$(_wifi_iface)"
      if [ "$BE" = "nm" ]; then
          _nm_terse -f NAME,TYPE connection show \
            | awk -F'|' '$2=="802-11-wireless"{print $1}' | while IFS= read -r CN; do
                  [ -n "$CN" ] || continue
                  SS=$(nmcli -g 802-11-wireless.ssid connection show "$CN" 2>/dev/null)
                  AC=$(nmcli -g connection.autoconnect connection show "$CN" 2>/dev/null)
                  PR=$(nmcli -g connection.autoconnect-priority connection show "$CN" 2>/dev/null)
                  KM=$(nmcli -g 802-11-wireless-security.key-mgmt connection show "$CN" 2>/dev/null)
                  printf '%s|%s|%s|%s|%s\n' "$CN" "${SS:--}" "${AC:-yes}" "${PR:-0}" "${KM:-open}"
              done
      else
          awk '
            /network=\{/ { inb=1; ssid=""; km="open"; hid=""; next }
            inb && /\}/  { if (ssid=="") ssid="-"; printf "%s|%s|yes|0|%s%s\n", ssid, ssid, km, hid; inb=0; next }
            inb && /^[ \t]*ssid=/ { s=$0; sub(/^[ \t]*ssid=/,"",s); gsub(/"/,"",s); gsub(/\r/,"",s); ssid=s }
            inb && /^[ \t]*psk=/ { km="WPA/PSK" }
            inb && /^[ \t]*key_mgmt=NONE/ { km="open" }
            inb && /^[ \t]*scan_ssid=1/ { hid="(隐藏)" }
          ' /etc/wpa_supplicant/wpa_supplicant.conf 2>/dev/null
      fi
      ;;
  wifi-connect)
      SSID="${1:-}"; PW="${2:-}"; HID="${3:-0}"
      [ -n "$SSID" ] || { echo "缺少 SSID"; exit 2; }
      BE="$(_wifi_backend)"; IF="$(_wifi_iface)"
      if [ "$BE" = "nm" ]; then
          _wifi_radio_on
          if [ -n "$PW" ]; then
              if [ "$HID" = "1" ]; then OUT=$(nmcli --wait 45 dev wifi connect "$SSID" password "$PW" hidden yes 2>&1)
              else OUT=$(nmcli --wait 45 dev wifi connect "$SSID" password "$PW" 2>&1); fi
          else
              if [ "$HID" = "1" ]; then OUT=$(nmcli --wait 45 dev wifi connect "$SSID" hidden yes 2>&1)
              else OUT=$(nmcli --wait 45 dev wifi connect "$SSID" 2>&1); fi
          fi
          RC=$?
          echo "$OUT"
          if [ $RC -eq 0 ]; then
              CN=$(_wifi_conn_of_ssid "$SSID")
              [ -n "$CN" ] && _wifi_fix_profile "$CN"
              _wpa_backup_add "$SSID" "$PW" "$HID"
              echo "已保存并设为开机自动连接（连接名: ${CN:-$SSID}）"
              echo "---- 当前 WiFi 状态 ----"
              _nm_terse -f IN-USE,SSID,SIGNAL,FREQ,RATE dev wifi list \
                | awk -F'|' '$1=="*"{print "  SSID="$2"  信号="$3"%  频率="$4"  速率="$5; exit}'
          fi
          exit $RC
      elif [ "$BE" = "wpa" ]; then
          _wifi_radio_on
          ID=$(wpa_cli -i "$IF" add_network 2>/dev/null | tail -1)
          case "$ID" in ''|*[!0-9]*) echo "wpa_cli add_network 失败（后端未就绪）"; exit 3 ;; esac
          wpa_cli -i "$IF" set_network "$ID" ssid "\"$SSID\"" >/dev/null 2>&1
          if [ -n "$PW" ]; then wpa_cli -i "$IF" set_network "$ID" psk "\"$PW\"" >/dev/null 2>&1
          else wpa_cli -i "$IF" set_network "$ID" key_mgmt NONE >/dev/null 2>&1; fi
          [ "$HID" = "1" ] && wpa_cli -i "$IF" set_network "$ID" scan_ssid 1 >/dev/null 2>&1
          wpa_cli -i "$IF" enable_network "$ID" >/dev/null 2>&1
          wpa_cli -i "$IF" select_network "$ID" >/dev/null 2>&1
          wpa_cli -i "$IF" save_config >/dev/null 2>&1 || echo "注意：save_config 失败，重启后可能不会自动重连"
          _wpa_backup_add "$SSID" "$PW" "$HID"
          sleep 8
          wpa_cli -i "$IF" status 2>/dev/null | grep -E '^(ssid|wpa_state|ip_address|key_mgmt)='
      else
          echo "未检测到可用的 WiFi 后端（NetworkManager / wpa_supplicant 都没有）"; exit 3
      fi
      ;;
  wifi-hidden)
      exec "$0" wifi-connect "$1" "${2:-}" 1
      ;;

  wifi-disconnect)
      BE="$(_wifi_backend)"; IF="$(_wifi_iface)"
      if [ "$BE" = "nm" ]; then
          if [ -n "$IF" ]; then nmcli dev disconnect "$IF" 2>&1; else echo "没有活动的 WiFi 接口"; fi
      else
          wpa_cli -i "$IF" disconnect 2>&1 || echo "断开失败"
      fi
      ;;
  wifi-forget)
      T="${1:-}"
      [ -n "$T" ] || { echo "缺少要删除的连接名或 SSID"; exit 2; }
      if [ "$(_wifi_backend)" = "nm" ]; then
          CN=""
          nmcli -t -f NAME connection show 2>/dev/null | grep -qxF "$T" && CN="$T"
          [ -z "$CN" ] && CN=$(_wifi_conn_of_ssid "$T")
          [ -z "$CN" ] && CN="$T"
          nmcli connection delete "$CN" 2>&1
      else
          python3 - "$T" <<'WPADEL'
import re, sys
ssid = sys.argv[1]
p = "/etc/wpa_supplicant/wpa_supplicant.conf"
try:
    s = open(p, encoding="utf-8", errors="ignore").read()
except Exception as e:
    print("读取 %s 失败: %s" % (p, e)); sys.exit(1)
blocks = re.findall(r"network=\{[^}]*\}", s, re.S)
keep, removed = [], 0
for b in blocks:
    m = re.search(r'ssid="([^"]*)"', b)
    if m and m.group(1) == ssid:
        removed += 1; continue
    keep.append(b)
rest = re.sub(r"network=\{[^}]*\}", "", s, flags=re.S).strip()
open(p + ".bak", "w", encoding="utf-8").write(s)
open(p, "w", encoding="utf-8").write(rest + "\n" + "\n".join(keep) + "\n")
print("已删除 %d 个 SSID=%s 的配置（原文件已备份为 wpa_supplicant.conf.bak）" % (removed, ssid))
WPADEL
      fi
      ;;
  wifi-reconnect)
      T="${1:-}"; [ -n "$T" ] || { echo "缺少连接名"; exit 2; }
      if [ "$(_wifi_backend)" = "nm" ]; then nmcli connection up "$T" 2>&1
      else wpa_cli -i "$(_wifi_iface)" reconfigure 2>&1; fi
      ;;
  wifi-radio)
      R="${1:-on}"
      if [ "$R" = "off" ]; then
          command -v nmcli >/dev/null 2>&1 && nmcli radio wifi off 2>&1
          rfkill block wifi 2>&1
          echo "WiFi 射频已关闭（省电/防干扰；网线仍可用）"
      else
          rfkill unblock wifi 2>&1
          command -v nmcli >/dev/null 2>&1 && nmcli radio wifi on 2>&1
          echo "WiFi 射频已打开"
      fi
      ;;
  wifi-country)
      C="CN"      # ★ 国家/地区码固定为 CN（不再允许界面修改）
      if command -v raspi-config >/dev/null 2>&1; then
          raspi-config nonint do_wifi_country "$C" >/dev/null 2>&1 && echo "已设置地区：$C"
      fi
      CONF=$(_wpa_conf)
      if [ -f "$CONF" ]; then
          if grep -q '^country=' "$CONF"; then sed -i "s/^country=.*/country=$C/" "$CONF"
          else sed -i "1i country=$C" "$CONF"; fi
          echo "已写入 $CONF : country=$C"
      else
          mkdir -p "$(dirname "$CONF")"
          printf 'ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev\nupdate_config=1\ncountry=%s\n' "$C" > "$CONF"
          echo "已创建 $CONF 并写入 country=$C"
      fi
      mkdir -p /etc/default
      if [ -f /etc/default/crda ]; then sed -i "s/^REGDOMAIN=.*/REGDOMAIN=$C/" /etc/default/crda
      else echo "REGDOMAIN=$C" > /etc/default/crda; fi
      iw reg set "$C" 2>/dev/null || true
      sleep 1
      echo "当前管制域：$(iw reg get 2>/dev/null | awk '/^country/{print $2; exit}' | tr -d ':')（固定 CN）"
      ;;
  wifi-priority)
      T="${1:-}"; P="${2:-0}"
      [ -n "$T" ] || { echo "缺少连接名"; exit 2; }
      case "$P" in ''|*[!0-9-]*) echo "优先级必须是整数"; exit 2 ;; esac
      nmcli connection modify "$T" connection.autoconnect-priority "$P" 2>&1 && echo "已设置 $T 自动连接优先级 = $P"
      ;;
  wifi-autoconnect)
      T="${1:-}"; V="${2:-yes}"
      [ -n "$T" ] || { echo "缺少连接名"; exit 2; }
      case "$V" in yes|no) : ;; *) echo "取值必须是 yes / no"; exit 2 ;; esac
      nmcli connection modify "$T" connection.autoconnect "$V" 2>&1 && echo "$T 自动连接 = $V"
      ;;
  wifi-info)
      T="${1:-}"
      [ -n "$T" ] || { echo "缺少连接名"; exit 2; }
      echo "--- $T ---"
      for k in 802-11-wireless.ssid connection.autoconnect connection.autoconnect-priority \
               802-11-wireless-security.key-mgmt 802-11-wireless.band ipv4.method; do
          printf '  %-40s %s\n' "$k" "$(nmcli -g "$k" connection show "$T" 2>/dev/null)"
      done
      ;;

  restart-hotspot)
      systemctl restart --no-block mmdvm-multi
      sleep 4
      for i in 1 2 3 4 5 6; do
          [ -f "/etc/MMDVM-IQ-$i.ini" ] && systemctl restart --no-block "mmdvm-iq@$i"
      done
      sleep 5
      for i in 1 2 3 4 5 6; do
          [ -f "/etc/MMDVM-Host-$i.ini" ] && systemctl restart --no-block "mmdvmhost@$i"
      done
      for g in dmrgateway ircddbgatewayd ysfgateway p25gateway nxdngateway; do
          # ★ 不能用 try-restart：网关可能因 Requires 被连带停掉，停了就不会再拉起来
          systemctl restart --no-block "$g" 2>/dev/null || true
      done
      echo "已重启热点服务"
      ;;
  restart-web)  systemctl restart --no-block mmdvm-web; echo "已重启 Web 控制台" ;;
  reboot)       systemctl reboot ;;
  shutdown)     systemctl poweroff ;;
  timezone)     timedatectl set-timezone "${1:-Asia/Shanghai}" 2>&1 && echo "时区已设为 ${1:-Asia/Shanghai}" ;;
  ntp)          timedatectl set-ntp "${1:-true}" 2>&1 && echo "NTP=${1:-true}" ;;
  time-status)
      timedatectl status 2>/dev/null | sed 's/^ *//'
      echo "NTP=$(timedatectl show -p NTP --value 2>/dev/null)"
      echo "SYNCED=$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
      echo "TZ=$(timedatectl show -p Timezone --value 2>/dev/null)"
      echo "NOW=$(date '+%Y-%m-%d %H:%M:%S %Z')"
      if systemctl is-enabled mmdvm-sched-reboot.timer >/dev/null 2>&1; then
          echo "SCHED=on"
          echo "SCHED_CAL=$(systemctl show -p TimersCalendar --value mmdvm-sched-reboot.timer 2>/dev/null)"
          echo "SCHED_NEXT=$(systemctl show -p NextElapseUSecRealtime --value mmdvm-sched-reboot.timer 2>/dev/null)"
      else
          echo "SCHED=off"
      fi
      ;;
  ntp)
      case "${1:-on}" in on|true|1) timedatectl set-ntp true ;; *) timedatectl set-ntp false ;; esac
      sleep 1
      echo "NTP=$(timedatectl show -p NTP --value 2>/dev/null)"
      ;;
  timesync)
      systemctl restart systemd-timesyncd 2>/dev/null || true
      sleep 2
      echo "SYNCED=$(timedatectl show -p NTPSynchronized --value 2>/dev/null)"
      date '+NOW=%Y-%m-%d %H:%M:%S %Z'
      ;;
  tz)
      Z="${1:-}"
      [ -n "$Z" ] || { echo "缺少时区（例如 Asia/Shanghai）"; exit 2; }
      timedatectl set-timezone "$Z" && echo "时区已设为 $Z" && date '+NOW=%Y-%m-%d %H:%M:%S %Z'
      ;;
  sched-reboot)
      ON="${1:-off}"; HHMM="${2:-04:00}"; MODE="${3:-daily}"
      if [ "$ON" = "on" ]; then
          case "$MODE" in
            weekly) CAL="Mon *-*-* $HHMM:00" ;;
  hw-info)
      /usr/local/bin/hw-detect.sh hw-info
      ;;
  hw-force-max)
      /usr/local/bin/hw-detect.sh hw-force-max ${1:-0}
      ;;
            *)      CAL="*-*-* $HHMM:00" ;;
          esac
          printf '%s\n' '[Unit]' 'Description=Scheduled reboot for MMDVM hotspot' '[Service]' \
              'Type=oneshot' 'ExecStart=/sbin/shutdown -r +1 "scheduled reboot (web console)"' \
              > /etc/systemd/system/mmdvm-sched-reboot.service
          printf '%s\n' '[Unit]' 'Description=Scheduled reboot timer' '[Timer]' \
              "OnCalendar=$CAL" 'Persistent=true' '[Install]' 'WantedBy=timers.target' \
              > /etc/systemd/system/mmdvm-sched-reboot.timer
          systemctl daemon-reload
          systemctl enable --now mmdvm-sched-reboot.timer >/dev/null 2>&1
          echo "定时重启已开启：$CAL"
      else
          systemctl disable --now mmdvm-sched-reboot.timer >/dev/null 2>&1
          rm -f /etc/systemd/system/mmdvm-sched-reboot.timer /etc/systemd/system/mmdvm-sched-reboot.service
          systemctl daemon-reload 2>/dev/null || true
          echo "定时重启已关闭"
      fi
      ;;
  backup)
      if [ -x /usr/local/bin/backup.sh ]; then /usr/local/bin/backup.sh 2>&1 | tail -6
      else echo "backup.sh 不存在"; fi
      ;;
  logs)
      SVC="${1:-mmdvm-multi}"; N="${2:-60}"
      journalctl -u "$SVC" -n "$N" --no-pager 2>&1 | tail -n "$N"
      ;;
  ping)
      H="${1:-223.5.5.5}"
      ping -c 3 -W 2 "$H" 2>&1 | tail -4
      ;;
  sys-upgrade-count)
      echo "UPGRADABLE=$(_apt_count)"
      apt-get -s upgrade 2>/dev/null | grep '^Inst' | head -20 | sed 's/^/  /'
      ;;
  sys-update-check)
      echo "正在刷新软件源索引（apt-get update -qq，约 10-60 秒）…"
      timeout 150 apt-get update -qq 2>&1 | tail -6
      echo "UPGRADABLE=$(_apt_count)"
      echo "CHECKED_AT=$(date '+%F %T')"
      ;;
  sys-update)
      _job_start mmdvm-sysupdate "$JOB_LOG_DIR/mmdvm-sysupdate.log" \
        /bin/bash -c 'apt-get update && DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::=--force-confold upgrade && echo "--- 自动清理 ---" && apt-get -y autoremove && echo "===== 系统更新完成 ====="'
      ;;
  sys-update-status)
      _job_status mmdvm-sysupdate "$JOB_LOG_DIR/mmdvm-sysupdate.log"
      ;;
  fw-info)
      echo "CURRENT_VERSION=$(head -1 /etc/mmdvm/firmware-version 2>/dev/null || awk -F= '/^VERSION=/{print $2; exit}' /etc/mmdvm/install-state.conf 2>/dev/null || echo -)"
      echo "MANIFEST_URL=$(_fw_url)"
      echo "URL=$(_fw_url)"
      B=/usr/local/bin/MMDVM-Multi
      if [ -f "$B" ]; then
          echo "ENGINE_DATE=$(date -r "$B" '+%F %T')"
          echo "ENGINE_SIZE=$(stat -c %s "$B")"
          echo "ENGINE_MD5=$(md5sum "$B" | awk '{print $1}')"
      else
          echo "ENGINE_DATE=-"
      fi
      echo "ENGINE_SVC=$(systemctl is-active mmdvm-multi 2>/dev/null)"
      echo "ENGINE_SINCE=$(systemctl show -p ActiveEnterTimestamp --value mmdvm-multi 2>/dev/null)"
      ST=/etc/mmdvm/install-state.conf
      AM=/etc/mmdvm/active-mode.conf
      echo "CFG_VERSION=$(awk -F= '/^VERSION=/{print $2; exit}' "$ST" 2>/dev/null)"
      echo "CFG_CHANNELS=$(awk -F= '/^CHANNELS=/{print $2; exit}' "$AM" 2>/dev/null)"
      echo "CFG_MODE=$(awk -F= '/^MODE=/{print $2; exit}' "$AM" 2>/dev/null)"
      echo "CFG_CH_JSON=$(python3 -c "import json;d=json.load(open('/etc/mmdvm/channels.json'));print(d.get('channels') if isinstance(d,dict) else len(d))" 2>/dev/null || echo -)"
      echo "CFG_C_SOAPY=$(awk -F= '/^SOAPYSX_COMMIT=/{print substr($2,1,8); exit}' "$ST" 2>/dev/null)"
      echo "CFG_C_HOST=$(awk -F= '/^MMDVM_HOST_COMMIT=/{print substr($2,1,8); exit}' "$ST" 2>/dev/null)"
      echo "CFG_C_IQ=$(awk -F= '/^MMDVM_IQ_COMMIT=/{print substr($2,1,8); exit}' "$ST" 2>/dev/null)"
      echo "CFG_C_DMR=$(awk -F= '/^DMRGATEWAY_COMMIT=/{print substr($2,1,8); exit}' "$ST" 2>/dev/null)"
      echo "SRC_DIR=$(ls -d /opt/src-mmdvm/MMDVM-Multi 2>/dev/null || echo -)"
      ;;
  fw-url)
      if [ -n "${1:-}" ]; then
          mkdir -p "$(dirname "$FW_URL_FILE")"
          printf '%s\n' "$1" > "$FW_URL_FILE"
          echo "发布页地址已保存：$1"
      fi
      echo "URL=$(_fw_url)"
      ;;
  fw-check)
      /usr/local/bin/arch-fw-online.sh check "$(_fw_url)"
      ;;
  fw-online-update)
      U="$(_fw_url)"
      _job_start mmdvm-fwonline "$JOB_LOG_DIR/mmdvm-firmware.log" \
        /usr/local/bin/arch-fw-online.sh update "$U"
      ;;
  sys-update-clean)
      _job_start mmdvm-aptclean "$JOB_LOG_DIR/mmdvm-sysupdate.log" \
        /bin/bash -c 'apt-get -y autoremove; apt-get -y autoclean; apt-get -y clean; echo "===== 清理完成 ====="'
      ;;
  boot-fw-status)
      echo "--- 引导固件 (rpi-eeprom-update) ---"
      rpi-eeprom-update 2>&1 | head -12
      echo "--- 系统 ---"
      echo "MODEL=$(tr -d '\0' < /proc/device-tree/model 2>/dev/null)"
      echo "KERNEL=$(uname -r)"
      echo "OS=$(awk -F= '/^PRETTY_NAME/{gsub(/"/,"",$2); print $2}' /etc/os-release 2>/dev/null)"
      echo "BOOT_CONFIG_REBOOT=$( [ -f /var/run/reboot-required ] && echo yes || echo no )"
      ;;
  boot-fw-update)
      _job_start mmdvm-bootfw "$JOB_LOG_DIR/mmdvm-firmware.log" \
        /bin/bash -c 'echo "== 当前状态 =="; rpi-eeprom-update; echo; echo "== 应用更新 (rpi-eeprom-update -a) =="; rpi-eeprom-update -a; echo; echo "新引导固件将在下次重启后生效"; echo "===== 完成 ====="'
      ;;
  fw-download)
      U="${1:-}"
      [ -n "$U" ] || { echo "缺少下载地址"; exit 2; }
      _job_start mmdvm-fwdl "$JOB_LOG_DIR/mmdvm-firmware.log" \
        /bin/bash -c 'cd /tmp; rm -f mmdvm-fw.pkg; echo "下载地址: $1"; curl -L --retry 2 --connect-timeout 20 -m 900 -A "Mozilla/5.0" -o /tmp/mmdvm-fw.pkg -w "HTTP=%{http_code}  大小=%{size_download} 字节\n" "$1"; echo; file /tmp/mmdvm-fw.pkg; echo "===== 下载完成，可点「安装已下载的包」 ====="' _ "$U"
      ;;
  fw-install)
      F="${1:-/tmp/mmdvm-fw.pkg}"
      [ -f "$F" ] || { echo "找不到安装包：$F（先下载或上传）"; exit 2; }
      _job_start mmdvm-fwinst "$JOB_LOG_DIR/mmdvm-firmware.log" \
        /bin/bash -c '
F="$1"; D=/tmp/mmdvm-fw-extract
echo "安装包：$F"; file "$F"; ls -l "$F"
cp -a /usr/local/bin/MMDVM-Multi "/tmp/MMDVM-Multi.before.$(date +%Y%m%d-%H%M%S)" 2>/dev/null && echo "已备份当前引擎到 /tmp"
rm -rf "$D"; mkdir -p "$D"
case "$F" in
  *.deb)
      echo "== 使用 dpkg 安装 =="; dpkg -i "$F" 2>&1 | tail -25 ;;
  *.zip)
      echo "== 解压 zip =="; unzip -o "$F" -d "$D" >/dev/null 2>&1 || { echo "解压失败（unzip 报错）"; exit 1; } ;;
  hw-info)
      /usr/local/bin/hw-detect.sh hw-info
      ;;
  hw-force-max)
      /usr/local/bin/hw-detect.sh hw-force-max ${1:-0}
      ;;
  *)
      echo "== 解压 tar =="
      tar -xzf "$F" -C "$D" 2>/dev/null || tar -xf "$F" -C "$D" 2>/dev/null || { echo "无法识别的压缩包（支持 .tar.gz/.tgz/.tar/.zip/.deb）"; exit 1; } ;;
esac
echo "--- 包内文件（前 25 个）---"
find "$D" -type f 2>/dev/null | head -25
BIN=$(find "$D" -type f -name MMDVM-Multi | head -1)
SH=$(find "$D" -type f -name "*.sh" | head -1)
if [ -n "$BIN" ]; then
    echo "== 安装新引擎 =="
    install -m 755 "$BIN" /usr/local/bin/MMDVM-Multi && echo "已替换 /usr/local/bin/MMDVM-Multi"
    systemctl restart --no-block mmdvm-multi; sleep 5
    echo "mmdvm-multi 状态：$(systemctl is-active mmdvm-multi)"
    echo "新引擎 MD5：$(md5sum /usr/local/bin/MMDVM-Multi | cut -d" " -f1)"
elif [ -n "$SH" ]; then
    echo "== 执行包内脚本：$SH =="
    chmod +x "$SH"
    ( cd "$(dirname "$SH")" && bash "$(basename "$SH")" ) 2>&1 | tail -45
else
    echo "包内没有 MMDVM-Multi 可执行文件，也没有安装脚本；已解压到 $D ，请手动处理"
fi
echo "===== 固件更新流程结束 ====="
' _ "$F"
      ;;
  fw-log)
      L="$JOB_LOG_DIR/mmdvm-firmware.log"
      if [ -s "$L" ]; then tail -n "${1:-60}" "$L"; else echo "还没有固件更新日志"; fi
      ;;


  wifi-restore)
      BE="$(_wifi_backend)"
      if [ "$BE" != "nm" ]; then
          echo "当前后端是 $BE（wpa_supplicant 的配置本身就是持久化的），无需恢复"; exit 0
      fi
      _wifi_radio_on
      N=0; R=0
      while IFS="$(printf '\t')" read -r S P; do
          [ -n "${S:-}" ] || continue
          N=$((N+1))
          CN=$(_wifi_conn_of_ssid "$S")
          if [ -n "$CN" ]; then
              _wifi_fix_profile "$CN"
              echo "  已存在配置：$S（已设为自动连接）"
              continue
          fi
          if [ -n "${P:-}" ]; then nmcli dev wifi connect "$S" password "$P" >/dev/null 2>&1
          else nmcli dev wifi connect "$S" >/dev/null 2>&1; fi
          if [ $? -eq 0 ]; then
              _wifi_fix_profile "$(_wifi_conn_of_ssid "$S")"
              R=$((R+1)); echo "  已从备份重建：$S ✓"
          else
              echo "  重建失败（热点可能不在范围内）：$S"
          fi
      done <<EOF
$(_wpa_backup_list)
EOF
      echo "备份中的网络 $N 个，本次重建 $R 个"
      ;;
  wifi-boot)
      nmcli radio wifi on >/dev/null 2>&1 || true
      rfkill unblock wifi 2>/dev/null || true
      if [ -z "$(nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2=="802-11-wireless"')" ]; then
          "$0" wifi-restore >/dev/null 2>&1 || true
      fi
      CONN=$(nmcli -t -f NAME,TYPE connection show 2>/dev/null | awk -F: '$2=="802-11-wireless"{print $1; exit}')
      if [ -n "${CONN:-}" ]; then
          nmcli connection modify "$CONN" connection.autoconnect yes >/dev/null 2>&1 || true
          nmcli connection up "$CONN" >/dev/null 2>&1 || true
      fi
      IF="$(_wifi_iface)"
      echo "BACKEND=$(_wifi_backend)"
      echo "RADIO=$(_wifi_radio_state)"
      echo "PROFILES=$(_wifi_saved_count)"
      echo "SSID=$(nmcli -t -f IN-USE,SSID dev wifi 2>/dev/null | awk -F: '$1=="*"{print $2; exit}')"
      echo "IP=$(ip -4 -o addr show "$IF" 2>/dev/null | awk '{print $4}' | head -1)"
      ;;
  net-info)
      IF="${1:-eth0}"
      CN=$(nmcli -t -f NAME,DEVICE connection show --active 2>/dev/null | awk -F: -v d="$IF" '$2==d{print $1; exit}')
      [ -n "${CN:-}" ] || CN=$(nmcli -t -f NAME,DEVICE connection show 2>/dev/null | awk -F: -v d="$IF" '$2==d{print $1; exit}')
      A=$(nmcli -g ipv4.addresses connection show "$CN" 2>/dev/null | head -1)
      P="${A#*/}"; [ "$P" = "$A" ] && P=""
      echo "IFACE=$IF"
      echo "CONN=${CN:-}"
      echo "TYPE=$(nmcli -g connection.type connection show "$CN" 2>/dev/null)"
      echo "METHOD=$(nmcli -g ipv4.method connection show "$CN" 2>/dev/null)"
      echo "IP=${A%%/*}"
      echo "PREFIX=$P"
      [ -n "$P" ] && echo "MASK=$(_prefix2mask "$P")"
      echo "GW=$(nmcli -g ipv4.gateway connection show "$CN" 2>/dev/null)"
      echo "DNS=$(nmcli -g ipv4.dns connection show "$CN" 2>/dev/null | tr ' ' ',')"
      echo "AUTOCONNECT=$(nmcli -g connection.autoconnect connection show "$CN" 2>/dev/null)"
      echo "CURIP=$(ip -4 -o addr show "$IF" 2>/dev/null | awk '{print $4}' | head -1)"
      echo "CURGW=$(ip -4 route show default dev "$IF" 2>/dev/null | awk '{print $3; exit}')"
      echo "IFACES=$(nmcli -t -f DEVICE,TYPE dev status 2>/dev/null | awk -F: '$2=="ethernet"||$2=="wifi"{printf "%s(%s) ", $1, $2}')"
      ;;
  net-set)
      IF="${1:-}"; IP="${2:-}"; MASK="${3:-}"; GW="${4:-}"; DNS="${5:-}"; MODE="${6:-static}"
      [ -n "$IF" ] || { echo "缺少网卡名"; exit 2; }
      CN=$(nmcli -t -f NAME,DEVICE connection show 2>/dev/null | awk -F: -v d="$IF" '$2==d{print $1; exit}')
      [ -n "${CN:-}" ] || { echo "找不到网卡 $IF 的连接配置（先用界面连接该网络）"; exit 3; }
      if [ "$MODE" = "dhcp" ]; then
          nmcli connection modify "$CN" ipv4.method auto ipv4.addresses "" ipv4.gateway "" ipv4.dns "" 2>&1
          OUT=$(nmcli connection up "$CN" 2>&1 | tail -3)
          echo "已改为自动获取（DHCP）：$IF"
          echo "$OUT"
      else
          [ -n "$IP" ] || { echo "缺少 IP 地址"; exit 2; }
          P=$(_mask2prefix "$MASK")
          case "$P" in ''|*[!0-9]*) echo "子网掩码非法（示例 255.255.255.0 或 24）"; exit 2 ;; esac
          case "$IP" in *[!0-9.]*) echo "IP 非法（示例 192.168.7.60）"; exit 2 ;; esac
          nmcli connection modify "$CN" ipv4.method manual ipv4.addresses "$IP/$P" 2>&1
          if [ -n "$GW" ]; then
              nmcli connection modify "$CN" ipv4.gateway "$GW" 2>&1
          else
              nmcli connection modify "$CN" ipv4.gateway "" 2>&1
          fi
          if [ -n "$DNS" ]; then
              nmcli connection modify "$CN" ipv4.dns "$(printf '%s' "$DNS" | tr ',' ' ')" ipv4.ignore-auto-dns yes 2>&1
          else
              nmcli connection modify "$CN" ipv4.dns "" ipv4.ignore-auto-dns no 2>&1
          fi
          nmcli connection modify "$CN" connection.autoconnect yes 2>&1
          OUT=$(nmcli connection up "$CN" 2>&1 | tail -3)
          echo "已应用静态地址：$IF = $IP/$P  网关 $GW  DNS ${DNS:-（无）}"
          echo "$OUT"
          echo "注意：若 IP 与访问本页面的电脑不在同一网段，页面会立刻断开，请用新地址重新访问"
      fi
      echo "--- 当前状态 ---"
      echo "CURIP=$(ip -4 -o addr show "$IF" 2>/dev/null | awk '{print $4}' | head -1)"
      echo "CURGW=$(ip -4 route show default dev "$IF" 2>/dev/null | awk '{print $3; exit}')"
      ;;
  fix-units)
      # \u2605 \u7edf\u4e00\u4fee\u6b63\u7f51\u5173\u5355\u5143\uff1a\u5f00\u673a\u7b49\u7f51\u7edc\u5c31\u7eea + \u65ad\u7ebf\u65e0\u9650\u91cd\u8bd5\uff08\u907f\u514d\u300c\u91cd\u542f\u540e\u4e0d\u81ea\u52a8\u8fde\u670d\u52a1\u5668\u300d\uff09
      W=/etc/systemd/system
      mkdir -p "$W"
      for x in dmrgateway:DMRGateway:DMRGateway.ini \
               ysfgateway:YSFGateway:YSFGateway.ini \
               p25gateway:P25Gateway:P25Gateway.ini \
               nxdngateway:NXDNGateway:NXDNGateway.ini; do
          U="${x%%:*}"; rest="${x#*:}"; B="${rest%%:*}"; I="${rest#*:}"
          cat > "$W/$U.service" <<UNIT
[Unit]
Description=$B Service
After=network-online.target network.target mmdvm-multi.service
Wants=network-online.target mmdvm-multi.service
[Service]
Type=simple
ExecStart=/usr/local/bin/$B /etc/$I
Restart=always
RestartSec=10
User=root
[Install]
WantedBy=multi-user.target
UNIT
          chmod 644 "$W/$U.service"
      done
      if [ -f "$W/ircddbgatewayd.service" ]; then
          sed -i 's/^Restart=.*/Restart=always/' "$W/ircddbgatewayd.service"
          grep -q '^RestartSec=' "$W/ircddbgatewayd.service" || sed -i '/^Restart=always/a RestartSec=10' "$W/ircddbgatewayd.service"
      fi
      cat > "$W/mmdvm-wifi-boot.service" <<'UNITW'
[Unit]
Description=MMDVM WiFi auto-connect at boot
After=NetworkManager.service network-online.target
Wants=NetworkManager.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/sys-manage.sh wifi-boot
[Install]
WantedBy=multi-user.target
UNITW
      chmod 644 "$W/mmdvm-wifi-boot.service"
      systemctl daemon-reload
      systemctl enable mmdvm-wifi-boot.service >/dev/null 2>&1 || true
      for g in dmrgateway ysfgateway p25gateway nxdngateway ircddbgatewayd; do
          systemctl is-enabled "$g" >/dev/null 2>&1 || systemctl enable "$g" >/dev/null 2>&1 || true
      done
      echo "已修正：网关单元（开机自动重连 + 断线重试）与 WiFi 开机自检单元"
      echo "--- 当前单元 ---"
      for g in dmrgateway ysfgateway p25gateway nxdngateway ircddbgatewayd mmdvm-wifi-boot; do
          printf '  %-18s %-9s After=%s\n' "$g" "$(systemctl is-active $g 2>/dev/null)" \
              "$(systemctl show -p After --value $g 2>/dev/null | tr ' ' ',' | cut -c1-70)"
      done
      ;;
  hw-info)
      /usr/local/bin/hw-detect.sh hw-info
      ;;
  hw-force-max)
      /usr/local/bin/hw-detect.sh hw-force-max ${1:-0}
      ;;
  *) echo "用法: sys-manage.sh {info|net|wifi-list|wifi-connect <SSID> [密码]|wifi-disconnect|wifi-forget <连接名>|wifi-reconnect <连接名>|wifi-radio <on|off>|wifi-country <CN>|wifi-priority <名> <n>|wifi-autoconnect <名> <yes|no>|wifi-info <名>|wifi-status|wifi-saved|restart-hotspot|restart-web|reboot|shutdown|time-status|ntp <on|off>|timesync|tz <Asia/Shanghai>|sched-reboot <on|off> [HH:MM] [daily|weekly]|backup|logs <服务> [行数]|ping <主机>|net-info <网卡>|net-set <网卡> <IP> <掩码> <网关> <DNS> <static|dhcp>|wifi-restore|wifi-boot|fix-units|sys-upgrade-count|sys-update-check|sys-update|sys-update-status|sys-update-clean|boot-fw-status|boot-fw-update|fw-info|fw-url [地址]|fw-check|fw-online-update|fw-log [行数]}"; exit 2 ;;
esac
```

### `usr/local/bin/update-database.sh`

```bash
#!/bin/bash
# ARCH database updater: our Cloudflare/ARCH mirror is authoritative.
set -Eeuo pipefail
TYPE="${1:-dmrid}"
MIRROR="${ARCH_MIRROR_BASE:-https://arch.hljrsa.cn/downloads/arch-hotspot/data}"
PISTAR="${ARCH_PISTAR_BASE:-https://www.pistar.uk/downloads}"
LOCK_HELD=0
if [ "${MMDVM_INTERNAL:-0}" != "1" ]; then
  exec 9>/run/mmdvm-control.lock
  flock -w 120 9 || { echo "无法获取控制锁"; exit 1; }
  LOCK_HELD=1
fi
trap '[ "$LOCK_HELD" -eq 1 ] && flock -u 9 2>/dev/null || true' EXIT

atomic_url() {
  local url="$1" dst="$2" validator="${3:-}" tmp="${2}.new"
  curl -fsSL --retry 2 --connect-timeout 15 --max-time 180 -A 'ARCH-Hotspot/1.0' "$url" -o "$tmp" 2>/dev/null || \
    curl -kfsSL --retry 2 --connect-timeout 15 --max-time 180 -A 'ARCH-Hotspot/1.0' "$url" -o "$tmp" 2>/dev/null || { rm -f "$tmp"; return 1; }
  [ -s "$tmp" ] || { rm -f "$tmp"; return 1; }
  if [ -n "$validator" ]; then eval "$validator '$tmp'" || { rm -f "$tmp"; return 1; }; fi
  mv -f "$tmp" "$dst"
}

from_mirror_or_upstream() {
  local rel="$1" dst="$2" validator="${3:-}" url
  for url in "$MIRROR/$rel" "$PISTAR/$rel"; do
    atomic_url "$url" "$dst" "$validator" && return 0
  done
  return 1
}

ensure_dirs() { mkdir -p /usr/local/etc /usr/local/etc/hosts /usr/local/etc/private; }

case "$TYPE" in
 dmrid)
   ensure_dirs
   if from_mirror_or_upstream DMRIds.dat /usr/local/etc/DMRIds.dat "test -s"; then
      cp -f /usr/local/etc/DMRIds.dat /usr/local/etc/hosts/DMRIds.dat
      head -2 /usr/local/etc/DMRIds.dat > /usr/local/etc/DMRIds-min.dat 2>/dev/null || true
      echo "DMR ID 更新成功（ARCH 镜像）"; exit 0
   fi
   if [ -x /usr/local/bin/DMRIDUpdate.sh ] && /usr/local/bin/DMRIDUpdate.sh && [ -s /usr/local/etc/DMRIds.dat ]; then
      cp -f /usr/local/etc/DMRIds.dat /usr/local/etc/hosts/DMRIds.dat
      echo "DMR ID 更新成功"; exit 0
   fi
   echo "DMR ID 更新失败"; exit 1
   ;;
 ysfhosts)
   ensure_dirs
   if from_mirror_or_upstream YSFHosts.json /usr/local/etc/YSFHosts.json "python3 -m json.tool >/dev/null"; then
      cp -f /usr/local/etc/YSFHosts.json /usr/local/etc/hosts/YSFHosts.json
      echo "YSF Hosts 更新成功（ARCH 镜像）"; exit 0
   fi
   echo "YSF Hosts 更新失败"; exit 1
   ;;
 p25hosts)
   ensure_dirs
   if from_mirror_or_upstream P25_Hosts.txt /usr/local/etc/hosts/P25_Hosts.txt "grep -Eq '^[0-9]+[[:space:]]'"; then
      python3 /usr/local/libexec/hosts-build.py --p25-only >/dev/null
      echo "P25 Hosts 更新成功（ARCH 镜像）"; exit 0
   fi
   echo "P25 Hosts 更新失败"; exit 1
   ;;
 nxdnhosts)
   ensure_dirs
   ok=0
   from_mirror_or_upstream NXDNHosts.json /usr/local/etc/NXDNHosts.json "python3 -m json.tool >/dev/null" && ok=1
   from_mirror_or_upstream NXDN.csv /usr/local/etc/NXDN.csv "grep -q '^'" && ok=1
   cp -f /usr/local/etc/NXDNHosts.json /usr/local/etc/hosts/NXDNHosts.json 2>/dev/null || true
   cp -f /usr/local/etc/NXDN.csv /usr/local/etc/hosts/NXDN.csv 2>/dev/null || true
   [ "$ok" -eq 1 ] && { echo "NXDN Hosts/ID 更新成功（ARCH 镜像）"; exit 0; }
   echo "NXDN Hosts/ID 更新失败"; exit 1
   ;;
 dstarhosts)
   ensure_dirs
   from_mirror_or_upstream DSTAR_Reflectors.json /usr/local/etc/hosts/DSTAR_Reflectors.json "python3 -m json.tool >/dev/null" || true
   cp -f /usr/local/etc/hosts/DSTAR_Reflectors.json /usr/local/etc/DSTAR_Reflectors.json 2>/dev/null || true
   for f in DCS_Hosts.txt DExtra_Hosts.txt DPlus_Hosts.txt XLX_Hosts.txt CCS_Hosts.txt FCS_Hosts.txt DStar_Hosts.json; do
      from_mirror_or_upstream "$f" "/usr/local/etc/hosts/$f" "" || true
   done
   echo "D-STAR Hosts 更新完成（ARCH 镜像）"
   ;;
 dmrhosts)
   ensure_dirs
   ok=0
   from_mirror_or_upstream DMR_Hosts.txt /usr/local/etc/hosts/DMR_Hosts.txt "" && ok=1
   from_mirror_or_upstream DMR_Hosts.json /usr/local/etc/hosts/DMR_Hosts.json "python3 -m json.tool >/dev/null" || true
   python3 /usr/local/libexec/hosts-build.py >/dev/null 2>&1 || true
   if [ "$ok" -eq 1 ]; then echo "DMR Hosts 更新成功（ARCH 镜像）"; exit 0; fi
   echo "DMR Hosts 更新失败"; exit 1
   ;;
 hosts)
   fail=0
   MMDVM_INTERNAL=1 "$0" dmrhosts || fail=$((fail+1))
   MMDVM_INTERNAL=1 "$0" ysfhosts || fail=$((fail+1))
   MMDVM_INTERNAL=1 "$0" p25hosts || fail=$((fail+1))
   MMDVM_INTERNAL=1 "$0" nxdnhosts || fail=$((fail+1))
   MMDVM_INTERNAL=1 "$0" dstarhosts || fail=$((fail+1))
   [ "$fail" -eq 0 ] && echo "Hosts 全量更新成功（ARCH 镜像）" || { echo "Hosts 有 $fail 项更新失败"; exit 1; }
   ;;
 *) echo "用法: $0 {dmrid|dmrhosts|ysfhosts|p25hosts|nxdnhosts|dstarhosts|hosts}"; exit 2;;
esac
```

### `usr/local/bin/update-dstar.sh`

```bash
#!/bin/bash
# ARCH D-STAR host updater: fetch the prebuilt ARCH mirror files.
set -Eeuo pipefail
HOSTS=/usr/local/etc/hosts
BASE="${ARCH_MIRROR_BASE:-https://arch.hljrsa.cn/downloads/arch-hotspot/data}"
mkdir -p "$HOSTS"
echo "=== Updating D-STAR host lists ==="
for f in DCS_Hosts.txt DExtra_Hosts.txt DPlus_Hosts.txt XLX_Hosts.txt CCS_Hosts.txt FCS_Hosts.txt DStar_Hosts.json DSTAR_Reflectors.json; do
  tmp="$HOSTS/$f.new"
  if curl -fsSL --retry 2 --connect-timeout 15 --max-time 180 -A 'ARCH-Hotspot/1.0' "$BASE/$f" -o "$tmp" 2>/dev/null || \
     curl -kfsSL --retry 2 --connect-timeout 15 --max-time 180 -A 'ARCH-Hotspot/1.0' "$BASE/$f" -o "$tmp" 2>/dev/null; then
      [ -s "$tmp" ] && mv -f "$tmp" "$HOSTS/$f" || rm -f "$tmp"
  else
      rm -f "$tmp"
      echo "  下载失败: $f"
  fi
done
[ -s "$HOSTS/DSTAR_Reflectors.json" ] && cp -f "$HOSTS/DSTAR_Reflectors.json" /usr/local/etc/DSTAR_Reflectors.json
chmod 644 "$HOSTS"/DCS_Hosts.txt "$HOSTS"/DExtra_Hosts.txt "$HOSTS"/DPlus_Hosts.txt "$HOSTS"/XLX_Hosts.txt "$HOSTS"/CCS_Hosts.txt "$HOSTS"/FCS_Hosts.txt "$HOSTS"/DSTAR_Reflectors.json 2>/dev/null || true
echo "=== D-STAR hosts update complete ==="
```

### `usr/local/bin/update-hosts.sh`

```bash
#!/bin/bash
# ARCH / Pi-Star 主机库与 ID 数据库更新器：ARCH 镜像优先，Pi-Star 仅作回退。
set -Eeuo pipefail
MIRROR="${ARCH_MIRROR_BASE:-https://arch.hljrsa.cn/downloads/arch-hotspot/data}"
FALLBACK="${ARCH_PISTAR_BASE:-https://www.pistar.uk/downloads}"
DEST="/usr/local/etc/hosts"
ETC="/usr/local/etc"
mkdir -p "$DEST" "$ETC"
cd "$DEST"

fetch() {
    local rel="$1" dst="$2" min="${3:-100}" base url sz i
    for base in "$MIRROR" "$FALLBACK"; do
        url="${base%/}/$rel"
        for i in 1 2 3; do
            if curl -fsSL --retry 1 --connect-timeout 15 --max-time 180 -A 'ARCH-Hotspot/1.0' "$url" -o "$dst.tmp" 2>/dev/null ||                curl -kfsSL --retry 1 --connect-timeout 15 --max-time 180 -A 'ARCH-Hotspot/1.0' "$url" -o "$dst.tmp" 2>/dev/null; then
                sz=$(stat -c%s "$dst.tmp" 2>/dev/null || echo 0)
                if [ "$sz" -ge "$min" ]; then mv -f "$dst.tmp" "$dst"; return 0; fi
            fi
            rm -f "$dst.tmp"; sleep 2
        done
    done
    echo "  下载失败: $rel"
    return 1
}

OK=0; FAIL=0
run(){ if fetch "$1" "$2" "${3:-100}"; then OK=$((OK+1)); else FAIL=$((FAIL+1)); fi; }

run DMRIds.dat           DMRIds.dat           100000
run DMR_Hosts.txt        DMR_Hosts.txt
run DMR_Hosts.json       DMR_Hosts.json
run XLX_Hosts.txt        XLX_Hosts.txt
run DPlus_Hosts.txt      DPlus_Hosts.txt
run DExtra_Hosts.txt     DExtra_Hosts.txt
run DCS_Hosts.txt        DCS_Hosts.txt
run CCS_Hosts.txt        CCS_Hosts.txt
run FCS_Hosts.txt        FCS_Hosts.txt
run FCSHosts.json        FCSHosts.json
run YSF_Hosts.txt        YSF_Hosts.txt
run YSFHosts.json        YSFHosts.json
run NXDN_Hosts.txt       NXDN_Hosts.txt
run NXDNHosts.json       NXDNHosts.json
run NXDN.csv             NXDN.csv
run P25_Hosts.txt        P25_Hosts.txt
run M17_Hosts.txt        M17_Hosts.txt
run APRS_Hosts.txt       APRS_Hosts.txt
run DStar_Hosts.json     DStar_Hosts.json
run DSTAR_Reflectors.json DSTAR_Reflectors.json

# 生成/校正 Web 控制台和网关共用的 JSON。
python3 /usr/local/libexec/hosts-build.py -v || FAIL=$((FAIL+1))

# DMR ID 数据库的运行时路径。
if [ -s "$DEST/DMRIds.dat" ]; then cp -f "$DEST/DMRIds.dat" "$ETC/DMRIds.dat"; fi
if [ -s "$ETC/DMRIds.dat" ]; then head -2 "$ETC/DMRIds.dat" > "$ETC/DMRIds-min.dat" 2>/dev/null || true; fi

# 网关实际读取的 JSON/文本路径。
for f in DMR_Hosts.json YSFHosts.json NXDNHosts.json FCSHosts.json DSTAR_Reflectors.json hosts-index.json; do
    [ -s "$DEST/$f" ] && cp -f "$DEST/$f" "$ETC/$f"
done
for f in NXDNHosts.txt; do
    [ -s "$DEST/$f" ] && cp -f "$DEST/$f" "$ETC/$f"
done
[ -s "$DEST/NXDN.csv" ] && cp -f "$DEST/NXDN.csv" "$ETC/NXDN.csv"
[ -s "$DEST/DStar_Hosts.json" ] && cp -f "$DEST/DStar_Hosts.json" "$ETC/DStar_Hosts.json"

rm -f "$DEST"/*.tmp 2>/dev/null || true
chmod 644 "$DEST"/* 2>/dev/null || true
chmod 644 "$ETC"/*Hosts* "$ETC"/DMRIds*.dat "$ETC"/NXDN.csv "$ETC"/DSTAR_Reflectors.json 2>/dev/null || true
chown -R mmdvm-web:mmdvm-web "$DEST" 2>/dev/null || true
echo "ARCH 镜像更新完成：成功 $OK / 失败 $FAIL"
exit 0
```

### `usr/local/bin/web-fmo.sh`

```bash
#!/bin/bash
# web-fmo.sh —— FMO 桥接后端入口（Web 控制台调用，需 root）
set -Eeuo pipefail
exec python3 /usr/local/libexec/fmo-api.py "$@"
```

### `usr/local/bin/web-manage.sh`

```bash
#!/bin/bash
# MMDVM-IQ web-manage.sh V20.2
# status 分支改为 Python 内嵌，避免 set -e 导致脚本提前退出
set -u

ACTION="${1:-}"

case "$ACTION" in
    status)
        exec /usr/local/libexec/mmdvm-status.py
        ;;

    status-quick)
        exec /usr/local/libexec/mmdvm-status.py quick
        ;;

    qso)
        # ★ 轻量：只输出通话记录（Live QSO 1 秒轮询用）
        exec /usr/local/libexec/mmdvm-status.py qso
        ;;

    ports)
        for p in 3334 3335 62031 62032 3200 4200 32010 42020 14020 14021 20010 20011; do
            if ss -lun 2>/dev/null | grep -qE ":$p\b"; then
                proc=$(ss -lunp 2>/dev/null | grep -E ":$p\b" | grep -oE 'users:\(\("[^"]+"' | sed 's/users:(("//;s/"//' | head -1 2>/dev/null || echo "未知")
                echo "$p|${proc:-未知}|LISTEN"
            else
                echo "$p|-|CLOSED"
            fi
        done
        ;;

    switch)
        MODE="${2:-}"
        case "$MODE" in
            DMR|D-STAR|YSF|P25|NXDN|FM|POCSAG) ;;
            *) echo "无效模式: $MODE" >&2; exit 2 ;;
        esac
        # ★ 多信道并行：启用某模式时绝不关闭其它通道。
        #   1) 规划里已分配该模式 -> 重载那条通道
        #   2) 规划里没有该模式 -> 占用一条空闲通道并加入运行路数
        #   3) 4 路都在跑 -> 提示用户到 Multi 页选择替换哪条通道
        python3 - "$MODE" <<'PYSW'
import json, os, subprocess, sys

MODE = sys.argv[1].upper()
PLAN = "/etc/mmdvm/channels.json"
CHSETUP = "/usr/local/libexec/channel-setup.py"
CHMODESH = "/usr/local/bin/channel-mode.sh"

def done(msg, code=0):
    print(msg)
    sys.exit(code)

if not os.path.exists(PLAN):
    done("规划文件不存在，请先运行 install-multi.sh", 1)
try:
    with open(PLAN, encoding="utf-8") as f:
        plan = json.load(f)
except Exception as e:
    done("规划文件读取失败: %s" % e, 1)

lst = plan.get("list") or []
try:
    r = subprocess.run(["/usr/local/libexec/arch-channel-limit.py"],
                       capture_output=True, text=True, timeout=8)
    hw_limit = max(1, min(7, int((r.stdout or "7").strip())))
except Exception:
    hw_limit = 7
running = max(1, min(hw_limit, int(plan.get("channels") or 1)))
plan["channels"] = running

def save_and_apply():
    with open(PLAN, "w", encoding="utf-8") as f:
        json.dump(plan, f, ensure_ascii=False, indent=2)
    if os.path.exists(CHSETUP):
        return subprocess.run([CHSETUP, "apply", json.dumps(plan)]).returncode == 0
    return False

def active_items():
    out = []
    for c in lst:
        try:
            n = int(c.get("n", 0))
        except Exception:
            continue
        if 1 <= n <= running:
            out.append(c)
    return out

active = active_items()

# 1) 当前已运行的通道里已经有这个模式，只重载该通道。
same = [c for c in active if str(c.get("mode", "")).upper() == MODE]
if same:
    tgt = int(same[0].get("n", 1))
    if os.path.exists(CHMODESH):
        rc = subprocess.run([CHMODESH, str(tgt), MODE]).returncode
        done("通道 %d 已重载 %s" % (tgt, MODE), 0 if rc == 0 else 1)
    done("找不到 channel-mode.sh", 1)

# 2) 单通道模式：切换就是替换当前这一路的模式，绝不阻挡。
if running == 1:
    cur = active[0] if active else None
    if cur is None:
        cur = next((c for c in lst if int(c.get("n", 0)) == 1), None)
    if cur is None:
        cur = {"n": 1, "mode": MODE,
               "rx": int(plan.get("center_rx", 434000000)),
               "tx": int(plan.get("center_tx", 439500000))}
        lst.append(cur)
        plan["list"] = lst
    cur["mode"] = MODE
    plan["channels"] = 1
    if save_and_apply():
        done("单通道已切换为 %s" % MODE)
    done("单通道切换失败，请查看 channel-setup 日志", 1)

# 3) 多通道模式：只在正在运行的通道里找空闲；历史未启用通道不算占用。
free = [c for c in active if str(c.get("mode", "NONE")).upper() in ("", "NONE")]
if not free:
    used = "/".join(str(c.get("mode", "NONE")) for c in active) or "NONE"
    done("当前 %d 路射频都在用（%s）。请先把一条运行中的通道设为 NONE，再切换 %s。"
         "未启用的历史通道不会计入占用。" % (running, used, MODE), 1)
cur = free[0]
cur["mode"] = MODE
plan["channels"] = running
if save_and_apply():
    done("已在通道 %d 启用 %s（当前 %d 路）" % (int(cur.get("n", 1)), MODE, running))
done("配置已写入，但应用失败，请查看 channel-setup 日志", 1)
PYSW
        ;;

    get-config)
        MODE="${2:-}"
        python3 - "$MODE" <<'PYCFG'
import sys, os
mode = sys.argv[1] if len(sys.argv) > 1 else ""

def read_ini(path):
    out = {}
    if not os.path.exists(path): return out
    current = None
    with open(path, encoding="utf-8", errors="replace") as f:
        for line in f:
            s = line.rstrip("\n").strip()
            if not s or s.startswith(";") or s.startswith("#"): continue
            if s.startswith("[") and s.endswith("]"):
                current = s[1:-1]
                if current not in out: out[current] = {}
                continue
            if "=" in s and current is not None:
                k, v = s.split("=", 1)
                out[current][k.strip()] = v.strip()
    return out

def read_flat(path):
    out = {}
    if not os.path.exists(path): return out
    with open(path, encoding="utf-8", errors="replace") as f:
        for line in f:
            s = line.strip()
            if s and not s.startswith("#") and "=" in s:
                k, v = s.split("=", 1)
                out[k.strip()] = v.strip()
    return out

def get_sec(ini, sec):
    for k, v in ini.items():
        if k.lower() == sec.lower(): return v
    return {}

def emit(section, d, keys):
    print(f"=== {section} ===")
    for k in keys:
        if k in d: print(f"{k}={d[k]}")

host = read_ini("/etc/MMDVM-Host.ini")
g = get_sec(host, "General")
m = get_sec(host, "Modem")
info = get_sec(host, "Info")

if mode == "RF":
    emit("MMDVM_HOST", {**g, **m, **info},
         ["Callsign","Id","RXFrequency","TXFrequency","Duplex","Power"])
    iq = read_ini("/etc/MMDVM-IQ.ini")
    emit("MMDVM_IQ", get_sec(iq, "Soapy"), ["RxGain","TxGain","RxInvert","TxInvert"])
elif mode == "DMR":
    # ★ 多通道：DMR 不在通道 1 时，必须读实际 DMR 通道的 Host 配置
    _dmr_host = host
    try:
        import json as _json
        with open("/etc/mmdvm/channels.json", encoding="utf-8") as _f:
            _plan = _json.load(_f)
        _dmr_item = next((x for x in _plan.get("list", [])
                          if str(x.get("mode", "")).upper() == "DMR"), None)
        if _dmr_item:
            _dmr_host = read_ini("/etc/MMDVM-Host-%d.ini" % int(_dmr_item.get("n", 1)))
    except Exception:
        pass
    g = get_sec(_dmr_host, "General")
    dmr = get_sec(_dmr_host, "DMR")
    emit("MMDVM_HOST", {**g, **m, **info, **dmr},
         ["Callsign","Id","RXFrequency","TXFrequency","ColorCode","SelfOnly","CallHang","TXHang"])
    emit("DMR_NETWORK", get_sec(_dmr_host, "DMR Network"),
         ["Enable","GatewayAddress","GatewayPort","LocalAddress","LocalPort","Jitter","Slot1","Slot2"])
    gw = read_ini("/etc/DMRGateway.ini")
    _dmr_secs = [k for k in gw if k.lower().startswith("dmr network")]
    _sec = _dmr_secs[0] if _dmr_secs else "DMR Network 1"
    for _k in _dmr_secs:
        _d = get_sec(gw, _k)
        _blob = (str(_d.get("Name", "")) + " " + str(_d.get("Address", ""))).lower()
        if "brandmeister" in _blob:
            _sec = _k
            break
    else:
        if len(_dmr_secs) > 1:
            _sec = _dmr_secs[1]
    emit("DMRGATEWAY", get_sec(gw, _sec),
         ["Name","Id","Address","Port","Password","Enabled"])
elif mode == "D-STAR":
    emit("MMDVM_HOST", g, ["Callsign"])
    emit("MMDVM_HOST_DSTAR",
         {**get_sec(host, "D-Star"), **get_sec(host, "D-Star Network")},
         ["Module","SelfOnly","GatewayAddress","GatewayPort","LocalPort"])
    emit("IRCDDBGATEWAY", read_flat("/etc/ircddbgateway"),
         ["gatewayCallsign","repeaterCall1","repeaterBand1","ircddbUsername","ircddbEnabled",
          "ircddbHostname","reflector1","atStartup1","dplusEnabled","dextraEnabled",
          "dcsEnabled","ccsEnabled","xlxEnabled"])
elif mode == "YSF":
    emit("MMDVM_HOST", g, ["Callsign"])
    emit("MMDVM_HOST_YSF",
         {**get_sec(host, "System Fusion"), **get_sec(host, "System Fusion Network")},
         ["LoDev","SelfOnly","GatewayAddress","GatewayPort","LocalPort"])
    y = read_ini("/etc/YSFGateway.ini")
    emit("YSFGATEWAY",
         {**get_sec(y, "General"), **get_sec(y, "Network"), **get_sec(y, "YSF Network")},
         ["Callsign","Suffix","Id","RptAddress","RptPort","LocalAddress","LocalPort",
          "WiresXCommandPassthrough","WiresXMakeUpper","Startup","Port"])
elif mode == "P25":
    emit("MMDVM_HOST", g, ["Callsign"])
    emit("MMDVM_HOST_P25",
         {**get_sec(host, "P25"), **get_sec(host, "P25 Network")},
         ["SelfOnly","NAC","GatewayAddress","GatewayPort","LocalPort"])
    p = read_ini("/etc/P25Gateway.ini")
    emit("P25GATEWAY",
         {**get_sec(p, "General"), **get_sec(p, "Network")},
         ["Callsign","RptAddress","RptPort","LocalPort","Port","Static","RFHangTime","NetHangTime"])
elif mode == "NXDN":
    nxdn0 = read_ini("/etc/NXDNGateway.ini")
    emit("MMDVM_HOST", {**g, "Callsign": get_sec(nxdn0, "General").get("Callsign", g.get("Callsign","")), "Id": get_sec(nxdn0, "General").get("Id", g.get("Id",""))}, ["Callsign","Id"])
    emit("MMDVM_HOST_NXDN",
         {**get_sec(host, "NXDN"), **get_sec(host, "NXDN Network")},
         ["SelfOnly","RAN","Protocol","GatewayAddress","GatewayPort","LocalPort"])
    n = read_ini("/etc/NXDNGateway.ini")
    emit("NXDNGATEWAY",
         {**get_sec(n, "General"), **get_sec(n, "Network")},
         ["Callsign","RptProtocol","RptAddress","RptPort","LocalPort","Port","Static"])
elif mode == "FM":
    emit("MMDVM_HOST", g, ["Callsign"])
    emit("MMDVM_HOST_FM", get_sec(host, "FM"),
         ["Enable","CTCSSFrequency","CTCSSHighThreshold","CTCSSLowThreshold",
          "CTCSSLevel","KerchunkTime","HangTime","AccessMode"])
elif mode == "POCSAG":
    emit("MMDVM_HOST", g, ["Callsign"])
    emit("MMDVM_HOST_POCSAG", get_sec(host, "POCSAG"), ["Enable","Frequency"])
PYCFG
        ;;

    save-rf|save-dmr|save-ysf|save-p25|save-nxdn|save-dstar|save-fm|save-pocsag)
        MODE_UPPER="${ACTION#save-}"
        case "$MODE_UPPER" in
            rf) MODE_ARG="RF" ;;
            dmr) MODE_ARG="DMR" ;;
            ysf) MODE_ARG="YSF" ;;
            p25) MODE_ARG="P25" ;;
            nxdn) MODE_ARG="NXDN" ;;
            dstar) MODE_ARG="D-STAR" ;;
            fm) MODE_ARG="FM" ;;
            pocsag) MODE_ARG="POCSAG" ;;
        esac
        exec /usr/local/libexec/mmdvm-save.py "$MODE_ARG" "${@:2}"
        ;;

    logs)
        SVC="${2:-mmdvmhost}"
        case "$SVC" in
            mmdvm-iq|mmdvmhost|dmrgateway|ysfgateway|p25gateway|nxdngateway|ircddbgatewayd|mosquitto|mmdvm-web|mmdvm-multi|tetra-bs|fmo-bridge) ;;
            *) echo "无效服务" >&2; exit 2 ;;
        esac
        journalctl -u "$SVC.service" -n "${3:-80}" --no-pager 2>&1 || echo "无法获取 $SVC 日志"
        ;;

    backup) exec /usr/local/bin/backup.sh ;;
    diagnose) exec /usr/local/bin/diagnose.sh ;;

    update-db)
        DB="${2:-dmrid}"
        if [ "$DB" = "dmrid" ]; then
            MMDVM_INTERNAL=1 exec /usr/local/bin/update-database.sh dmrid 9>&-
        elif [ "$DB" = "hosts" ]; then
            MMDVM_INTERNAL=1 exec /usr/local/bin/update-database.sh hosts 9>&-
        elif [ "$DB" = "all" ]; then
            MMDVM_INTERNAL=1 /usr/local/bin/update-database.sh hosts 9>&- 2>&1 || true
            MMDVM_INTERNAL=1 /usr/local/bin/update-database.sh dmrid 9>&- 2>&1 || true
            echo "全量同步完成"
        else
            exec /usr/local/bin/update-database.sh "$DB" 9>&-
        fi
        ;;

    bm-list)
        cat /etc/mmdvm/bm-masters.json 2>/dev/null || echo "[]"
        ;;

    bm-test)
        HOST="${2:-}"
        [ -n "$HOST" ] || { echo "需要指定主机" >&2; exit 2; }
        python3 - "$HOST" <<'PYBM'
import sys, socket, subprocess, re, json
host = sys.argv[1]
r = {"host": host, "dns": False, "loss": "N/A", "rtt": "N/A"}
try: r["dns"] = bool(socket.getaddrinfo(host, None))
except: pass
try:
    p = subprocess.run(["ping","-c","4","-W","1",host],
                       capture_output=True, text=True, timeout=8)
    t = p.stdout + p.stderr
    m = re.search(r"([0-9]+(?:\.[0-9]+)?)% packet loss", t)
    if m: r["loss"] = f"{m.group(1)}%"
    m = re.search(r"(?:=|,)\s*[\d.]+/([\d.]+)/", t)
    if m: r["rtt"] = f"{m.group(1)} ms"
except: pass
print(json.dumps(r))
PYBM
        ;;

    restart)
        # 多信道架构：mmdvmhost / mmdvm-iq 是实例化单元 mmdvmhost@N / mmdvm-iq@N
        rest_channels(){
            local base="$1" n
            for n in 1 2 3 4 5; do
                [ -f "/etc/MMDVM-Host-$n.ini" ] || continue
                systemctl restart --no-block "$base@$n.service"
            done
        }
        case "${2:-}" in
            all)
                systemctl restart --no-block mmdvm-multi.service
                rest_channels mmdvm-iq
                rest_channels mmdvmhost
                systemctl restart --no-block dmrgateway.service ;;
            mmdvmhost)  rest_channels mmdvmhost ;;
            mmdvm-iq)   rest_channels mmdvm-iq ;;
            mmdvm-multi|dmrgateway|ysfgateway|p25gateway|nxdngateway|ircddbgatewayd)
                systemctl restart --no-block "$2.service" ;;
            *) echo "无效服务" >&2; exit 2 ;;
        esac
        echo "已提交重启（后台执行）"
        ;;

    *)
        echo "用法: $0 {status|ports|switch|get-config|save-XXX|logs|backup|diagnose|update-db|bm-list|bm-test|restart}"
        exit 2 ;;
esac
```

### `usr/local/bin/web-multi.sh`

```bash
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import configparser, json, os, subprocess, sys
PLAN="/etc/mmdvm/channels.json"; MULTI="/etc/MMDVM-Multi.ini"; CS="/usr/local/libexec/channel-setup.py"; LIMIT="/usr/local/libexec/arch-channel-limit.py"

def run(cmd,timeout=60):
    try:return subprocess.run(cmd,stdout=subprocess.PIPE,stderr=subprocess.PIPE,text=True,timeout=timeout)
    except Exception as e:return type("R",(),{"returncode":-1,"stdout":"","stderr":str(e)})()

def active(u):
    r=run(["systemctl","is-active",u],8); return r.returncode==0 and r.stdout.strip()=="active"

def load_json(path,dv):
    try:
        with open(path,encoding="utf-8") as f:return json.load(f)
    except Exception:return dv

def hw_limit():
    r=run([LIMIT,"--json"],8)
    try:return json.loads(r.stdout)
    except Exception:return {"max_channels":7,"profile":"未知设备","reason":"未检测到硬件限制","model":"","memory_mb":0,"cpu_cores":0}

def normalize_plan(d):
    d=dict(d or {}); lim=int(hw_limit().get("max_channels") or 7)
    d["channels"]=max(1,min(lim,int(d.get("channels") or 1)))
    d["list"]=[x for x in (d.get("list") or []) if int(x.get("n",99))<=d["channels"]]
    d["hardware_max"]=lim
    return d

def plan(): return normalize_plan(load_json(PLAN,{"channels":1,"center_rx":434500000,"center_tx":439500000,"sample_rate":150000,"list":[]}))

def ini(path):
    c=configparser.RawConfigParser(); c.optionxform=str
    try:c.read(path,encoding="utf-8")
    except Exception:pass
    return c

def out(d,code=0): print(json.dumps(d,ensure_ascii=False)); return code

def channels():
    p=plan(); m=ini(MULTI); n=int(p.get("channels") or m.get("General","Channels",fallback=1))
    by={int(x.get("n",0)):x for x in p.get("list",[]) if str(x.get("n","")).isdigit()}
    rows=[]
    for i in range(1,n+1):
        x=by.get(i,{"n":i,"mode":"NONE","rx":0,"tx":0})
        rows.append({"n":i,"mode":x.get("mode") or "NONE","rx":int(x.get("rx") or 0),"tx":int(x.get("tx") or 0),
                     "mport":48200+i-1,"hport":3335+2*(i-1),
                     "iq":active("mmdvm-iq@%d.service"%i),"host":active("mmdvmhost@%d.service"%i),"lastq":""})
    hw=hw_limit()
    return out({"ok":True,"hardware_max":hw.get("max_channels"),"hardware":hw,"multi":{"active":active("mmdvm-multi.service"),"channels":n,
                "rate":m.get("Modem","SampleRate",fallback=p.get("sample_rate",300000)),
                "sdr":m.get("Modem","Type",fallback="sx"),
                "rx":m.get("Modem","RxFrequency",fallback=p.get("center_rx",0)),
                "tx":m.get("Modem","TxFrequency",fallback=p.get("center_tx",0)),
                "rxg":m.get("Modem","RxGain",fallback=None),"txg":m.get("Modem","TxGain",fallback=None)},
                "channels":rows,"services":[{"name":"MMDVM-Multi","active":active("mmdvm-multi.service")},
                {"name":"DMRGateway","active":active("dmrgateway.service")}]} )

def main():
    a=sys.argv[1:]; cmd=a[0] if a else "channels"
    if cmd=="channels": return channels()
    if cmd=="switch" and len(a)>=3:
        if int(a[1])>int(hw_limit().get("max_channels") or 7): return out({"ok":False,"error":hw_limit().get("reason","硬件通道数已达上限")},1)
        r=run(["sudo",CS,"set",a[1],a[2]],180); return out({"ok":r.returncode==0,"message":r.stdout.strip(),"error":r.stderr.strip()},0 if r.returncode==0 else 1)
    if cmd=="restart" and len(a)>=2:
        n=int(a[1]);
        if n>int(hw_limit().get("max_channels") or 7): return out({"ok":False,"error":hw_limit().get("reason","硬件通道数已达上限")},1)
        run(["systemctl","restart","mmdvm-iq@%d.service"%n],60); r=run(["systemctl","restart","mmdvmhost@%d.service"%n],60)
        return out({"ok":r.returncode==0,"error":r.stderr.strip()},0 if r.returncode==0 else 1)
    if cmd=="multi-restart":
        r=run(["systemctl","restart","mmdvm-multi.service"],80); return out({"ok":r.returncode==0,"error":r.stderr.strip()},0 if r.returncode==0 else 1)
    if cmd=="logs":
        ch=a[1] if len(a)>1 else "0"; svc=a[2] if len(a)>2 else "mmdvmhost"; lines=a[3] if len(a)>3 else "200"
        unit=(svc+"@%s.service"%ch) if ch not in ("","0") and svc in ("mmdvmhost","mmdvm-iq") else svc+".service"
        r=run(["journalctl","-u",unit,"-n",str(lines),"--no-pager"],30)
        return out({"ok":r.returncode==0,"text":r.stdout,"error":r.stderr},0 if r.returncode==0 else 1)
    if cmd=="plan":
        d=plan(); d["ok"]=True; d["hardware"]=hw_limit(); return out(d)
    if cmd in ("plan-save","plan-apply") and len(a)>=2:
        try:d=json.loads(a[1])
        except Exception as e:return out({"ok":False,"error":"参数错误: %s"%e},1)
        lim=int(hw_limit().get("max_channels") or 7); requested=int(d.get("channels") or 1)
        d=normalize_plan(d)
        os.makedirs(os.path.dirname(PLAN),exist_ok=True)
        with open(PLAN,"w",encoding="utf-8") as f:json.dump(d,f,ensure_ascii=False,indent=2)
        if cmd=="plan-apply":
            r=run(["sudo",CS,"apply",json.dumps(d,ensure_ascii=False)],240)
            return out({"ok":r.returncode==0,"message":r.stdout.strip(),"error":r.stderr.strip()},0 if r.returncode==0 else 1)
        msg="规划已保存"
        if requested>lim: msg="当前硬件最多支持 %d 路，已自动限制为 %d 路"%(lim,lim)
        return out({"ok":True,"message":msg,"hardware_max":lim,"channels":d.get("channels")})
    return out({"ok":False,"error":"未知操作"},1)

if __name__=="__main__":raise SystemExit(main())
```

### `usr/local/bin/web-tetra.sh`

```bash
#!/bin/bash
set -Eeuo pipefail
exec python3 /usr/local/libexec/tetra-api.py "$@"
```

### `usr/local/libexec/arch-channel-limit.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ARCH 硬件通道上限：旧树莓派和低资源设备自动限流。"""
import json
import os
import sys

MODEL_FILES = ("/proc/device-tree/model", "/sys/firmware/devicetree/base/model")


def read_model():
    for p in MODEL_FILES:
        try:
            with open(p, "rb") as f:
                return f.read().replace(b"\x00", b"").decode("utf-8", "ignore").strip()
        except OSError:
            pass
    return ""


def memory_mb():
    try:
        for line in open("/proc/meminfo", encoding="ascii", errors="ignore"):
            if line.startswith("MemTotal:"):
                return int(line.split()[1]) // 1024
    except OSError:
        pass
    return 0


def decision(model=None):
    model = (model if model is not None else read_model()).strip()
    low = model.lower()
    cores = os.cpu_count() or 1
    mem = memory_mb()
    if "raspberry pi 2" in low or "raspberry pi zero 2" in low or "compute module 3" in low:
        limit, profile = 2, "树莓派 2 / Zero 2 级别"
        reason = "CPU 和内存较低，建议最多 2 路"
    elif "raspberry pi 3" in low or "compute module 3" in low:
        limit, profile = 4, "树莓派 3 级别"
        reason = "树莓派 3B 设备最多开放 4 路"
    elif "raspberry pi 4" in low or "compute module 4" in low:
        if mem and mem < 1800:
            limit, profile = 4, "低内存树莓派 4 级别"
            reason = "内存不足 2GB，最多开放 4 路"
        else:
            limit, profile = 7, "树莓派 4 级别"
            reason = "内存和 CPU 足够，最多开放 7 路"
    elif "raspberry pi 5" in low:
        limit, profile = 7, "树莓派 5 级别"
        reason = "新平台最多开放 7 路"
    else:
        if mem and mem < 1200 or cores <= 2:
            limit, profile = 2, "低资源通用设备"
            reason = "CPU 不超过 2 核或内存较低，最多开放 2 路"
        elif mem and mem < 2200 or cores <= 4:
            limit, profile = 4, "中低资源通用设备"
            reason = "CPU 不超过 4 核或内存不足 2GB，最多开放 4 路"
        else:
            limit, profile = 7, "通用高性能设备"
            reason = "资源充足，最多开放 7 路"
    return {"max_channels": limit, "profile": profile, "reason": reason,
            "model": model or "未知设备", "memory_mb": mem, "cpu_cores": cores}


def main():
    d = decision()
    if "--json" in sys.argv[1:]:
        print(json.dumps(d, ensure_ascii=False))
    else:
        print(d["max_channels"])
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

### `usr/local/libexec/channel-setup.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""生成 ARCH 多信道配置并重载 MMDVM-Multi。"""
import configparser
import copy
import json
import os
import shutil
import subprocess
import sys

PLAN = "/etc/mmdvm/channels.json"
MULTI = "/etc/MMDVM-Multi.ini"
IQ_BASE = "/etc/MMDVM-IQ.ini"
HOST_BASE = "/etc/MMDVM-Host.ini"
LIMIT_SCRIPT = "/usr/local/libexec/arch-channel-limit.py"
MODES = ("DMR", "D-STAR", "YSF", "P25", "NXDN", "FM", "POCSAG")


def ini(path):
    cp = configparser.RawConfigParser()
    cp.optionxform = str
    try: cp.read(path, encoding="utf-8")
    except Exception: pass
    return cp


def save(cp, path):
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        for sec in cp.sections():
            f.write("[" + sec + "]\n")
            for k, v in cp.items(sec, raw=True): f.write(str(k) + "=" + str(v) + "\n")
    os.replace(tmp, path)


def setv(cp, sec, key, val):
    if not cp.has_section(sec): cp.add_section(sec)
    cp.set(sec, key, str(val))


def hardware_limit():
    try:
        r=subprocess.run([LIMIT_SCRIPT],stdout=subprocess.PIPE,stderr=subprocess.DEVNULL,text=True,timeout=8)
        return max(1,min(7,int((r.stdout or "7").strip())))
    except Exception:
        return 7


def default_plan():
    try:
        with open(PLAN, encoding="utf-8") as f: d=json.load(f)
        if isinstance(d, dict) and d.get("list"): return d
    except Exception: pass
    return {"channels": 1, "center_rx": 434500000, "center_tx": 439500000,
            "callsign": "", "dmr_id": "", "spacing": 25000, "sample_rate": 150000,
            "list": [{"n": i, "mode": "NONE", "rx": 434500000,
                      "tx": 439500000} for i in range(1, 12)]}


def grid_step(i, sr=300000):
    n=max(1, round(int(sr or 300000)/25000.0))
    m=max(1, min(int(n/2)-1, 4))
    p=i-1
    b=p if p<m else (n-1)-(p-m)
    return b if b*2<=n else b-n


def normalized(plan):
    d=default_plan(); d.update(plan or {})
    limit=hardware_limit()
    ch=max(1,min(limit,int(d.get("channels") or 1)))
    d["channels"]=ch
    sr=int(d.get("sample_rate") or 300000)
    rx=int(d.get("center_rx") or 434500000); tx=int(d.get("center_tx") or 439500000)
    old={int(x.get("n",0)):x for x in d.get("list",[]) if str(x.get("n", "")).isdigit()}
    out=[]
    for i in range(1,12):
        x=dict(old.get(i) or {})
        x["n"]=i
        x["mode"]=str(x.get("mode") or "NONE").upper()
        if x["mode"] not in MODES+("NONE",): x["mode"]="NONE"
        x["rx"]=int(x.get("rx") or (rx+grid_step(i,sr)*25000))
        x["tx"]=int(x.get("tx") or (tx+grid_step(i,sr)*25000))
        out.append(x)
    d["list"]=out[:max(1,ch)]; d["sample_rate"]=sr
    d["hardware_max"]=limit
    return d


def mode_flags(cp, mode, dmr_ready=False):
    # 所有数字模式先关闭，随后只打开本通道分配的模式。
    for sec,key in (("DMR","[DMR]"),("D-Star","[D-Star]"),("System Fusion","[System Fusion]"),
                    ("P25","[P25]"),("NXDN","[NXDN]"),("FM","[FM]"),("POCSAG","[POCSAG]")):
        setv(cp,sec,"Enable","0")
    for sec in ("DMR Network","D-Star Network","System Fusion Network","P25 Network",
                "NXDN Network","FM Network","POCSAG Network"):
        setv(cp,sec,"Enable","0")
    m=mode.upper()
    if m=="DMR":
        setv(cp,"DMR","Enable","1" if dmr_ready else "0")
        setv(cp,"DMR Network","Enable","1" if dmr_ready else "0")
    elif m=="D-STAR":
        setv(cp,"D-Star","Enable","1"); setv(cp,"D-Star Network","Enable","1")
    elif m=="YSF":
        setv(cp,"System Fusion","Enable","1"); setv(cp,"System Fusion Network","Enable","1")
    elif m=="P25":
        setv(cp,"P25","Enable","1"); setv(cp,"P25 Network","Enable","1")
    elif m=="NXDN":
        setv(cp,"NXDN","Enable","1"); setv(cp,"NXDN Network","Enable","1")
    elif m=="FM":
        setv(cp,"FM","Enable","1"); setv(cp,"FM Network","Enable","1")
        setv(cp,"FM Network","GatewayAddress","127.0.0.1"); setv(cp,"FM Network","GatewayPort","49880")
    elif m=="POCSAG":
        setv(cp,"POCSAG","Enable","1"); setv(cp,"POCSAG Network","Enable","1")


def apply(plan):
    d=normalized(plan)
    # 互斥规则只检查正在运行的通道；未启用通道保留的历史模式不算占用。
    _active_modes=[str(x.get("mode","NONE")).upper()
                   for x in d["list"][:int(d.get("channels") or 1)]
                   if str(x.get("mode","NONE")).upper() not in ("DMR","NONE","")]
    _dupes=sorted({m for m in _active_modes if _active_modes.count(m)>1})
    if _dupes:
        raise RuntimeError("模式互斥规则：运行中的非 DMR 模式只能各用一次。重复: %s" % ", ".join(_dupes))
    # 先复制模板，避免逐条生成时互相覆盖。
    if not os.path.exists(HOST_BASE):
        raise RuntimeError("缺少 /etc/MMDVM-Host.ini 模板")
    host_tpl=ini(HOST_BASE)
    iq_tpl=ini(IQ_BASE)
    call=str(d.get("callsign") or "").strip().upper()
    dmr_id=str(d.get("dmr_id") or "").strip()
    for item in d["list"]:
        n=int(item["n"])
        if n>d["channels"]:
            continue
        h=copy.deepcopy(host_tpl)
        setv(h,"General","Callsign",call or "NOCALL")
        if dmr_id: setv(h,"General","Id",dmr_id)
        setv(h,"General","RXFrequency",item["rx"]); setv(h,"General","TXFrequency",item["tx"])
        setv(h,"Info","RXFrequency",item["rx"]); setv(h,"Info","TXFrequency",item["tx"])
        setv(h,"Modem","RXFrequency",item["rx"]); setv(h,"Modem","TXFrequency",item["tx"])
        setv(h,"Modem","ModemPort",3334+2*(n-1)); setv(h,"Modem","LocalPort",3335+2*(n-1))
        setv(h,"DMR Network","LocalPort",62032+(n-1))
        dmr_ready=bool(dmr_id and any(c in "123456789" for c in dmr_id))
        mode_flags(h,item["mode"],dmr_ready)
        setv(h,"DMR","TXHang","4"); setv(h,"DMR","CallHang","3")
        save(h,"/etc/MMDVM-Host-%d.ini"%n)
        # 兼容旧页面/脚本读取的基础 Host 文件，必须与 1 号通道保持一致。
        if n == 1:
            save(h,HOST_BASE)
        q=copy.deepcopy(iq_tpl)
        setv(q,"Modem","Driver","Multi")
        setv(q,"Host","HostAddress","127.0.0.1"); setv(q,"Host","HostPort",3335+2*(n-1))
        setv(q,"Host","LocalAddress","127.0.0.1"); setv(q,"Host","LocalPort",3334+2*(n-1))
        setv(q,"Multi","ModemAddress","127.0.0.1"); setv(q,"Multi","ModemPort",48200+(n-1))
        setv(q,"Multi","LocalAddress","127.0.0.1"); setv(q,"Multi","LocalPort",48100+(n-1))
        setv(q,"MQTT","Name","mmdvm-iq-%d"%n)
        save(q,"/etc/MMDVM-IQ-%d.ini"%n)
    m=ini(MULTI)
    setv(m,"General","Channels",d["channels"]); setv(m,"Modem","SampleRate",d["sample_rate"])
    setv(m,"Modem","RxFrequency",d["center_rx"]); setv(m,"Modem","TxFrequency",d["center_tx"])
    save(m,MULTI)
    os.makedirs(os.path.dirname(PLAN),exist_ok=True)
    with open(PLAN,"w",encoding="utf-8") as f: json.dump(d,f,ensure_ascii=False,indent=2)
    subprocess.run(["systemctl","daemon-reload"],timeout=15)
    limit=hardware_limit()
    for n in range(d["channels"]+1,12):
        subprocess.run(["systemctl","stop","mmdvm-iq@%d.service"%n,"mmdvmhost@%d.service"%n],timeout=25,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
        subprocess.run(["systemctl","disable","mmdvm-iq@%d.service"%n,"mmdvmhost@%d.service"%n],timeout=20,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
    for n in range(1,d["channels"]+1):
        subprocess.run(["systemctl","enable","mmdvm-iq@%d.service"%n,"mmdvmhost@%d.service"%n],timeout=20,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
        subprocess.run(["systemctl","restart","mmdvm-iq@%d.service"%n],timeout=30,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
        subprocess.run(["systemctl","restart","mmdvmhost@%d.service"%n],timeout=30,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
    subprocess.run(["systemctl","restart","mmdvm-multi.service"],timeout=40,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
    sync_gateway_services(d)
    print("多信道配置已应用：%d 路" % d["channels"])


def sync_gateway_services(plan):
    modes = {str(x.get("mode", "")).upper().replace("-", "") for x in plan.get("list", [])[:int(plan.get("channels") or 0)]}
    for mode, svc in (("YSF", "ysfgateway"), ("P25", "p25gateway"), ("NXDN", "nxdngateway"), ("DSTAR", "ircddbgatewayd")):
        if mode in modes:
            subprocess.run(["systemctl", "enable", svc], timeout=15, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
            subprocess.run(["systemctl", "restart", svc], timeout=40, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        else:
            subprocess.run(["systemctl", "disable", "--now", svc], timeout=25, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def set_mode(n,mode):
    limit=hardware_limit()
    if int(n)>limit:
        raise RuntimeError("当前硬件最多支持 %d 路，不能启用第 %d 路" % (limit,int(n)))
    d=normalized(None); mode=mode.upper()
    if mode not in MODES+("NONE",): raise RuntimeError("无效模式: %s"%mode)
    for x in d["list"]:
        if int(x["n"])==int(n): x["mode"]=mode
    d["channels"]=max(int(d.get("channels") or 1),int(n))
    apply(d)


def main():
    try:
        if len(sys.argv)>=4 and sys.argv[1]=="set":
            set_mode(int(sys.argv[2]),sys.argv[3]); return 0
        if len(sys.argv)>=2 and sys.argv[1]=="apply":
            plan=json.loads(sys.argv[2]) if len(sys.argv)>2 and sys.argv[2].strip() else normalized(None)
            apply(plan); return 0
        if len(sys.argv)>=2 and sys.argv[1]=="plan":
            print(json.dumps(normalized(None),ensure_ascii=False,indent=2)); return 0
        raise RuntimeError("用法: channel-setup.py {apply [json]|set n mode|plan}")
    except Exception as e:
        print(str(e),file=sys.stderr); return 1

if __name__=="__main__": raise SystemExit(main())
```

### `usr/local/libexec/fmo-api.py`

```python
# -*- coding: utf-8 -*-
"""ARCH FMO 本地桥接 Web 后端。

仅供本机 root 控制台通过 /usr/local/bin/web-fmo.sh 调用。
"""
import copy
import json
import os
import re
import subprocess
import sys
import time

BASE = "/opt/fmo-bridge"
CONFIG = os.environ.get("ARCH_FMO_CONFIG", os.path.join(BASE, "config.json"))
HOST_INI = "/etc/MMDVM-Host-%d.ini"
DEFAULT_REG_HOST = "fmo.hljrsa.cn"
DEFAULT_REG_PORT = 8081

TONES = [
    67.0, 69.3, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
    94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8, 123.0, 127.3,
    131.8, 136.5, 141.3, 146.2, 151.4, 156.7, 159.8, 162.2, 165.5, 167.9,
    171.3, 173.8, 177.3, 179.9, 183.5, 186.2, 189.9, 192.8, 196.6, 199.5,
    203.5, 206.5, 210.7, 218.1, 225.7, 229.1, 233.6, 241.8, 250.3, 254.1,
]
COMMON_TONES = [67.0, 71.9, 74.4, 77.0, 79.7, 82.5, 85.4, 88.5, 91.5,
                94.8, 97.4, 100.0, 103.5, 107.2, 110.9, 114.8, 118.8,
                123.0, 127.3, 131.8, 136.5, 141.3, 146.2, 151.4, 156.7,
                162.2, 167.9, 173.8, 179.9, 186.2, 192.8, 203.5, 210.7,
                218.1, 225.7, 233.6, 241.8, 250.3, 254.1]

DEFAULT_CFG = {
    "server": {"callsign": "", "uid": 0, "mqtt_host": DEFAULT_REG_HOST,
               "mqtt_port": 1883, "reg_port": DEFAULT_REG_PORT,
               "certFingerprint": ""},
    "client": {"callsign": "", "uid": 0, "role": "user",
               "user_cert": "./pki/user_fm.json",
               "user_key": "./pki/user_fm.key",
               "intermediate_cert": "./pki/intermediate.json"},
    "fm": {"bind_addr": "127.0.0.1", "bind_port": 49880,
           "rxgain": 1.0, "txgain": 0.45, "vendor": 0, "debug": False,
           "tone": {"enable": False, "freq": 1000.0, "ms": 200,
                    "delay_ms": 120, "gain": 0.5},
           "ctcss": {"enable": True, "rx": True, "tx": True,
                     "freq": 88.5, "rx_tone": "88.5", "tx_tone": "88.5"}},
    "voice": {"codec": "radpcm", "topic": "FMO/RAW", "relay_to_public": False},
}


def _deep_merge(dst, src):
    for k, v in (src or {}).items():
        if isinstance(v, dict) and isinstance(dst.get(k), dict):
            _deep_merge(dst[k], v)
        else:
            dst[k] = v
    return dst


def _abs(path):
    path = str(path or "")
    return path if os.path.isabs(path) else os.path.abspath(os.path.join(BASE, path))


def load_cfg():
    cfg = copy.deepcopy(DEFAULT_CFG)
    try:
        with open(CONFIG, encoding="utf-8") as f:
            data = json.load(f)
        if isinstance(data, dict):
            _deep_merge(cfg, data)
    except (OSError, ValueError):
        pass
    # 兼容旧版遗留配置：通道号/固定频率永远不作为绑定项返回给页面。
    if isinstance(cfg.get("fm"), dict):
        for k in ("channel", "tx_mhz", "rx_mhz"):
            cfg["fm"].pop(k, None)
    return cfg


def save_cfg(cfg):
    os.makedirs(os.path.dirname(CONFIG), exist_ok=True)
    tmp = CONFIG + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(cfg, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.replace(tmp, CONFIG)


def _run(cmd, timeout=30):
    try:
        p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                           text=True, timeout=timeout)
        return p.returncode, (p.stdout or "").strip(), (p.stderr or "").strip()
    except Exception as e:
        return -1, "", str(e)


def is_active(unit):
    rc, out, _ = _run(["systemctl", "is-active", unit], 8)
    return rc == 0 and out.strip() == "active"


def _ini_get(path, section, key, default=None):
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        return default
    sec = None
    key_l = key.lower()
    for line in lines:
        s = line.strip()
        if s.startswith("[") and s.endswith("]"):
            sec = s[1:-1].strip().lower()
            continue
        if sec != section.lower() or "=" not in line or line.lstrip().startswith(("#", ";")):
            continue
        k, v = line.split("=", 1)
        if k.strip().lower() == key_l:
            return v.split("#", 1)[0].strip()
    return default


def _ini_set(path, updates):
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        raise RuntimeError("配置文件不存在: %s" % path)
    sections = {}
    current = None
    for i, line in enumerate(lines):
        s = line.strip()
        if s.startswith("[") and s.endswith("]"):
            current = s[1:-1].strip().lower()
            sections.setdefault(current, [i, i])
        elif current:
            sections[current][1] = i
    for section, values in updates.items():
        sl = section.lower()
        if sl not in sections:
            lines.append("\n[%s]\n" % section)
            start = len(lines)
            sections[sl] = [start, start]
        first, last = sections[sl]
        for key, val in values.items():
            pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=", re.I)
            found = None
            for i in range(first + 1, last + 1):
                if pat.match(lines[i]):
                    found = i
                    break
            newline = "%s=%s\n" % (key, val)
            if found is None:
                lines.insert(last + 1, newline)
                last += 1
            else:
                lines[found] = newline
            sections[sl][1] = last
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.writelines(lines)
    os.replace(tmp, path)


def active_channel(cfg=None):
    """按当前 channels.json 的已启用通道实时找 FM，不保存固定通道号。"""
    try:
        with open("/etc/mmdvm/channels.json", encoding="utf-8") as f:
            plan = json.load(f)
        limit = max(1, min(16, int(plan.get("channels") or 1)))
        for item in plan.get("list", [])[:limit]:
            if str(item.get("mode", "")).upper() == "FM":
                n = int(item.get("n", 0))
                if 1 <= n <= 16 and os.path.exists(HOST_INI % n):
                    return n
        return None
    except Exception:
        pass
    # 仅当规划文件不可读时才回退；仍然只认真正启用 FM 的 Host。
    for n in range(1, 17):
        p = HOST_INI % n
        if os.path.exists(p):
            en = _ini_get(p, "FM", "Enable", "0")
            if str(en).strip().lower() in ("1", "true", "yes", "on"):
                return n
    return None


def cert_info(cfg=None):
    cfg = cfg or load_cfg()
    p = _abs((cfg.get("client") or {}).get("user_cert", "./pki/user_fm.json"))
    out = {"exists": os.path.exists(p), "path": p}
    if not out["exists"]:
        return out
    try:
        with open(p, encoding="utf-8") as f:
            c = json.load(f)
        sub = c.get("subject") or {}
        out.update({"callsign": sub.get("callsign", ""), "uid": sub.get("uid"),
                    "notAfter": c.get("notAfter") or c.get("not_after") or ""})
    except Exception as e:
        out["error"] = str(e)
    return out


def summarize():
    cfg = load_cfg()
    cert = cert_info(cfg)
    if cert.get("exists"):
        cfg.setdefault("client", {})["callsign"] = cert.get("callsign", "")
        cfg["client"]["uid"] = cert.get("uid", 0)
    cfg["cert"] = cert
    n = active_channel(cfg)
    if n:
        try:
            with open("/etc/mmdvm/channels.json", encoding="utf-8") as f:
                plan = json.load(f)
            item = next(x for x in plan.get("list", []) if int(x.get("n", 0)) == n)
            cfg.setdefault("fm", {})["tx_mhz"] = round(float(item.get("tx") or 0) / 1e6, 5)
            cfg["fm"]["rx_mhz"] = round(float(item.get("rx") or 0) / 1e6, 5)
        except Exception:
            pass
    return cfg


def _current_ctcss(cfg=None, n=None):
    cfg = cfg or load_cfg()
    ct = (cfg.get("fm") or {}).get("ctcss") or {}
    n = n or active_channel(cfg)
    tone = ct.get("freq", ct.get("rx_tone", "off"))
    try:
        tone = float(tone)
    except Exception:
        tone = 0.0
    need_rx = bool(ct.get("rx", True))
    if n:
        v = _ini_get(HOST_INI % n, "FM", "CTCSSFrequency")
        try:
            if v is not None:
                tone = float(v)
        except Exception:
            pass
        need_rx = str(_ini_get(HOST_INI % n, "FM", "AccessMode", "1")).strip() == "1"
    if tone <= 0:
        tone_s = "off"
    else:
        tone_s = ("%.1f" % tone).rstrip("0").rstrip(".")
    return {"tone": tone_s, "need_rx": need_rx}


def cmd_status():
    cfg = summarize()
    n = active_channel(cfg)
    services = {
        "fmo-bridge": "active" if is_active("fmo-bridge.service") else "inactive",
        "dmrgateway": "active" if is_active("dmrgateway.service") else "inactive",
    }
    if n:
        services["mmdvmhost"] = "active" if is_active("mmdvmhost@%d.service" % n) else "inactive"
    else:
        services["mmdvmhost"] = "inactive"
    return {"ok": True, "services": services, "cfg": cfg, "cert": cfg.get("cert", {})}


def cmd_ping(host, port):
    cfg = load_cfg()
    host = str(host or (cfg.get("server") or {}).get("mqtt_host") or DEFAULT_REG_HOST).strip()
    try:
        port = int(port or (cfg.get("server") or {}).get("reg_port") or DEFAULT_REG_PORT)
    except Exception:
        port = DEFAULT_REG_PORT
    sys.path.insert(0, BASE)
    import fmo_reg
    base = fmo_reg.register_base(host, port)
    info = fmo_reg.probe_server(base, timeout=15)
    return {"ok": bool(info.get("ok")), "info": info, "register_url": base}


def cmd_register(host, port, callsign, uid=0):
    callsign = str(callsign or "").strip().upper()
    if not callsign:
        return {"ok": False, "error": "请填写本机呼号"}
    cfg = load_cfg()
    host = str(host or (cfg.get("server") or {}).get("mqtt_host") or DEFAULT_REG_HOST).strip()
    try:
        port = int(port or (cfg.get("server") or {}).get("reg_port") or DEFAULT_REG_PORT)
    except Exception:
        port = DEFAULT_REG_PORT
    try:
        sys.path.insert(0, BASE)
        import fmo_reg
        base = fmo_reg.register_base(host, port)
        cert_path = _abs(cfg["client"].get("user_cert", "./pki/user_fm.json"))
        key_path = _abs(cfg["client"].get("user_key", "./pki/user_fm.key"))
        inter_path = _abs(cfg["client"].get("intermediate_cert", "./pki/intermediate.json"))
        server, user, inter = fmo_reg.ensure_cert(
            base, callsign, int(uid or 0), cert_path, key_path, inter_path,
            force=True, timeout=25)
        sub = (user or {}).get("subject") or {}
        s = cfg.setdefault("server", {})
        if server:
            s["callsign"] = server.get("callsign", s.get("callsign", ""))
            s["uid"] = server.get("uid", s.get("uid", 0))
            s["mqtt_host"] = server.get("mqttHost") or server.get("mqtt_host") or host
            s["mqtt_port"] = server.get("mqttPort") or server.get("mqtt_port") or 1883
            s["certFingerprint"] = server.get("certFingerprint") or s.get("certFingerprint", "")
        s["reg_port"] = port
        c = cfg.setdefault("client", {})
        c["callsign"] = sub.get("callsign", callsign)
        c["uid"] = sub.get("uid", 0)
        c["role"] = "user"
        save_cfg(cfg)
        _run(["systemctl", "daemon-reload"], 10)
        _run(["systemctl", "enable", "fmo-bridge.service"], 15)
        rc, out, err = _run(["systemctl", "restart", "fmo-bridge.service"], 30)
        return {"ok": rc == 0, "client": c, "server": s,
                "message": "证书已签发" if rc == 0 else ("证书已签发，桥接启动失败: " + (err or out))}
    except Exception as e:
        return {"ok": False, "error": str(e)}


def cmd_save(body):
    try:
        d = json.loads(body or "{}")
    except Exception as e:
        return {"ok": False, "error": "参数错误: %s" % e}
    cfg = load_cfg()
    if isinstance(d.get("fm"), dict):
        for k in ("channel", "tx_mhz", "rx_mhz"):
            d["fm"].pop(k, None)
    _deep_merge(cfg, d)
    cert = cert_info(cfg)
    if cert.get("exists"):
        cfg.setdefault("client", {})["callsign"] = cert.get("callsign", "")
        cfg["client"]["uid"] = cert.get("uid", 0)
    save_cfg(cfg)
    return {"ok": True, "cfg": summarize()}


def cmd_restart(what):
    what = str(what or "bridge").lower()
    units = []
    if what in ("bridge", "all", "fmo-bridge"):
        units.append("fmo-bridge.service")
    if what in ("host", "all", "mmdvmhost", "mmdvm"):
        n = active_channel()
        if n:
            units.append("mmdvmhost@%d.service" % n)
    if "dmrgateway" in what:
        units.append("dmrgateway.service")
    done = []
    errors = []
    for u in units:
        rc, out, err = _run(["systemctl", "restart", u], 40)
        if rc == 0:
            done.append(u)
        else:
            errors.append("%s: %s" % (u, err or out))
    return {"ok": not errors, "restarted": done, "error": "; ".join(errors)}


def cmd_ctcss():
    return {"ok": True, "tones": TONES, "common": COMMON_TONES,
            "current": _current_ctcss()}


def cmd_apply_ctcss(body):
    try:
        d = json.loads(body or "{}")
        tone = str(d.get("tone", "off")).strip().lower()
        need_rx = bool(d.get("need_rx", True))
    except Exception as e:
        return {"ok": False, "error": str(e)}
    n = active_channel()
    if not n:
        return {"ok": False, "error": "未找到可用的 FM 通道"}
    if tone in ("off", "0", ""):
        freq = 88.5
        access = "5"
        tone_value = "off"
    else:
        try:
            freq = float(tone)
            if freq < 60.0 or freq > 260.0:
                raise ValueError()
        except Exception:
            return {"ok": False, "error": "哑音频率无效"}
        access = "1" if need_rx else "5"
        tone_value = ("%.1f" % freq).rstrip("0").rstrip(".")
    try:
        _ini_set(HOST_INI % n, {"FM": {
            "Enable": "1",
            "CTCSSFrequency": freq,
            "AccessMode": access,
            "CTCSSLevel": "2.0",
        }})
    except Exception as e:
        return {"ok": False, "error": str(e)}
    cfg = load_cfg()
    cfg["fm"]["ctcss"] = {"enable": tone_value != "off", "rx": need_rx,
                         "tx": tone_value != "off", "freq": freq,
                         "rx_tone": tone_value, "tx_tone": tone_value}
    save_cfg(cfg)
    rc, out, err = _run(["systemctl", "restart", "mmdvmhost@%d.service" % n], 40)
    if rc != 0:
        return {"ok": False, "error": err or out, "current": _current_ctcss(cfg, n)}
    return {"ok": True, "channel": n, "current": _current_ctcss(cfg, n)}


def _tailtone_current(cfg=None, n=None):
    cfg = cfg or load_cfg()
    t = ((cfg.get("fm") or {}).get("tone") or {})
    n = n or active_channel(cfg)
    out = {"ok": True, "channel": n, "enable": bool(t.get("enable", False)),
           "freq": float(t.get("freq", 1000.0)), "ms": int(t.get("ms", 200)),
           "delay_ms": int(t.get("delay_ms", 120)), "gain": float(t.get("gain", 0.5))}
    if n:
        p = HOST_INI % n
        out["channel"] = n
    return out


def cmd_tailtone_set(body):
    try:
        d = json.loads(body or "{}")
    except Exception as e:
        return {"ok": False, "error": str(e)}
    n = active_channel()
    if not n:
        return {"ok": False, "error": "未找到可用的 FM 通道"}
    try:
        enable = bool(d.get("enable", False))
        freq = float(d.get("freq", 1000.0))
        ms = max(20, min(2000, int(d.get("ms", 200))))
        delay = max(0, min(2000, int(d.get("delay_ms", 120))))
        gain = max(0.0, min(1.0, float(d.get("gain", 0.5))))
    except Exception as e:
        return {"ok": False, "error": "参数错误: %s" % e}
    try:
        _ini_set(HOST_INI % n, {"FM": {
            "RFAck": "K" if enable else "N",
            "AckFrequency": freq,
            "AckMinTime": ms if enable else 0,
            "AckDelay": delay,
            "AckLevel": gain * 100.0,
        }})
    except Exception as e:
        return {"ok": False, "error": str(e)}
    cfg = load_cfg()
    cfg["fm"]["tone"] = {"enable": enable, "freq": freq, "ms": ms,
                         "delay_ms": delay, "gain": gain}
    save_cfg(cfg)
    rc, out, err = _run(["systemctl", "restart", "mmdvmhost@%d.service" % n], 40)
    if rc != 0:
        return {"ok": False, "error": err or out}
    return {"ok": True, "channel": n,
            "applied": {"freq": freq, "ms": ms, "delay_ms": delay, "gain": gain}}


def cmd_tone_test():
    try:
        with open("/tmp/fmo_tone_req", "w", encoding="ascii") as f:
            f.write(str(time.time()))
    except Exception as e:
        return {"ok": False, "error": str(e)}
    return {"ok": True, "hint": "已向本机 FMO 桥接发送尾音测试请求"}


def cmd_logs(lines):
    cnt = max(10, min(500, int(lines or 150)))
    rc, out, err = _run(["journalctl", "-u", "fmo-bridge.service",
                         "-n", str(cnt), "--no-pager"], 20)
    n = active_channel()
    if rc == 0 and out:
        return {"ok": True, "channel": n, "service": "fmo-bridge", "text": out}
    if n:
        rc, out2, err2 = _run(["journalctl", "-u", "mmdvmhost@%d.service" % n,
                               "-n", str(cnt), "--no-pager"], 20)
        if rc == 0:
            return {"ok": True, "channel": n,
                    "service": "mmdvmhost@%d" % n, "text": out2}
    return {"ok": False, "error": err or err2 or "无法读取日志", "text": out or out2}


def main():
    a = sys.argv[1:]
    cmd = a[0] if a else "status"
    if cmd == "status":
        out = cmd_status()
    elif cmd == "ping":
        out = cmd_ping(a[1] if len(a) > 1 else "", a[2] if len(a) > 2 else DEFAULT_REG_PORT)
    elif cmd == "register":
        out = cmd_register(a[1] if len(a) > 1 else "", a[2] if len(a) > 2 else DEFAULT_REG_PORT,
                           a[3] if len(a) > 3 else "", a[4] if len(a) > 4 else 0)
    elif cmd == "save":
        out = cmd_save(a[1] if len(a) > 1 else "{}")
    elif cmd == "restart":
        out = cmd_restart(a[1] if len(a) > 1 else "all")
    elif cmd == "ctcss":
        out = cmd_ctcss()
    elif cmd == "apply-ctcss":
        out = cmd_apply_ctcss(a[1] if len(a) > 1 else "{}")
    elif cmd == "tailtone":
        out = _tailtone_current()
    elif cmd == "tailtone-set":
        out = cmd_tailtone_set(a[1] if len(a) > 1 else "{}")
    elif cmd == "tone-test":
        out = cmd_tone_test()
    elif cmd == "logs":
        out = cmd_logs(a[1] if len(a) > 1 else 150)
    else:
        out = {"ok": False, "error": "未知操作: %s" % cmd}
    print(json.dumps(out, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
```

### `usr/local/libexec/hosts-build.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, os, re, sys
ROOT="/usr/local/etc"
H=os.path.join(ROOT,"hosts")
os.makedirs(H,exist_ok=True)

def lines(name):
    for p in (os.path.join(H,name), os.path.join(ROOT,name)):
        try:
            with open(p,encoding="utf-8",errors="ignore") as f:
                return [x.rstrip("\n") for x in f]
        except OSError:
            pass
    return []

def write(name,value):
    for d in (H,ROOT):
        with open(os.path.join(d,name),"w",encoding="utf-8") as f:
            json.dump(value,f,ensure_ascii=False,indent=2)

def write_path(path,value):
    os.makedirs(os.path.dirname(path),exist_ok=True)
    tmp=path+".tmp"
    with open(tmp,"w",encoding="utf-8") as f:
        json.dump(value,f,ensure_ascii=False,indent=2)
        f.write("\n")
    os.replace(tmp,path)

# ---- ARCH 自建服务器（始终置顶，统一注释“二区多模融合”）----
_ARCH_DMR = {"id": "46002", "host": "srv2.hljrsa.cn", "port": 62030, "pw": "",
             "net": "DMR", "label": "ARCH_46002_China",
             "region": "46002 二区多模融合", "country": "CN"}
_ARCH_YSF = {"designator": "46999", "country": "CN", "name": "CN-46002",
             "use_xx_prefix": False, "user_count": "000",
             "description": "二区多模融合 / 哈尔滨",
             "port": 42000, "ipv4": "ysf.hljrsa.cn", "ipv6": None}
_ARCH_P25 = {"designator": 46002, "name": "46002 二区多模融合",
             "port": 41000, "ipv4": "39.96.117.224", "ipv6": None}
_ARCH_NXDN = {"designator": 46002, "name": "46002 二区多模融合",
              "port": 41400, "ipv4": "39.96.117.224", "ipv6": None}
_ARCH_DSTAR = {
    "DCS": [{"name": "DCSHLJ 46002二区多模融合", "ip": "39.96.117.224"}],
    "XRF": [{"name": "XRFHLJ 46002二区多模融合", "ip": "39.96.117.224"}],
    "REF": [{"name": "REFHLJ 46002二区多模融合", "ip": "39.96.117.224"}],
    "XLX": [{"name": "XLXHLJ 46002二区多模融合", "ip": "39.96.117.224"}],
}

_DMR_CMAP = {
    "china": "CN", "chinese": "CN", "hongkong": "HK", "taiwan": "TW",
    "japan": "JP", "korea": "KR", "singapore": "SG", "malaysia": "MY", "thailand": "TH",
    "indonesia": "ID", "philippines": "PH", "india": "IN", "vietnam": "VN",
    "usa": "US", "united": "US", "canada": "CA", "mexico": "MX", "brazil": "BR",
    "argentina": "AR", "chile": "CL", "colombia": "CO", "peru": "PE",
    "uk": "GB", "england": "GB", "scotland": "GB", "wales": "GB", "ireland": "IE",
    "germany": "DE", "france": "FR", "spain": "ES", "italy": "IT", "portugal": "PT",
    "netherlands": "NL", "belgium": "BE", "switzerland": "CH", "austria": "AT",
    "sweden": "SE", "norway": "NO", "denmark": "DK", "finland": "FI", "iceland": "IS",
    "poland": "PL", "czech": "CZ", "slovakia": "SK", "hungary": "HU", "romania": "RO",
    "bulgaria": "BG", "greece": "GR", "croatia": "HR", "serbia": "RS", "slovenia": "SI",
    "turkey": "TR", "russia": "RU", "ukraine": "UA", "belarus": "BY",
    "australia": "AU", "newzealand": "NZ", "southafrica": "ZA", "egypt": "EG",
    "israel": "IL", "saudiarabia": "SA", "uae": "AE", "iran": "IR", "iraq": "IQ",
    "europe": "EU", "world": "WW", "global": "WW", "asia": "AS", "africa": "AF",
}
_DMR_ISO2 = set(_DMR_CMAP.values()) | {"HK", "MO", "PR", "GU", "LU", "MT", "CY", "EE",
                                        "LV", "LT", "MD", "AL", "MK", "BA", "ME", "GE",
                                        "AM", "AZ", "KZ", "UZ"}
_DMR_PIN_TOP = ("4602", "4501")
_DMR_TIER_CN = {"CN"}
_DMR_TIER_ASIA = {"HK", "TW", "MO", "JP", "SG", "MY", "TH", "ID", "PH", "VN", "IN"}

def _dmr_country(label):
    for tok in re.split(r"[^A-Za-z]+", str(label or "")):
        if not tok:
            continue
        up = tok.upper()
        if len(up) == 2 and up in _DMR_ISO2:
            return up
        low = tok.lower()
        if low in _DMR_CMAP:
            return _DMR_CMAP[low]
    return ""

def _dmr_region(label):
    parts = str(label or "").split("_")
    return " ".join(parts[1:]) if len(parts) > 1 else str(label or "")

def _dmr_tier(country):
    if country in _DMR_TIER_CN:
        return 0
    if country in _DMR_TIER_ASIA:
        return 1
    return 2

def dmr():
    rows = []
    for line in lines("DMR_Hosts.txt"):
        line = line.strip()
        if not line or line.startswith("#"):
            continue
        p = [x for x in re.split(r"[\t ]+", line) if x]
        if len(p) < 4:
            continue
        label, rid, host, pw = p[:4]
        port = p[4] if len(p) > 4 else "62031"
        if host.startswith("127.0.0.") or host in ("localhost", "0.0.0.0"):
            continue
        if not re.match(r"^[A-Za-z0-9_.\-]+$", host):
            continue
        net = label.split("_")[0].upper()
        rows.append({
            "id": "" if rid in ("0000", "0") else str(rid),
            "host": host,
            "port": int(port) if str(port).isdigit() else 62031,
            "pw": "" if str(pw).lower() in ("none", "") else pw,
            "net": net,
            "label": label,
            "region": _dmr_region(label),
            "country": _dmr_country(label),
        })
    for i, row in enumerate(rows):
        row["_i"] = i
    def rank(row):
        net = row["net"]
        if net == "BM":
            group = _DMR_PIN_TOP.index(row["id"]) if row["id"] in _DMR_PIN_TOP else len(_DMR_PIN_TOP)
            tier = 0
        else:
            group = len(_DMR_PIN_TOP) + 1
            tier = _dmr_tier(row["country"])
        return (group, tier, row["_i"])
    rows.sort(key=rank)
    for row in rows:
        row.pop("_i", None)
    # ★ ARCH 自建服务器（交换机）始终置顶，避免被上游排序挤到后面
    rows = [r for r in rows
            if not (str(r.get("host")) == _ARCH_DMR["host"]
                    and int(r.get("port") or 0) == int(_ARCH_DMR["port"]))]
    rows.insert(0, dict(_ARCH_DMR))
    return rows
def reflectors(name):
    rows=[]
    for line in lines(name):
        line=line.strip()
        if not line or line.startswith("#"): continue
        p=[x for x in re.split(r"[\t ]+",line) if x]
        if len(p)>=3:
            try: rows.append({"designator":int(p[0]),"port":int(p[2]),"ipv4":p[1],"ipv6":None})
            except ValueError: pass
    return {"reflectors":rows}

def ysf():
    rows=[]
    for line in lines("YSF_Hosts.txt"):
        line=line.strip()
        if not line or line.startswith("#"): continue
        p=[x.strip() for x in line.split(";")]
        if len(p)<5 or not p[0] or not p[3]: continue
        try: port=int(p[4] or 42000)
        except ValueError: continue
        name=p[1]
        if "-" in name: country,short=name.split("-",1); use_xx=False
        else: country,short,use_xx="",name,True
        rows.append({"designator":p[0],"country":country,"name":short,"use_xx_prefix":use_xx,
                     "user_count":p[5] if len(p)>5 and p[5] else "000","description":p[2] or short,
                     "port":port,"ipv4":p[3],"ipv6":None})
    # ★ ARCH 自建 YSF 房间置顶，并去掉上游的重复条目
    rows = [r for r in rows
            if not (str(r.get("designator")) == str(_ARCH_YSF["designator"])
                    or str(r.get("ipv4")) == _ARCH_YSF["ipv4"])]
    rows.insert(0, dict(_ARCH_YSF))
    return {"reflectors":rows}

def fcs():
    rows=[]
    for line in lines("FCS_Hosts.txt"):
        line=line.strip()
        if not line or line.startswith("#"): continue
        p=line.split(";")
        if p and p[0]: rows.append({"id":p[0],"name":p[1] if len(p)>1 else p[0],
                                     "desc":p[2] if len(p)>2 else (p[1] if len(p)>1 else p[0])})
    return rows

def dstar():
    groups={"DCS":[],"XRF":[],"REF":[],"XLX":[],"CCS":[]}
    try:
        data=json.load(open(os.path.join(H,"DStar_Hosts.json"),encoding="utf-8"))
        for x in data.get("reflectors",[]):
            typ=str(x.get("reflector_type","")).upper()
            if typ in groups and (x.get("ipv4") or x.get("ipv6")):
                groups[typ].append({"name":x.get("name",""),"ip":x.get("ipv4") or x.get("ipv6")})
    except Exception: pass
    groups["FCS"]=[{"name":x.get("id",""),"ip":x.get("desc","")} for x in fcs()]
    # ★ ARCH 自建 D-STAR/XLX 条目置顶，并补上 46002 注释
    for _k, _items in _ARCH_DSTAR.items():
        for _it in reversed(_items):
            if not any(str(x.get("name", "")).upper() == _it["name"].upper()
                       for x in groups.get(_k, [])):
                groups.setdefault(_k, []).insert(0, dict(_it))
    return groups

def build_p25_outputs():
    p=reflectors("P25_Hosts.txt")
    aliases=[dict(_ARCH_P25)]
    ids={"46002","46020"}
    refs=[x for x in p.get("reflectors",[]) if str(x.get("designator","")) not in ids]
    full={"reflectors":aliases+refs}
    gateway={"reflectors":aliases}
    # Web UI uses the full public list.
    write_path(os.path.join(H,"P25Hosts.json"),full)
    # P25Gateway only needs the ARCH static reflector, avoiding slow DNS scans.
    write_path(os.path.join(ROOT,"P25Hosts.json"),gateway)
    os.makedirs(os.path.join(ROOT,"private"),exist_ok=True)
    write_path(os.path.join(ROOT,"private","P25Hosts.json"),gateway)
    return full

def main():
    if "--p25-only" in sys.argv:
        p=build_p25_outputs()
        print("P25 主机库已重建：%d 条（含 ARCH 46002）" % len(p.get("reflectors",[])))
        return
    dm=dmr(); y=ysf(); p=build_p25_outputs(); n=reflectors("NXDN_Hosts.txt")
    _nx=[r for r in n.get("reflectors",[])
         if not (str(r.get("designator"))==str(_ARCH_NXDN["designator"])
                 or str(r.get("ipv4"))==_ARCH_NXDN["ipv4"])]
    _nx.insert(0,dict(_ARCH_NXDN)); n={"reflectors":_nx}
    write("DMR_Hosts.json",dm); write("YSFHosts.json",y)
    write("NXDNHosts.json",n); write("FCSHosts.json",fcs()); write("DSTAR_Reflectors.json",dstar())
    idx={"built":"","counts":{"DMR":len(dm),"YSF":len(y.get("reflectors",[])),
                               "P25":len(p.get("reflectors",[])),"NXDN":len(n.get("reflectors",[]))}}
    write("hosts-index.json",idx)
    print("主机库已重建：DMR %d，YSF %d，P25 %d，NXDN %d"%(idx["counts"]["DMR"],idx["counts"]["YSF"],idx["counts"]["P25"],idx["counts"]["NXDN"]))

if __name__=="__main__": main()
```

### `usr/local/libexec/ini-edit.py`

```python
#!/usr/bin/env python3
import configparser, os, sys
if len(sys.argv)!=6 or sys.argv[2]!="set":
    print("用法: ini-edit.py FILE set SECTION KEY VALUE",file=sys.stderr); raise SystemExit(2)
p,_,sec,key,val=sys.argv[1:]
c=configparser.RawConfigParser(); c.optionxform=str
try:c.read(p,encoding="utf-8")
except Exception:pass
if not c.has_section(sec):c.add_section(sec)
c.set(sec,key,val)
t=p+".tmp"
with open(t,"w",encoding="utf-8") as f:c.write(f)
os.replace(t,p)
```

### `usr/local/libexec/mmdvm-save.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""ARCH 热点参数保存工具。"""
import configparser
import glob
import json
import os
import re
import subprocess
import sys

HOST = "/etc/MMDVM-Host.ini"
DMRGW = "/etc/DMRGateway.ini"
PLAN = "/etc/mmdvm/channels.json"
DMR_READY = "/etc/mmdvm/dmr-ready"
YSFGW = "/etc/YSFGateway.ini"
P25GW = "/etc/P25Gateway.ini"
NXDNGW = "/etc/NXDNGateway.ini"
IRCDDB = "/etc/ircddbgateway"
LIMIT_SCRIPT = "/usr/local/libexec/arch-channel-limit.py"


def run(cmd, timeout=40):
    try:
        return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                              text=True, timeout=timeout)
    except Exception as e:
        return type("R", (), {"returncode": -1, "stdout": "", "stderr": str(e)})()


def ini(path):
    cp = configparser.RawConfigParser()
    cp.optionxform = str
    try:
        cp.read(path, encoding="utf-8")
    except Exception:
        pass
    return cp


def save_ini(cp, path):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        for sec in cp.sections():
            f.write("[" + sec + "]\n")
            for k, v in cp.items(sec, raw=True):
                f.write(str(k) + "=" + str(v) + "\n")
    os.replace(tmp, path)


def set_sec(cp, sec, key, val):
    if not cp.has_section(sec):
        cp.add_section(sec)
    cp.set(sec, key, str(val))

def _gateway_span(lines, section):
    header = '[' + section.lower() + ']'
    start = None
    for i, line in enumerate(lines):
        if line.strip().lower() == header:
            start = i + 1
            break
    if start is None:
        lines.extend(['\n[' + section + ']\n'])
        start = len(lines)
    end = start
    while end < len(lines):
        s = lines[end].strip()
        if s.startswith('[') and s.endswith(']'):
            break
        end += 1
    return start, end


def _gateway_set(lines, section, key, value):
    start, end = _gateway_span(lines, section)
    target = key.lower() + '='
    for i in range(start, end):
        if lines[i].strip().lower().startswith(target):
            lines[i] = key + '=' + str(value) + '\n'
            return
    lines.insert(end, key + '=' + str(value) + '\n')


def _gateway_strip(lines, section, prefixes):
    start, end = _gateway_span(lines, section)
    i = start
    while i < end:
        key = lines[i].split('=', 1)[0].strip().lower() if '=' in lines[i] else ''
        if any(key.startswith(x.lower()) for x in prefixes):
            del lines[i]
            end -= 1
        else:
            i += 1


def save_dmr_gateway(path, values):
    with open(path, encoding='utf-8', errors='replace') as f:
        lines = f.readlines()
    for sec, kv in values.items():
        for key, val in kv.items():
            _gateway_set(lines, sec, key, val)
    _gateway_strip(lines, 'DMR Network 1', ('PassAllTG','PassAllPC','TGRewrite','PCRewrite','TypeRewrite','SrcRewrite','TGDynRewrite'))
    start, end = _gateway_span(lines, 'DMR Network 1')
    rules = ['PassAllTG=1\n','PassAllTG=2\n','PassAllPC=1\n','PassAllPC=2\n',
             'TGRewrite=1,9,1,9,1\n','TGRewrite=2,9,2,9,1\n']
    lines[end:end] = rules
    tmp = path + '.tmp'
    with open(tmp, 'w', encoding='utf-8') as f:
        f.writelines(lines)
    os.replace(tmp, path)


def set_flat(path, values):
    lines = []
    try:
        with open(path, encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        pass
    seen = set()
    out = []
    for line in lines:
        s = line.strip()
        if "=" in s and not s.startswith(("#", ";", "[")):
            k = s.split("=", 1)[0].strip()
            if k in values:
                out.append("%s=%s\n" % (k, values[k]))
                seen.add(k)
                continue
        out.append(line if line.endswith("\n") else line + "\n")
    for k, v in values.items():
        if k not in seen:
            out.append("%s=%s\n" % (k, v))
    with open(path, "w", encoding="utf-8") as f:
        f.writelines(out)


def clean_call(v):
    return re.sub(r"[^A-Za-z0-9]", "", str(v or "")).upper()[:16]


def clean_id(v):
    s = re.sub(r"\D", "", str(v or ""))
    return s[:9]


def clean_essid(v):
    s = re.sub(r"\D", "", str(v or ""))
    if not s:
        return ""
    try:
        n = int(s)
    except Exception:
        raise ValueError("ESSID 必须是 01-99 之间的两位数字")
    if n < 1 or n > 99:
        raise ValueError("ESSID 必须是 01-99 之间的两位数字")
    return "%02d" % n


def freq(v, old="0"):
    try:
        n = int(float(str(v).strip()))
        return str(n) if n > 0 else str(old)
    except Exception:
        return str(old)


def host_paths():
    paths = [HOST]
    paths += sorted(glob.glob("/etc/MMDVM-Host-*.ini"))
    return list(dict.fromkeys(paths))


def set_identity(callsign, dmr_id="", freqs=None):
    for p in host_paths():
        cp = ini(p)
        set_sec(cp, "General", "Callsign", callsign)
        if dmr_id:
            set_sec(cp, "General", "Id", dmr_id)
        if freqs:
            rx, tx = freqs
            set_sec(cp, "General", "RXFrequency", rx)
            set_sec(cp, "General", "TXFrequency", tx)
            set_sec(cp, "Info", "RXFrequency", rx)
            set_sec(cp, "Info", "TXFrequency", tx)
            set_sec(cp, "Modem", "RXFrequency", rx)
            set_sec(cp, "Modem", "TXFrequency", tx)
        save_ini(cp, p)


def active_channels():
    try:
        r = run([LIMIT_SCRIPT], 8)
        limit = max(1, min(7, int((r.stdout or "7").strip())))
    except Exception:
        limit = 7
    try:
        d = json.load(open(PLAN, encoding="utf-8"))
        return [int(x.get("n", 1)) for x in d.get("list", []) if x.get("mode") not in (None, "", "NONE")][:limit]
    except Exception:
        return [1]


def load_plan():
    try:
        with open(PLAN, encoding="utf-8") as f:
            return json.load(f) or {}
    except Exception:
        return {}


def plan_by_channel(plan=None):
    plan = plan or load_plan()
    result = {}
    for item in plan.get("list", []):
        try:
            result[int(item.get("n"))] = item
        except Exception:
            pass
    return result


def write_plan(plan):
    os.makedirs(os.path.dirname(PLAN), exist_ok=True)
    tmp = PLAN + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(plan, f, ensure_ascii=False, indent=2)
        f.write("\n")
    os.replace(tmp, PLAN)


def update_plan_identity(callsign="", dmr_id=""):
    plan = load_plan()
    if not plan:
        return
    if callsign:
        plan["callsign"] = callsign
    if dmr_id:
        plan["dmr_id"] = dmr_id
    write_plan(plan)


def host_channel_number(path):
    m = re.search(r"/etc/MMDVM-Host-(\d+)\.ini$", path)
    if m:
        return int(m.group(1))
    return 1


def sync_host_identity_and_frequencies(callsign="", dmr_id=""):
    """同步身份，并用通道规划修正 Host 元数据频点，避免页面/电台频率漂移。"""
    by_channel = plan_by_channel()
    for path in host_paths():
        cp = ini(path)
        if callsign:
            set_sec(cp, "General", "Callsign", callsign)
        if dmr_id:
            set_sec(cp, "General", "Id", dmr_id)
        item = by_channel.get(host_channel_number(path))
        if item:
            rx, tx = item.get("rx"), item.get("tx")
            if rx:
                set_sec(cp, "General", "RXFrequency", rx)
                set_sec(cp, "Info", "RXFrequency", rx)
                set_sec(cp, "Modem", "RXFrequency", rx)
            if tx:
                set_sec(cp, "General", "TXFrequency", tx)
                set_sec(cp, "Info", "TXFrequency", tx)
                set_sec(cp, "Modem", "TXFrequency", tx)
        save_ini(cp, path)


def restart_channels(mode=None):
    for n in active_channels():
        if mode is None:
            run(["systemctl", "restart", "mmdvm-iq@%d.service" % n], 30)
        run(["systemctl", "restart", "mmdvmhost@%d.service" % n], 30)
    run(["systemctl", "restart", "mmdvm-multi.service"], 40)


def save_rf(a):
    call, dmr_id = clean_call(a[0]), clean_id(a[1])
    update_plan_identity(call, dmr_id)
    sync_host_identity_and_frequencies(call, dmr_id)
    restart_channels()
    print("基础射频参数已保存；实际频点以通道规划为准，未被改写")


def save_dmr(a):
    call = clean_call(a[0])
    base_id = clean_id(a[1])
    cc = a[2] or "1"
    bm_host, bm_port, bm_pw = a[5] or "", a[6] or "62031", a[7] or ""
    essid = clean_essid(a[8]) if len(a) > 8 else ""
    if not call or call == "NOCALL" or not base_id or base_id == "0":
        raise ValueError("呼号或 DMR ID 未填写")
    if essid and len(base_id) != 7:
        raise ValueError("启用 ESSID 时，DMR ID 必须是 7 位数字")
    effective_id = base_id + essid if essid else base_id
    dmr_items = [x for x in plan_by_channel().values() if str(x.get("mode", "")).upper() == "DMR"]
    if not dmr_items:
        raise ValueError("当前通道规划中没有 DMR 通道，请先在射频模式配置中设置")

    # 通道规划保存基础 DMR ID；ESSID 只叠加到 DMR 主机和 DMRGateway，
    # 避免把 9 位扩展 ID 写到 P25/YSF/NXDN/D-STAR 等其他模式。
    update_plan_identity(call, base_id)
    sync_host_identity_and_frequencies(call, base_id)
    for item in dmr_items:
        n = int(item.get("n"))
        p = "/etc/MMDVM-Host-%d.ini" % n
        cp = ini(p)
        set_sec(cp, "General", "Callsign", call)
        set_sec(cp, "General", "Id", effective_id)
        for sec in ("General", "Info", "Modem"):
            set_sec(cp, sec, "RXFrequency", item.get("rx"))
            set_sec(cp, sec, "TXFrequency", item.get("tx"))
        set_sec(cp, "DMR", "Enable", "1")
        set_sec(cp, "DMR", "ColorCode", cc)
        set_sec(cp, "DMR Network", "Enable", "1")
        save_ini(cp, p)
    primary = sorted(dmr_items, key=lambda x: int(x.get("n", 0)))[0]
    rx, tx = str(primary.get("rx") or ""), str(primary.get("tx") or "")
    values = {
        "General": {"Id": effective_id, "Callsign": call},
        "Info": {"Callsign": call, "URL": "www.qrz.com/db/" + call,
                 "TXFrequency": tx, "RXFrequency": rx, "ColorCode": cc,
                 "Slot1": "1", "Slot2": "1"},
        "DMR Network 1": {"Enabled": "1", "Name": "BM", "Id": effective_id,
                          "Address": bm_host or "", "Port": bm_port,
                          "Password": bm_pw or "", "Location": "1", "Debug": "0"},
    }
    save_dmr_gateway(DMRGW, values)
    os.makedirs(os.path.dirname(DMR_READY), exist_ok=True)
    open(DMR_READY, "w").write(effective_id + "\n")
    restart_channels()
    run(["systemctl", "restart", "dmrgateway.service"], 40)
    print("DMR 参数已保存（ID=%s）；Host 频点已按通道规划校正，DMRGateway 已启动" % effective_id)


def _restart_gateway(name):
    run(["systemctl", "restart", name + ".service"], 40)


def save_ysf(a):
    call = clean_call(a[0]); dmr_id = clean_id(a[1]) if len(a) > 1 else ""
    suffix = (a[2] if len(a) > 2 and a[2] else "RPT").upper()
    startup = a[3] if len(a) > 3 else ""
    set_identity(call, dmr_id)
    cp = ini(YSFGW)
    set_sec(cp, "General", "Callsign", call)
    set_sec(cp, "General", "Suffix", suffix)
    if dmr_id:
        set_sec(cp, "General", "Id", dmr_id)
    if startup:
        set_sec(cp, "Network", "Startup", startup)
    if len(a) > 4 and str(a[4]).strip() != "":
        set_sec(cp, "General", "WiresXCommandPassthrough", a[4])
    save_ini(cp, YSFGW)
    restart_channels()
    _restart_gateway("ysfgateway")
    print("YSF 参数已保存，YSFGateway 已重载")


def save_p25(a):
    call = clean_call(a[0] if a else "")
    set_identity(call)
    cp = ini(P25GW)
    set_sec(cp, "General", "Callsign", call)
    # ARCH P25 固定使用 46002；页面其他预设不写入网关静态表，避免无效反射器告警。
    set_sec(cp, "Network", "Static", "46002")
    save_ini(cp, P25GW)
    restart_channels()
    _restart_gateway("p25gateway")
    print("P25 参数已保存，P25Gateway 已重载")


def save_nxdn(a):
    call = clean_call(a[0] if a else "")
    nxdn_id = clean_id(a[1]) if len(a) > 1 else ""
    protocol = a[3] if len(a) > 3 and a[3] else "Icom"
    static = a[4] if len(a) > 4 else ""
    # 只更新 NXDN 页面所属的通道，避免覆盖 DMR / P25 的身份。
    for n, item in plan_by_channel().items():
        if str(item.get("mode", "")).upper() != "NXDN":
            continue
        hp = "/etc/MMDVM-Host-%d.ini" % n
        h = ini(hp)
        set_sec(h, "General", "Callsign", call)
        if nxdn_id:
            set_sec(h, "General", "Id", nxdn_id)
        save_ini(h, hp)
    cp = ini(NXDNGW)
    set_sec(cp, "General", "Callsign", call)
    if nxdn_id:
        set_sec(cp, "General", "Id", nxdn_id)
    set_sec(cp, "General", "RptProtocol", protocol)
    if static:
        set_sec(cp, "Network", "Static", static)
    save_ini(cp, NXDNGW)
    restart_channels()
    _restart_gateway("nxdngateway")
    print("NXDN 参数已保存，NXDNGateway 已重载")


def save_dstar(a):
    gw_call = clean_call(a[0] if a else "")
    rep_call = clean_call(a[1] if len(a) > 1 else "") or gw_call
    module = (a[2] if len(a) > 2 and a[2] else "B").upper()[:1]
    irc_user = a[3] if len(a) > 3 else ""
    irc_pw = a[4] if len(a) > 4 else ""
    reflector = a[7] if len(a) > 7 else ""
    startup = a[8] if len(a) > 8 else "1"
    routing = a[9] if len(a) > 9 else "1"
    dplus = a[11] if len(a) > 11 else ""
    ircddb_host = a[12] if len(a) > 12 else ""
    set_identity(gw_call)
    values = {
        "gatewayCallsign": gw_call,
        "repeaterCall1": rep_call,
        "repeaterBand1": module,
        "ircddbUsername": irc_user,
        "ircddbPassword": irc_pw,
        "ircddbEnabled": routing or "1",
        "atStartup1": startup or "0",
    }
    if reflector:
        values["reflector1"] = reflector
    if ircddb_host:
        values["ircddbHostname"] = ircddb_host
    if str(dplus).strip() in ("0", "1"):
        values["dplusEnabled"] = str(dplus)
    set_flat(IRCDDB, values)
    restart_channels()
    _restart_gateway("ircddbgatewayd")
    print("D-STAR 参数已保存，ircDDBGateway 已重载")


def save_fm(a):
    call = clean_call(a[0] if a else "")
    set_identity(call)
    ctcss = a[1] if len(a) > 1 and a[1] else "88.5"
    p = "/etc/MMDVM-Host.ini"
    cp = ini(p)
    for n in active_channels():
        hp = "/etc/MMDVM-Host-%d.ini" % n
        h = ini(hp)
        plan = json.load(open(PLAN, encoding="utf-8")) if os.path.exists(PLAN) else {}
        item = next((x for x in plan.get("list", []) if int(x.get("n", 0)) == n), {})
        if str(item.get("mode", "")).upper() != "FM":
            continue
        set_sec(h, "FM", "Enable", "1")
        set_sec(h, "FM", "CTCSSFrequency", ctcss)
        set_sec(h, "FM", "AccessMode", "1")
        set_sec(h, "FM Network", "Enable", "1")
        set_sec(h, "FM Network", "GatewayAddress", "127.0.0.1")
        set_sec(h, "FM Network", "GatewayPort", "49880")
        save_ini(h, hp)
    set_sec(cp, "FM", "Enable", "1")
    save_ini(cp, p)
    restart_channels()
    print("FM 参数已保存")


def save_pocsag(a):
    p = "/etc/MMDVM-Host.ini"
    cp = ini(p)
    set_sec(cp, "POCSAG", "Enable", "1")
    set_sec(cp, "POCSAG", "Frequency", a[0] if a else "439500000")
    save_ini(cp, p)
    restart_channels()
    print("POCSAG 参数已保存")


def cmd_bminfo():
    cp = ini(DMRGW)
    sec = "DMR Network 1"
    out = {"callsign": cp.get("General", "Callsign", fallback=""),
           "id": cp.get("General", "Id", fallback=""),
           "host": cp.get(sec, "Address", fallback=""),
           "port": cp.get(sec, "Port", fallback="62031"),
           "enabled": cp.get(sec, "Enabled", fallback="0"),
           "ready": os.path.exists(DMR_READY)}
    print(json.dumps(out, ensure_ascii=False))


def main():
    mode = (sys.argv[1] if len(sys.argv) > 1 else "").upper()
    args = sys.argv[2:]
    try:
        if mode == "BMINFO": cmd_bminfo()
        elif mode == "RF": save_rf(args)
        elif mode == "DMR": save_dmr(args)
        elif mode == "YSF": save_ysf(args)
        elif mode == "P25": save_p25(args)
        elif mode == "NXDN": save_nxdn(args)
        elif mode == "D-STAR": save_dstar(args)
        elif mode == "FM": save_fm(args)
        elif mode == "POCSAG": save_pocsag(args)
        else:
            raise ValueError("未知保存模式: %s" % mode)
        return 0
    except Exception as e:
        print(str(e), file=sys.stderr)
        return 1


if __name__ == "__main__":
    raise SystemExit(main())
```

### `usr/local/libexec/mmdvm-status.py`

```python
#!/usr/bin/env python3
"""MMDVM-IQ 状态输出（QSO 从 journald 和日志文件双读）"""
import subprocess, os, re, json, glob, sys

_FAST = len(sys.argv) > 1 and sys.argv[1] == "quick"


def _active_channel_count():
    """当前实际启动的通道数；历史 Host-N.ini 不代表正在运行。"""
    try:
        with open("/etc/mmdvm/channels.json", encoding="utf-8") as f:
            n = int((json.load(f) or {}).get("channels") or 0)
        if n > 0:
            return max(1, min(11, n))
    except Exception:
        pass
    try:
        txt = open("/etc/MMDVM-Multi.ini", encoding="utf-8", errors="ignore").read()
        m = re.search(r"(?mi)^\s*Channels\s*=\s*(\d+)", txt)
        if m:
            return max(1, min(11, int(m.group(1))))
    except Exception:
        pass
    return 1


def _compute_modes():
    """动态统计当前运行通道模式（只扫描前 N 路 Host 配置）。"""
    secs = [("DMR", "DMR"), ("D-STAR", "D-Star"), ("YSF", "System Fusion"),
            ("P25", "P25"), ("NXDN", "NXDN"), ("FM", "FM"), ("POCSAG", "POCSAG")]
    modes = []
    n = _active_channel_count()
    for i in range(1, n + 1):
        try:
            t = open("/etc/MMDVM-Host-%d.ini" % i, encoding="utf-8", errors="ignore").read()
        except Exception:
            continue
        cur = None
        for ln in t.split("\n"):
            ln = ln.strip()
            if ln.startswith("["):
                cur = ln.strip("[]")
            elif ln == "Enable=1" and cur:
                for short, sec in secs:
                    if cur == sec and short not in modes:
                        modes.append(short)
    return ("MODE=" + "+".join(modes) + "\nCHANNELS=%d" % n) if modes else "MODE=NONE"



def get_qso():
    """从 journald 和日志文件收集 QSO。
    ★ 性能：一次 journalctl 批量拉取全部相关单元（json 输出），
    不再逐通道各跑一次（旧版 8 次调用，每次 0.5~1.5 秒）。"""
    all_lines = []
    units = ["mmdvmhost.service"] + ["mmdvmhost@%d.service" % i for i in range(1, 12)] + ["fmo-bridge.service"]
    try:
        cmd = ["journalctl"]
        for unit in units:
            cmd += ["-u", unit]
        cmd += ["-n", "600", "--no-pager", "-o", "json"]
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=18)
        if r.returncode == 0:
            for raw in r.stdout.splitlines():
                try:
                    obj = json.loads(raw)
                except Exception:
                    continue
                msg = str(obj.get("MESSAGE") or "")
                unit = str(obj.get("_SYSTEMD_UNIT") or "")
                m = re.search(r"mmdvmhost@(\d+)\.service$", unit)
                if m:
                    msg = "[CH%s] %s" % (m.group(1), msg)
                if msg:
                    all_lines.append(msg)
    except Exception:
        pass

    # 日志文件备用（journald 不可用时才读）
    if not all_lines:
        files = []
        for pat in ("/var/log/mmdvm/MMDVM-Host-*.log", "/var/log/mmdvm/MMDVM-Host*.log"):
            files.extend(glob.glob(pat))
        for f in sorted(set(files), reverse=True)[:2]:
            try:
                with open(f, encoding="utf-8", errors="ignore") as fh:
                    all_lines.extend(fh.readlines()[-200:])
            except Exception:
                pass

    KWS = (
        "received rf end of voice",
        "received network end of voice",
        "received rf voice header",
        "received network voice header",
        # ★ YSF/P25/NXDN 网络语音没有 header 行，只有“开始行”
        "received network data from",
        "received network transmission",
        "received rf voice transmission",
        "network watchdog has expired",
        "d-star, received rf header",
        "d-star, received network header",
        "d-star, received rf end of transmission",
        "d-star, received network end of transmission",
        "d-star, received rf end of voice",
        "d-star, received network end of voice",
        "received rf header",
        "received network header",
        "end of transmission",
    )
    qso, seen = [], set()
    for line in all_lines:
        low = line.lower()
        if any(kw in low for kw in KWS):
            key = re.sub(r"\s+", " ", line.strip())[:160]
            if key not in seen:
                seen.add(key)
                qso.append(line.rstrip())

    def _ts_of(x):
        m = re.search(r"(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})", x)
        return m.group(1) if m else ""

    qso.sort(key=_ts_of, reverse=True)
    return qso[:80]

# ==================== ★ 快速模式：只输出通话记录（供 Live QSO 1 秒轮询，跳过服务/网络/磁盘）====================
if len(sys.argv) > 1 and sys.argv[1] == "qso":
    print("=== QSO ===")
    _q = get_qso()
    if _q:
        for _l in _q:
            print(_l)
    else:
        print("无通话记录")
    sys.exit(0)

# === SERVICES ===
def _any_active(units):
    """多信道架构下 mmdvmhost/mmdvm-iq 是实例化单元，需逐个探测"""
    for u in units:
        try:
            r = subprocess.run(["systemctl", "is-active", "--quiet", u],
                               capture_output=True, timeout=3)
            if r.returncode == 0:
                return True
        except Exception:
            pass
    return False


_UNITS = [
    ("mmdvm-iq",  ["mmdvm-iq@%d.service" % i for i in (1, 2, 3, 4, 5, 6)] + ["mmdvm-iq.service"]),
    ("mmdvmhost", ["mmdvmhost@%d.service" % i for i in (1, 2, 3, 4, 5, 6)] + ["mmdvmhost.service"]),
    ("mmdvm-multi", ["mmdvm-multi.service"]),
    ("dmrgateway", ["dmrgateway.service"]),
    ("ysfgateway", ["ysfgateway.service"]),
    ("p25gateway", ["p25gateway.service"]),
    ("nxdngateway", ["nxdngateway.service"]),
    ("ircddbgatewayd", ["ircddbgatewayd.service"]),
    ("mosquitto", ["mosquitto.service"]),
    ("mmdvm-web", ["mmdvm-web.service"]),
]
print("=== SERVICES ===")
_all_units = sorted({u for _name, _units in _UNITS for u in _units})
_active_set = set()
try:
    _r = subprocess.run(["systemctl", "is-active"] + _all_units,
                        capture_output=True, text=True, timeout=12)
    _active_set = {u for u, state in zip(_all_units, _r.stdout.splitlines())
                   if state.strip() == "active"}
except Exception:
    pass
for _name, _units in _UNITS:
    print("%s %s" % (_name, "active" if any(u in _active_set for u in _units) else "inactive"))

# === PROFILE ===
print("=== PROFILE ===")
try:
    content = _compute_modes()
    print(content if content else "MODE=未知")
except Exception:
    print("MODE=未知")

# === SYSTEM ===
print("=== SYSTEM ===")

try:
    with open("/sys/class/thermal/thermal_zone0/temp") as f:
        cpu = int(f.read().strip()) / 1000.0
    print(f"cpu_temp={cpu:.1f}")
except Exception:
    print("cpu_temp=N/A")

try:
    d = {}
    with open("/proc/meminfo") as f:
        for line in f:
            k, v = line.split(":", 1)
            d[k] = int(v.strip().split()[0])
    total_kb = d.get("MemTotal", 0)
    avail_kb = d.get("MemAvailable", 0)
    used_kb = total_kb - avail_kb
    print(f"mem={used_kb//1024}M/{total_kb//1024}M")
except Exception:
    print("mem=N/A")

try:
    with open("/proc/uptime") as f:
        secs = float(f.read().split()[0])
    days = int(secs // 86400); hours = int((secs % 86400) // 3600)
    mins = int((secs % 3600) // 60)
    if days > 0: print(f"uptime=up {days} days, {hours} hours")
    elif hours > 0: print(f"uptime=up {hours} hours, {mins} mins")
    else: print(f"uptime=up {mins} mins")
except Exception:
    print("uptime=N/A")

try:
    with open("/proc/loadavg") as f:
        parts = f.read().split()
    print(f"load={parts[0]}, {parts[1]}, {parts[2]}")
except Exception:
    print("load=N/A")

# ★ CPU 实时占用率：/proc/stat 两次采样（0.25 秒）
try:
    def _read_stat():
        with open("/proc/stat") as f:
            fld = f.readline().split()
        vals = [int(x) for x in fld[1:8]]          # user nice system idle iowait irq softirq
        total = sum(vals)
        idle = vals[3] + vals[4]                   # idle + iowait
        return total, idle
    _t1, _i1 = _read_stat()
    import time as _time
    _time.sleep(0.25)
    _t2, _i2 = _read_stat()
    _dt = _t2 - _t1
    if _dt > 0:
        _pct = 100.0 * (_dt - (_i2 - _i1)) / _dt
        print(f"cpu_usage={_pct:.0f}%")
    else:
        print("cpu_usage=N/A")
except Exception:
    print("cpu_usage=N/A")

try:
    st = os.statvfs("/")
    total = st.f_blocks * st.f_frsize; free = st.f_bavail * st.f_frsize
    used = total - free; pct = (used * 100 // total) if total else 0
    print(f"disk={used//(1024**3)}G/{total//(1024**3)}G ({pct}%)")
except Exception:
    print("disk=N/A")

print("sx_probe=OK" if os.path.exists("/etc/mmdvm/soapy-sx-verified")
      else "sx_probe=FAIL")

# === NETWORK ===
print("=== NETWORK ===")
try:
    r = subprocess.run(["hostname","-I"], capture_output=True, text=True, timeout=3)
    ips = r.stdout.strip().split()
    print(f"ip={ips[0] if ips else '127.0.0.1'}")
except Exception:
    print("ip=127.0.0.1")

try:
    r = subprocess.run(["ip","route","show"], capture_output=True, text=True, timeout=3)
    gw = ""
    for line in r.stdout.split("\n"):
        if line.startswith("default"):
            parts = line.split()
            if "via" in parts:
                gw = parts[parts.index("via")+1]; break
    print(f"gateway={gw or 'N/A'}")
except Exception:
    print("gateway=N/A")

try:
    dns = ""
    with open("/etc/resolv.conf") as f:
        for line in f:
            if line.startswith("nameserver"):
                dns = line.split()[1]; break
    print(f"dns={dns or 'N/A'}")
except Exception:
    print("dns=N/A")

# === QSO ===
print("=== QSO ===")
if _FAST:
    print("无通话记录")
else:
    qso = get_qso()
    if qso:
        for line in qso:
            print(line)
    else:
        print("无通话记录")
```

### `usr/local/libexec/tetra-api.py`

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""tetra-api.py —— TDR Link 后端（供 Web 控制台 /tetra 调用，需 root）

数据模型（按需求）：
    4 个 TETRA 射频通道（编号 7..10），每个通道：
        · 载波号（频率 = 载波号 × 25kHz）与 FDD 收发频率
        · 3 个通话时隙 TS2/TS3/TS4（TS1 = 控制信令时隙，不参与通话展示）
    另外：网络标识 MCC/MNC、色码、SDR 参数（采样率/增益/ppm）

命令：status / get / save <json> / start / stop / restart / logs <n> / switch <mmdvm|tetra>
"""
import json
import os
import re
import subprocess
import sys
import time

TETRA_DIR = "/opt/tetra"
CFG = os.path.join(TETRA_DIR, "config.toml")
UI_CFG = os.path.join(TETRA_DIR, "ui-config.json")
SVC = "tetra-bs.service"
BSTATION = "/opt/tetra-src/flowstation/target/release/bluestation-bs"
MMDVM_UNITS = ["mmdvm-multi", "fmo-bridge"] + \
              ["mmdvm-iq@%d" % i for i in range(1, 7)] + \
              ["mmdvmhost@%d" % i for i in range(1, 7)]

BREW_DEFAULTS = {
    "enable": True,
    "mcc": 901, "mnc": 9999, "color_code": 1, "duplex_spacing_mhz": 0.0,
    "sample_rate": 150000, "ppm_err": 0.0, "freq_band": 4, "callsign": "",
    "carrier": 1576,              # 主载波（频率 = 载波号 × 25kHz）
    "dual_carrier": False,         # 双载波开关（开启时额外占用紧邻 25kHz）
    "secondary_carrier": 1577,    # 次载波（通常与主载波相差 1 = +25kHz）
    "ts": {"ts2": True, "ts3": True, "ts4": True},   # 通话时隙（TS1 为控制时隙）
}
DEFAULTS = {
    "mcc": 901, "mnc": 9999, "color_code": 1, "duplex_spacing_mhz": 0.0,
    "sample_rate": 150000, "ppm_err": 0.0, "freq_band": 4, "callsign": "",
    "carrier": 1576,              # 主载波（频率 = 载波号 × 25kHz）
    "dual_carrier": False,         # 双载波开关（开启时额外占用紧邻的 25kHz）
    "secondary_carrier": 1577,    # 次载波（通常与主载波相差 1 = +25kHz）
    "ts": {"ts2": True, "ts3": True, "ts4": True},
}

# ── 联网服务器（Brew）默认列表 ──
# 从 176 设备同步的 TetraPack 官方服务器作为默认条目
# 用户可自行添加/修改/删除；username/password 需用户自己填写
DEFAULT_NET_SERVERS = [
    {
        "id": "tetrapack",
        "name": "TDR Link Pack 服务器",
        "host": "core.tetrapack.online",
        "port": 443,
        "tls": True,
        "dmr_id": "",
        "username": "",
        "password": "",
        "reconnect_delay_secs": 10,
        "feature_sds_enabled": True,
    },
    {
        "id": "arch",
        "name": "ARCH TDR Link 服务器",
        "host": "tetra-brew.hljrsa.cn",
        "port": 9100,
        "tls": True,
        "dmr_id": "",
        "username": "",
        "password": "",
        "reconnect_delay_secs": 10,
        "feature_sds_enabled": True,
    },
]
DEFAULT_ACTIVE_SERVER = "tetrapack"

# ── 高级参数默认值（从 176 设备同步） ──
ADV_DEFAULTS = {
    "cell_info.location_area": 2,
    "cell_info.colour_code": 1,
    "cell_info.freq_offset": 0,
    "cell_info.reverse_operation": False,
    "cell_info.hangtime_secs": 5,
    "cell_info.call_timeout_secs": 600,
    "cell_info.ul_inactivity_secs": 3,
    "cell_info.periodic_registration_secs": 3600,
    "cell_info.voice_service": True,
    "cell_info.system_wide_services": True,
    "cell_info.timezone": "Asia/Shanghai",
    "cell_info.dgna_use_ss_facility": True,
    "cell_info.dgna_attachment_mode": 0,
    "security.whitelist_mode": "off",
    "recovery.enabled": False,
    "recovery.reactive_enabled": True,
}


def sh(cmd, timeout=60):
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return r.returncode, r.stdout.strip(), r.stderr.strip()
    except Exception as e:
        return -1, "", str(e)


def is_active(unit):
    return sh(["systemctl", "is-active", unit], timeout=8)[1].strip() == "active"


def _split_login_username(username):
    value = str(username or "").strip()
    if value.isdigit() and len(value) == 9:
        return value[:7], value[7:9]
    if value.isdigit() and len(value) == 7:
        return value, ""
    return "", ""


def _normalize_servers(servers):
    merged = {}
    for raw in servers if isinstance(servers, list) else []:
        if not isinstance(raw, dict):
            continue
        sid = str(raw.get("id") or "").strip()
        if sid == "arch93full":
            sid = "arch"
        if sid not in ("tetrapack", "arch"):
            continue
        item = dict(raw)
        item["id"] = sid
        dmr_id = str(item.get("dmr_id") or "").strip()
        if not dmr_id:
            dmr_id, _ = _split_login_username(item.get("username", ""))
        item["dmr_id"] = dmr_id
        item.pop("essid", None)
        if dmr_id:
            item["username"] = dmr_id
        else:
            item["username"] = ""
        item["password"] = str(item.get("password") or "")
        item["port"] = int(item.get("port") or (443 if sid == "tetrapack" else 9100))
        item["tls"] = bool(item.get("tls", True))
        item["reconnect_delay_secs"] = int(item.get("reconnect_delay_secs") or 10)
        item["feature_sds_enabled"] = bool(item.get("feature_sds_enabled", True))
        if sid == "tetrapack":
            item["name"] = "TDR Link Pack 服务器"
            if not item.get("host"):
                item["host"] = "core.tetrapack.online"
        else:
            item["name"] = "ARCH TDR Link 服务器"
            host = str(item.get("host") or "").strip()
            if not host or host == "192.168.1.20":
                host = "tetra-brew.hljrsa.cn"
            item["host"] = host
            if item["port"] == 80:
                item["port"] = 9100
            item["tls"] = True
        merged[sid] = item
    for default in json.loads(json.dumps(DEFAULT_NET_SERVERS)):
        merged.setdefault(default["id"], default)
    return [merged["tetrapack"], merged["arch"]]


def load_ui():
    d = None
    if os.path.exists(UI_CFG):
        try:
            d = json.load(open(UI_CFG, encoding="utf-8"))
        except Exception:
            d = None
    if not d:
        d = json.loads(json.dumps(DEFAULTS))
    for k, v in DEFAULTS.items():
        d.setdefault(k, v)
    if not isinstance(d.get("ts"), dict):
        d["ts"] = json.loads(json.dumps(DEFAULTS["ts"]))
    b = d.setdefault("brew", {})
    for k, v in BREW_DEFAULTS.items():
        b.setdefault(k, v)
    d["net_servers"] = _normalize_servers(d.get("net_servers"))
    ids = {s["id"] for s in d["net_servers"]}
    if d.get("active_server") not in ids:
        d["active_server"] = DEFAULT_ACTIVE_SERVER
    return d

def save_ui(d):
    os.makedirs(TETRA_DIR, exist_ok=True)
    tmp = UI_CFG + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(d, f, ensure_ascii=False, indent=2)
    os.replace(tmp, UI_CFG)

def _carrier_offset(freq_band=4):
    """Bands are 100 MHz wide in FlowStation's carrier formula."""
    return int(freq_band or 0) * 4000


def carrier_abs(carrier, freq_band=4):
    n = int(carrier)
    off = _carrier_offset(freq_band)
    return n + off if off and n < off else n


def carrier_rel(carrier, freq_band=4):
    n = int(carrier)
    off = _carrier_offset(freq_band)
    return n - off if off and n >= off else n


def carrier_hz(carrier, freq_band=4):
    """Carrier number to downlink frequency using the TETRA 100 MHz band base."""
    return carrier_abs(carrier, freq_band) * 25000


def freq_to_band_carrier(freq_hz):
    """Convert 350-500 MHz to FlowStation band + 25 kHz carrier number."""
    freq_hz = int(round(float(freq_hz)))
    if freq_hz < 350_000_000 or freq_hz > 500_000_000:
        raise ValueError("频率必须在 350.000-500.000 MHz 之间")
    mhz = freq_hz / 1e6
    band = int(mhz // 100)
    base = band * 100_000_000
    rel = int(round((freq_hz - base) / 25_000.0))
    aligned = base + rel * 25_000
    if abs(aligned - freq_hz) > 1:
        raise ValueError("频率必须按 25 kHz 栅格填写")
    if rel < 0 or rel >= 4000:
        raise ValueError("频率无法换算为有效载波号")
    return band, rel, band * 4000 + rel


def _reverse_enabled(d):
    adv = d.get("adv") if isinstance(d.get("adv"), dict) else {}
    value = d.get("reverse_operation", adv.get("cell_info.reverse_operation", False))
    return str(value).strip().lower() in ("1", "true", "yes", "on")


def chan_freqs(d, c):
    band = int(d.get("freq_band", 4) or 4)
    tx = carrier_hz(c["carrier"], band)
    spacing = float(d.get("duplex_spacing_mhz") or 0)
    if spacing <= 0:
        spacing = 10.0
    delta = int(round(spacing * 1e6))
    rx = tx + delta if _reverse_enabled(d) else tx - delta
    return rx, tx

def cmd_status():
    d = load_ui()
    band = int(d.get("freq_band", 4) or 4)
    main_rel = carrier_rel(d.get("carrier", 0), band)
    sec_rel = carrier_rel(d.get("secondary_carrier", 0), band)
    spacing = float(d.get("duplex_spacing_mhz") or 0)
    if spacing <= 0 and band == 4:
        spacing = 10.0
    rx, tx = chan_freqs(d, {"carrier": main_rel})
    cfg = dict(d)
    cfg["carrier"] = main_rel
    cfg["secondary_carrier"] = sec_rel
    cfg["duplex_spacing_mhz"] = spacing
    sec = None
    if d.get("dual_carrier"):
        srx, stx = chan_freqs(d, {"carrier": sec_rel})
        sec = {"carrier": sec_rel, "rx": srx, "tx": stx}
    return {"ok": True,
            "service": "active" if is_active(SVC) else "inactive",
            "binary": os.path.exists(BSTATION),
            "cfg": cfg,
            "carrier": {"carrier": main_rel, "rx": rx, "tx": tx,
                        "ts": d.get("ts", {})},
            "secondary": sec,
            "bandwidth_khz": 50 if d.get("dual_carrier") else 25,
            "mmdvm": "active" if is_active("mmdvm-multi") else "inactive",
            "sdr_owner": sdr_owner()}


def cmd_get():
    return {"ok": True, "cfg": load_ui()}


def cmd_save(body):
    d = load_ui()
    restart = bool(body.get("restart", False))
    if isinstance(body.get("adv"), dict):
        d.setdefault("adv", {}).update(body["adv"])

    if "tx_freq_hz" in body or "rx_freq_hz" in body:
        try:
            tx = int(round(float(body.get("tx_freq_hz"))))
            rx = int(round(float(body.get("rx_freq_hz"))))
        except Exception:
            return {"ok": False, "error": "TX/RX 频率格式不正确"}
        try:
            band, rel, abs_carrier = freq_to_band_carrier(tx)
            freq_to_band_carrier(rx)
        except ValueError as e:
            return {"ok": False, "error": str(e)}
        spacing_hz = abs(rx - tx)
        if spacing_hz < 1_000_000 or spacing_hz > 100_000_000:
            return {"ok": False, "error": "双工间隔必须在 1-100 MHz 之间"}
        d["freq_band"] = band
        d["carrier"] = abs_carrier
        d["secondary_carrier"] = band * 4000 + (rel + 1 if rel < 3999 else rel - 1)
        d["duplex_spacing_mhz"] = spacing_hz / 1e6
        d["reverse_operation"] = rx > tx
        d.setdefault("adv", {})["cell_info.reverse_operation"] = d["reverse_operation"]
    else:
        for k in ("mcc", "mnc", "color_code", "duplex_spacing_mhz", "sample_rate",
                  "ppm_err", "freq_band", "callsign", "reverse_operation"):
            if k in body:
                d[k] = body[k]
        if isinstance(body.get("brew"), dict):
            b = d.setdefault("brew", {})
            for k in BREW_DEFAULTS:
                if k in body["brew"]:
                    b[k] = body["brew"][k]
        if isinstance(body.get("net_servers"), list):
            d["net_servers"] = body["net_servers"]
        if "active_server" in body:
            d["active_server"] = body["active_server"]
        for k in ("carrier", "secondary_carrier"):
            if k in body:
                try:
                    d[k] = carrier_abs(int(body[k]), d.get("freq_band", 4))
                except Exception:
                    pass

    if "dual_carrier" in body:
        d["dual_carrier"] = bool(body["dual_carrier"])
    if isinstance(body.get("ts"), dict):
        d["ts"] = {k: bool(v) for k, v in body["ts"].items()
                   if k in ("ts2", "ts3", "ts4")}
    save_ui(d)
    toml = write_toml(d)
    if restart:
        _restart_tetra_if_active(clear_recovery=True)
    return {"ok": not restart or is_active(SVC), "cfg": d, "toml": toml,
            "service": "active" if is_active(SVC) else "inactive",
            "restart": restart}

def cmd_servers_list():
    """返回联网服务器列表及当前活跃服务器。"""
    d = load_ui()
    return {
        "ok": True,
        "servers": d.get("net_servers", []),
        "active_server": d.get("active_server", DEFAULT_ACTIVE_SERVER),
    }


def _validate_server_profile(item):
    dmr_id = str(item.get("dmr_id") or "").strip()
    if dmr_id and (not dmr_id.isdigit() or len(dmr_id) != 7):
        raise ValueError("DMR ID 必须是 7 位数字")
    if not dmr_id:
        dmr_id, _ = _split_login_username(item.get("username", ""))
    if dmr_id and (not dmr_id.isdigit() or len(dmr_id) != 7):
        raise ValueError("DMR ID 必须是 7 位数字")
    return dmr_id, dmr_id


def cmd_servers_save(body):
    """保存两份服务器配置，选择当前服务器，并写入 [brew] 后重启。"""
    d = load_ui()
    incoming = body.get("servers") if isinstance(body.get("servers"), list) else []
    current = {s["id"]: s for s in d.get("net_servers", [])}
    try:
        for raw in incoming:
            if not isinstance(raw, dict):
                continue
            sid = "arch" if str(raw.get("id")) == "arch93full" else str(raw.get("id") or "")
            if sid not in ("tetrapack", "arch"):
                continue
            item = dict(current.get(sid, {}))
            item.update(raw)
            item["id"] = sid
            item["name"] = "TDR Link Pack 服务器" if sid == "tetrapack" else "ARCH TDR Link 服务器"
            item["host"] = str(item.get("host") or "").strip()
            item["port"] = int(item.get("port") or (443 if sid == "tetrapack" else 9100))
            item["tls"] = bool(item.get("tls", True))
            item["password"] = str(item.get("password") or "")
            item["reconnect_delay_secs"] = int(item.get("reconnect_delay_secs") or 10)
            item["feature_sds_enabled"] = bool(item.get("feature_sds_enabled", True))
            dmr_id, username = _validate_server_profile(item)
            item["dmr_id"], item["username"] = dmr_id, username
            item.pop("essid", None)
            current[sid] = item
    except ValueError as e:
        return {"ok": False, "error": str(e)}
    d["net_servers"] = _normalize_servers([current["tetrapack"], current["arch"]])
    active = "arch" if str(body.get("active_server")) in ("arch", "arch93full") else "tetrapack"
    d["active_server"] = active
    active_brew = _get_active_brew(d)
    d["brew"] = dict(active_brew)
    d["brew"]["enable"] = True
    save_ui(d)
    toml = write_toml(d)
    _restart_tetra_if_active(clear_recovery=True)
    return {"ok": is_active(SVC), "active_server": active,
            "servers": d["net_servers"], "toml": toml,
            "service": "active" if is_active(SVC) else "inactive"}

def cmd_server_switch(body):
    """切换当前使用的联网服务器。"""
    d = load_ui()
    target_id = "arch" if str(body.get("id")) == "arch93full" else str(body.get("id") or "")
    servers = d.get("net_servers", [])
    found = next((srv for srv in servers if srv.get("id") == target_id), None)
    if not found:
        return {"ok": False, "error": "未找到服务器: %s" % target_id}
    if not found.get("host"):
        return {"ok": False, "error": "该服务器未配置地址"}
    d["active_server"] = target_id
    d["brew"] = dict(_get_active_brew(d))
    d["brew"]["enable"] = True
    save_ui(d)
    toml = write_toml(d)
    _restart_tetra_if_active(clear_recovery=True)
    return {"ok": is_active(SVC), "active_server": target_id,
            "brew": _get_active_brew(d), "toml": toml,
            "service": "active" if is_active(SVC) else "inactive"}

def cmd_server_add(body):
    """添加联网服务器"""
    d = load_ui()
    servers = d.setdefault("net_servers", [])
    srv = {
        "id": body.get("id") or ("srv_%d" % int(time.time() * 1000)),
        "name": body.get("name", "新服务器"),
        "host": body.get("host", ""),
        "port": int(body.get("port", 443)),
        "tls": bool(body.get("tls", True)),
        "username": body.get("username", ""),
        "password": body.get("password", ""),
        "reconnect_delay_secs": int(body.get("reconnect_delay_secs", 10)),
        "feature_sds_enabled": bool(body.get("feature_sds_enabled", True)),
    }
    if not srv["host"]:
        return {"ok": False, "error": "服务器地址不能为空"}
    servers.append(srv)
    # 如果没有活跃服务器，自动设为当前活跃
    if not d.get("active_server"):
        d["active_server"] = srv["id"]
    save_ui(d)
    return {"ok": True, "servers": servers, "added": srv["id"],
            "active_server": d.get("active_server")}


def cmd_server_update(body):
    """更新联网服务器"""
    d = load_ui()
    servers = d.get("net_servers", [])
    sid = body.get("id", "")
    for i, srv in enumerate(servers):
        if srv.get("id") == sid:
            for k in ("name", "host", "port", "tls", "username", "password",
                       "reconnect_delay_secs", "feature_sds_enabled"):
                if k in body:
                    if k in ("port", "reconnect_delay_secs"):
                        servers[i][k] = int(body[k])
                    elif k in ("tls", "feature_sds_enabled"):
                        servers[i][k] = bool(body[k])
                    else:
                        servers[i][k] = body[k]
            save_ui(d)
            return {"ok": True, "servers": servers, "updated": sid}
    return {"ok": False, "error": "未找到服务器: %s" % sid}


def cmd_server_delete(body):
    """删除联网服务器"""
    d = load_ui()
    servers = d.get("net_servers", [])
    sid = body.get("id", "")
    before = len(servers)
    d["net_servers"] = [s for s in servers if s.get("id") != sid]
    if len(d["net_servers"]) < before:
        save_ui(d)
        return {"ok": True, "servers": d["net_servers"], "deleted": sid}
    return {"ok": False, "error": "未找到服务器: %s" % sid}



def _setk_any(text, key, val):
    """替换 key=... 行；若该行是注释形式（# key = ...）也能替换并取消注释。"""
    pat = re.compile(r"^#?\s*%s\s*=.*$" % re.escape(key), flags=re.M)
    if pat.search(text):
        return pat.sub("%s = %s" % (key, val), text, count=1)
    return text


def write_toml(d):
    """按界面配置更新 config.toml，并保留其余内容。"""
    if not os.path.exists(CFG):
        return "（config.toml 尚未生成，仅保存了界面配置）"
    s = open(CFG, encoding="utf-8", errors="ignore").read()
    rx, tx = chan_freqs(d, {"carrier": d.get("carrier", 0)})
    spacing_hz = int(round(float(d.get("duplex_spacing_mhz") or 10) * 1e6))
    reverse = _reverse_enabled(d)

    def setk(text, key, val):
        if re.search(r"^%s\s*=.*$" % re.escape(key), text, flags=re.M):
            return re.sub(r"^%s\s*=.*$" % re.escape(key), "%s = %s" % (key, val),
                          text, flags=re.M)
        return text

    def set_any(text, key, val):
        if re.search(r"^#?\s*%s\s*=.*$" % re.escape(key), text, flags=re.M):
            return re.sub(r"^#?\s*%s\s*=.*$" % re.escape(key), "%s = %s" % (key, val),
                          text, flags=re.M, count=1)
        return text

    s = setk(s, "tx_freq", tx)
    s = setk(s, "rx_freq", rx)
    s = setk(s, "mcc", d["mcc"])
    s = setk(s, "mnc", d["mnc"])
    s = set_any(s, "freq_band", d["freq_band"])
    s = set_any(s, "main_carrier", carrier_rel(d.get("carrier", 0), d.get("freq_band", 4)))
    s = set_any(s, "secondary_carrier", carrier_rel(d.get("secondary_carrier", 0), d.get("freq_band", 4)))
    s = set_any(s, "freq_offset", 0)
    s = set_any(s, "duplex_spacing", 0)
    standard_10mhz = int(d.get("freq_band", 4)) == 4 and spacing_hz == 10_000_000
    if standard_10mhz:
        # Band 4 duplex index 0 already means the standard 10 MHz spacing.
        # Omitting the custom override keeps the air interface identical to the known-good config.
        if re.search(r"^custom_duplex_spacing\s*=", s, flags=re.M):
            s = re.sub(r"^custom_duplex_spacing\s*=\s*([^#\n]+).*$",
                       lambda m: "# custom_duplex_spacing = " + m.group(1).strip(),
                       s, flags=re.M, count=1)
    elif re.search(r"^#?\s*custom_duplex_spacing\s*=", s, flags=re.M):
        s = set_any(s, "custom_duplex_spacing", spacing_hz)
    else:
        s = re.sub(r"^(duplex_spacing\s*=.*)$", r"\1\ncustom_duplex_spacing = %d" % spacing_hz,
                   s, count=1, flags=re.M)
    s = set_any(s, "reverse_operation", "true" if reverse else "false")
    if re.search(r"^#?\s*dual_carrier_enabled\s*=", s, flags=re.M):
        s = re.sub(r"^#?\s*dual_carrier_enabled\s*=.*$",
                   "dual_carrier_enabled = %s" % ("true" if d.get("dual_carrier") else "false"),
                   s, flags=re.M)
    else:
        s = re.sub(r"^(secondary_carrier\s*=.*)$",
                   r"\1\ndual_carrier_enabled = %s" % ("true" if d.get("dual_carrier") else "false"),
                   s, count=1, flags=re.M)
    if re.search(r"^sample_rate\s*=", s, flags=re.M):
        s = setk(s, "sample_rate", d["sample_rate"])
    s = _write_brew(s, d)
    try:
        os.makedirs(TETRA_DIR, exist_ok=True)
        tmp = CFG + ".tmp.arch-write"
        with open(tmp, "w", encoding="utf-8") as f:
            f.write(s)
        os.replace(tmp, CFG)
        return "已写入 config.toml：%d 频段载波 %d → 下行 %.4f / 上行 %.4f MHz，双工 %.4f MHz" % (
            int(d.get("freq_band", 4)), carrier_rel(d.get("carrier", 0), d.get("freq_band", 4)),
            tx / 1e6, rx / 1e6, spacing_hz / 1e6)
    except Exception as e:
        return "写入失败：%s" % e

def _wait_state(units, want_active, timeout=30.0):
    """轮询等待一组单元达到目标状态（比固定 sleep 快且可靠）"""
    t0 = time.time()
    while time.time() - t0 < timeout:
        cur = [is_active(u) for u in units]
        if all(cur) if want_active else (not any(cur)):
            return True
        time.sleep(0.25)
    return False


def sdr_owner():
    """谁在占用 SX1255（MMDVM / TETRA / 空闲）"""
    if is_active("mmdvm-multi"):
        return "MMDVM"
    if is_active(SVC):
        return "TETRA"
    return "空闲"



def _channels_to_run():
    """按规划文件返回需要运行的 IQ/Host 单元（跳过隔离带 mode=NONE）"""
    try:
        d = json.load(open("/etc/mmdvm/channels.json", encoding="utf-8"))
        ns = [c["n"] for c in d.get("list", [])
              if c.get("mode") and str(c["mode"]).upper() != "NONE"]
        if ns:
            return [("mmdvm-iq@%d" % n) for n in ns], [("mmdvmhost@%d" % n) for n in ns]
    except Exception:
        pass
    return IQ_UNITS, HOST_UNITS


def _all_units():
    """互斥启停涉及的全部单元：按规划动态取（mode!=NONE 的通道），兜底 IQ_UNITS/HOST_UNITS"""
    iq_u, host_u = _channels_to_run()
    return ["mmdvm-multi", "fmo-bridge"] + list(iq_u) + list(host_u)


def _stop_mmdvm():
    """并行停掉 MMDVM 全部单元（按 channels.json 动态取）"""
    units = _all_units()
    sh(["systemctl", "stop"] + units, timeout=45)
    _wait_state(units, False, 12.0)


def _start_mmdvm():
    """按依赖顺序起，但用等待式校验代替固定 sleep"""
    sh(["systemctl", "start", "mmdvm-multi"], timeout=60)
    if not _wait_state(["mmdvm-multi"], True, 25.0):
        return False
    iq_u, host_u = _channels_to_run()
    sh(["systemctl", "start"] + iq_u, timeout=60)
    sh(["systemctl", "start"] + host_u, timeout=60)
    ok = _wait_state(host_u, True, 30.0)
    sh(["systemctl", "start", "fmo-bridge"], timeout=30)
    return ok


def _start_tetra():
    sh(["systemctl", "start", SVC], timeout=60)
    return _wait_state([SVC], True, 20.0) and         not sh(["systemctl", "is-active", SVC])[1].strip() == "failed"


def cmd_start():
    _stop_mmdvm()
    sh(["systemctl", "start", SVC], timeout=60)
    ok = _wait_state([SVC], True, 20.0)
    return {"ok": ok, "service": "active" if is_active(SVC) else "inactive",
            "sdr_owner": sdr_owner(),
            "note": "MMDVM 各通道已停止（一块 SX1255 同一时刻只能被一个程序独占）"}


def cmd_stop():
    sh(["systemctl", "stop", SVC], timeout=40)
    return {"ok": True, "service": "inactive", "sdr_owner": sdr_owner()}


def _clear_recovery_cache():
    """主动重启前清空旧终端恢复缓存，避免旧会话占用控制信道。"""
    path = os.path.join(TETRA_DIR, "recovery_cache.json")
    try:
        os.makedirs(TETRA_DIR, exist_ok=True)
        tmp = path + ".tmp.arch-clear"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump({"version": 1, "terminals": []}, f, ensure_ascii=False, indent=2)
        os.replace(tmp, path)
        return True
    except Exception:
        return False


def _restart_tetra_if_active(clear_recovery=False):
    """如果 TETRA 服务正在运行，重启它以应用新配置。"""
    if clear_recovery:
        _clear_recovery_cache()
    if is_active(SVC):
        sh(["systemctl", "restart", SVC], timeout=60)
        _wait_state([SVC], True, 15.0)


def cmd_switch(what):
    """切换 SDR 归属；失败自动回滚"""
    before = sdr_owner()
    t0 = time.time()
    if what == "tetra":
        _stop_mmdvm()
        sh(["systemctl", "start", SVC], timeout=60)
        ok = _wait_state([SVC], True, 20.0)
    else:
        sh(["systemctl", "stop", SVC], timeout=40)
        _wait_state([SVC], False, 10.0)
        ok = _start_mmdvm()
    dt = time.time() - t0
    if not ok:
        if what == "tetra":
            sh(["systemctl", "stop", SVC], timeout=30)
            _start_mmdvm()
        else:
            _stop_mmdvm()
            sh(["systemctl", "start", SVC], timeout=60)
        return {"ok": False, "error": "切换失败，已回滚到 %s" % before,
                "switched": what, "elapsed": round(dt, 1), "sdr_owner": sdr_owner()}
    return {"ok": True, "switched": what, "elapsed": round(dt, 1),
            "sdr_owner": sdr_owner(),
            "tetra": "active" if is_active(SVC) else "inactive",
            "mmdvm": "active" if is_active("mmdvm-multi") else "inactive",
            "channels_up": sum(1 for u in _channels_to_run()[1] if is_active(u))}


def cmd_logs(lines):
    n = max(10, min(500, int(lines or 120)))
    rc, out, _ = sh(["journalctl", "-u", SVC, "-n", str(n), "--no-pager"], timeout=30)
    return {"ok": True, "text": out or "(暂无日志)"}


BREW_KEYS = ("host", "port", "tls", "username", "password",
             "reconnect_delay_secs")


def _get_active_brew(d):
    """当前 active_server 优先于旧的 brew 备忘配置。"""
    active_id = d.get("active_server", DEFAULT_ACTIVE_SERVER)
    servers = d.get("net_servers", [])
    for srv in servers:
        if srv.get("id") == active_id and srv.get("host"):
            return {
                "enable": True,
                "host": srv["host"],
                "port": int(srv.get("port", 443)),
                "tls": bool(srv.get("tls", True)),
                "username": srv.get("username", ""),
                "password": srv.get("password", ""),
                "reconnect_delay_secs": int(srv.get("reconnect_delay_secs", 10)),
                "feature_sds_enabled": bool(srv.get("feature_sds_enabled", True)),
            }
    return d.get("brew", {})

def _write_brew(text, d):
    """启用时把 [brew] 段取消注释并写入参数；禁用时整段注释掉。"""
    b = _get_active_brew(d)
    lines = text.split("\n")
    # 定位 [brew] 段落范围（含被注释的形式）
    start = None
    for i, ln in enumerate(lines):
        if re.match(r"^\s*#?\s*\[brew\]\s*$", ln):
            start = i
            break
    if start is None:
        return text
    end = len(lines)
    for j in range(start + 1, len(lines)):
        if re.match(r"^\s*#?\s*\[[a-zA-Z_]+\]", lines[j]):
            end = j
            break
    seg = lines[start:end]

    def existing(k):
        for ln in seg:
            m = re.match(r"^\s*#?\s*%s\s*=\s*(.*?)\s*$" % re.escape(k), ln)
            if m:
                return m.group(1)
        return None

    def val(k):
        v = b.get(k)
        if k in ("username", "password"):
            sv = "" if v is None else str(v).strip()
            if (not sv) or sv == "••••••":
                ev = existing(k)
                if ev:
                    return ev
        if k == "username":
            sv = "" if v is None else str(v).strip()
            if not sv.isdigit():
                ev = existing(k)
                if ev and str(ev).strip().isdigit():
                    return ev
            if sv.isdigit():
                return sv
        if isinstance(v, bool):
            return "true" if v else "false"
        if isinstance(v, str):
            return '"%s"' % v.replace('"', '')
        return str(v)

    if b.get("enable"):
        out = ["[brew]"]
        for k in BREW_KEYS:
            line = "%s = %s" % (k, val(k))
            for ln in seg:
                if re.match(r"^\s*#?\s*%s\s*=" % k, ln):
                    line = "%s\n%s" % (ln.replace("# ", "").rstrip(), "") if False else line
                    break
            out.append(line)
        # 保留段落里其它未被覆盖的注释行，便于人工查阅
        out.append("")
        return "\n".join(lines[:start] + out + lines[end:])
    else:
        out = ["# [brew]"]
        for ln in seg[1:]:
            t = ln.rstrip()
            if re.match(r"^\s*#?\s*(host|port|tls|username|password|reconnect_delay_secs)\s*=", t):
                out.append("# " + re.sub(r"^\s*#\s*", "", t))
            else:
                out.append(ln)
        return "\n".join(lines[:start] + out + lines[end:])


import urllib.request
PROFILE_DIR = os.path.join(TETRA_DIR, "profiles")


def _http_json(path, timeout=6):
    try:
        with urllib.request.urlopen("http://127.0.0.1:8080" + path, timeout=timeout) as r:
            return json.loads(r.read().decode("utf-8", "ignore"))
    except Exception:
        return None


def cmd_dash():
    running = is_active(SVC)
    owner = sdr_owner()
    return {"ok": True, "running": running, "sdr": {"owner": owner, "holder": owner},
            "note": "" if running else "TETRA not running",
            "btsinfo": _http_json("/api/btsinfo") or {},
            "system": _http_json("/api/system") or {},
            "dualcarrier": _http_json("/api/dualcarrier") or {},
            "asterisk": _http_json("/api/asterisk/status") or {},
            "version": "FlowStation"}



def cmd_ready():
    running = is_active(SVC)
    bts = _http_json("/api/btsinfo", timeout=2)
    return {"ok": True, "ready": bool(running and bts),
            "service": "active" if running else "inactive",
            "http_ready": bool(bts), "sdr_owner": sdr_owner()}

def _cfg_lines():
    with open(CFG, encoding="utf-8") as f:
        return f.readlines()


def _cfg_write(lines):
    tmp = CFG + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.writelines(lines)
    os.replace(tmp, CFG)


def _section_range(lines, section):
    head = "[" + section + "]"
    idx = None
    for i, ln in enumerate(lines):
        if ln.strip() == head:
            idx = i
            break
    if idx is None:
        return None, None
    for j in range(idx + 1, len(lines)):
        st = lines[j].strip()
        if st.startswith("[") and st.endswith("]"):
            return idx, j
    return idx, len(lines)


def _section_set(section, updates):
    lines = _cfg_lines()
    start, end = _section_range(lines, section)
    if start is None:
        lines.append("\n[" + section + "]\n")
        for k, v in updates.items():
            lines.append(k + " = " + str(v) + "\n")
        _cfg_write(lines)
        return True
    done = set()
    for i in range(start + 1, end):
        st = lines[i].strip()
        if not st or st.startswith("#") or "=" not in st:
            continue
        k = st.split("=", 1)[0].strip()
        if k in updates and k not in done:
            lines[i] = k + " = " + str(updates[k]) + "\n"
            done.add(k)
    for k, v in updates.items():
        if k not in done:
            lines.insert(end, k + " = " + str(v) + "\n")
            end += 1
    _cfg_write(lines)
    return True


def _section_get(section):
    out = {}
    lines = _cfg_lines()
    start, end = _section_range(lines, section)
    if start is None:
        return out
    for ln in lines[start + 1:end]:
        st = ln.strip()
        if not st or st.startswith("#") or "=" not in st:
            continue
        k, v = st.split("=", 1)
        out[k.strip()] = v.strip().strip('"')
    return out


def cmd_whitelist(op="get", arg=""):
    sec = _section_get("security")
    mode = sec.get("whitelist_mode", "off")
    cur = [int(x) for x in re.findall(r"\d+", sec.get("issi_whitelist", "[]"))]
    if op in ("get", "", None):
        return {"ok": True, "mode": mode, "whitelist": cur, "restricted": mode not in ("off", "open")}
    if op == "set":
        new = [int(x) for x in re.findall(r"\d+", str(arg))]
        if not new:
            return {"ok": False, "error": "empty whitelist not allowed"}
        _section_set("security", {"whitelist_mode": '"enforce"', "issi_whitelist": "[" + ", ".join(str(x) for x in new) + "]"})
        return {"ok": True, "mode": "enforce", "whitelist": new}
    if op == "open":
        _section_set("security", {"whitelist_mode": '"off"', "issi_whitelist": "[]"})
        return {"ok": True, "mode": "off", "whitelist": []}
    if op in ("add", "remove"):
        nums = re.findall(r"\d+", str(arg))
        if not nums:
            return {"ok": False, "error": "bad ISSI"}
        isi = int(nums[0])
        if op == "add" and isi not in cur:
            cur.append(isi)
        if op == "remove" and isi in cur:
            cur.remove(isi)
        if not cur:
            return {"ok": False, "error": "empty whitelist not allowed"}
        _section_set("security", {"whitelist_mode": '"enforce"', "issi_whitelist": "[" + ", ".join(str(x) for x in cur) + "]"})
        return {"ok": True, "mode": "enforce", "whitelist": cur}
    return {"ok": False, "error": "unknown whitelist op"}


def cmd_asterisk_get():
    sec = _section_get("asterisk")
    keys = ["enabled", "remote_host", "remote_port", "contact_host", "from_domain", "local_user",
            "auth_user", "realm", "codec", "outbound_prefix", "bind_addr", "bind_port",
            "rtp_port_min", "rtp_port_max", "service_numbers"]
    cfg = {}
    for k in keys:
        cfg[k] = sec.get(k, "")
    cfg["hasPassword"] = bool(sec.get("password"))
    return {"ok": True, "running": is_active(SVC), "config": cfg, "status": _http_json("/api/asterisk/status") or {}}


def cmd_asterisk_save(body):
    d = body if isinstance(body, dict) else json.loads(body or "{}")
    upd = {}
    for k in ["enabled", "remote_host", "remote_port", "contact_host", "from_domain", "local_user",
              "auth_user", "realm", "codec", "outbound_prefix", "bind_addr", "bind_port",
              "rtp_port_min", "rtp_port_max"]:
        if k in d and str(d.get(k)) != "":
            v = str(d.get(k))
            upd[k] = v if v.lower() in ("true", "false") else json.dumps(v, ensure_ascii=False)
    if "service_numbers" in d:
        nums = d.get("service_numbers")
        if isinstance(nums, str):
            nums = [x.strip() for x in nums.split(",") if x.strip()]
        upd["service_numbers"] = "[" + ", ".join(json.dumps(str(x), ensure_ascii=False) for x in (nums or [])) + "]"
    if str(d.get("password") or "").strip():
        upd["password"] = json.dumps(str(d["password"]), ensure_ascii=False)
    if upd:
        _section_set("asterisk", upd)
    if is_active(SVC):
        sh(["systemctl", "restart", SVC], timeout=60)
    return {"ok": True, "saved": sorted(upd.keys())}


def _profile_path(name):
    safe = re.sub(r"[^A-Za-z0-9_.-]", "_", str(name or "").strip())[:40]
    return os.path.join(PROFILE_DIR, safe + ".toml"), safe


def cmd_profiles_list():
    os.makedirs(PROFILE_DIR, exist_ok=True)
    cur = ""
    try:
        cur = open(CFG, encoding="utf-8").read()
    except Exception:
        pass
    out = []
    for fn in sorted(os.listdir(PROFILE_DIR)):
        if not fn.endswith(".toml"):
            continue
        p = os.path.join(PROFILE_DIR, fn)
        try:
            txt = open(p, encoding="utf-8").read()
        except Exception:
            txt = ""
        out.append({"name": fn[:-5], "size": len(txt), "active": bool(cur) and txt == cur,
                    "mtime": time.strftime("%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(p)))})
    return {"ok": True, "profiles": out}


def cmd_profile_save(name):
    p, safe = _profile_path(name)
    if not safe:
        return {"ok": False, "error": "empty name"}
    os.makedirs(PROFILE_DIR, exist_ok=True)
    shutil.copy2(CFG, p)
    return {"ok": True, "name": safe}


def cmd_profile_activate(name):
    p, safe = _profile_path(name)
    if not os.path.exists(p):
        return {"ok": False, "error": "no such profile"}
    shutil.copy2(CFG, CFG + ".bak.before-profile-" + time.strftime("%Y%m%d-%H%M%S"))
    shutil.copy2(p, CFG)
    _restart_tetra_if_active(clear_recovery=True)
    return {"ok": True, "name": safe, "service": "active" if is_active(SVC) else "inactive"}


def cmd_profile_delete(name):
    p, safe = _profile_path(name)
    if not os.path.exists(p):
        return {"ok": False, "error": "no such profile"}
    shutil.move(p, "/tmp/" + os.path.basename(p) + ".deleted-" + time.strftime("%Y%m%d-%H%M%S"))
    return {"ok": True, "name": safe}


def main():
    a = sys.argv[1:]
    cmd = a[0] if a else "status"
    try:
        if cmd == "status":
            r = cmd_status()
        elif cmd == "get":
            r = cmd_get()
        elif cmd == "save":
            r = cmd_save(json.loads(a[1]) if len(a) > 1 else {})
        elif cmd == "start":
            r = cmd_start()
        elif cmd == "stop":
            r = cmd_stop()
        elif cmd == "restart":
            r = cmd_stop()
            r = cmd_start()
        elif cmd == "switch":
            r = cmd_switch(a[1] if len(a) > 1 else "tetra")
        elif cmd == "logs":
            r = cmd_logs(a[1] if len(a) > 1 else 120)
        elif cmd == "servers":
            r = cmd_servers_list()
        elif cmd == "server-add":
            r = cmd_server_add(json.loads(a[1]) if len(a) > 1 else {})
        elif cmd == "server-update":
            r = cmd_server_update(json.loads(a[1]) if len(a) > 1 else {})
        elif cmd == "server-delete":
            r = cmd_server_delete(json.loads(a[1]) if len(a) > 1 else {})
        elif cmd == "server-switch":
            r = cmd_server_switch(json.loads(a[1]) if len(a) > 1 else {})
        elif cmd == "server-save":
            r = cmd_servers_save(json.loads(a[1]) if len(a) > 1 else {})
        elif cmd == "dash":
            r = cmd_dash()
        elif cmd == "ready":
            r = cmd_ready()
        elif cmd == "whitelist":
            r = cmd_whitelist(a[1] if len(a) > 1 else "get", a[2] if len(a) > 2 else "")
        elif cmd == "asterisk-get":
            r = cmd_asterisk_get()
        elif cmd == "asterisk-save":
            r = cmd_asterisk_save(a[1] if len(a) > 1 else "{}")
        elif cmd == "profiles":
            r = cmd_profiles_list()
        elif cmd == "profile-save":
            r = cmd_profile_save(a[1] if len(a) > 1 else "")
        elif cmd == "profile-activate":
            r = cmd_profile_activate(a[1] if len(a) > 1 else "")
        elif cmd == "profile-delete":
            r = cmd_profile_delete(a[1] if len(a) > 1 else "")
        else:
            r = {"ok": False, "error": "未知命令: %s" % cmd}
    except Exception as e:
        import traceback
        r = {"ok": False, "error": str(e), "trace": traceback.format_exc()[-500:]}
    print(json.dumps(r, ensure_ascii=False))


if __name__ == "__main__":
    main()
```

### `_build/installer_template.sh`

```bash
#!/usr/bin/env bash
set -euo pipefail
CHANNEL="@CHANNEL@"
VERSION="@VERSION@"
ROOT="$(cd "$(dirname "$0")" && pwd)"
if [ "$(id -u)" -ne 0 ]; then echo "请使用 sudo 运行"; exit 1; fi
export DEBIAN_FRONTEND=noninteractive
FIRST_INSTALL=0
[ -f /etc/arch-hotspot-release ] || FIRST_INSTALL=1
apt-get update
apt-get install -y --no-install-recommends python3 python3-pip nginx network-manager git curl wget ca-certificates alsa-utils
apt-get install -y --no-install-recommends python3-flask gunicorn python3-paho-mqtt python3-cbor2 python3-nacl || true
if ! python3 -c 'import flask,gunicorn,paho.mqtt,cbor2,nacl' >/dev/null 2>&1; then
  mkdir -p /opt/arch-deps/wheels
  [ -f /opt/arch-deps/arch-python-wheels.tar.gz ] || curl -fsSL "https://arch.hljrsa.cn/downloads/arch-hotspot/deps/arch-python-wheels.tar.gz" -o /opt/arch-deps/arch-python-wheels.tar.gz
  tar -xzf /opt/arch-deps/arch-python-wheels.tar.gz -C /opt/arch-deps/wheels
  python3 -m pip install --break-system-packages --no-index --find-links=/opt/arch-deps/wheels Flask gunicorn paho-mqtt cbor2 pynacl
fi
install -m 0755 "$ROOT/build_upstream.sh" /usr/local/bin/arch-build-upstream
mkdir -p /etc/mmdvm
if [ ! -x /usr/local/bin/MMDVM-Multi ] || ! find /usr/local/lib /usr/lib -type f -name '*SXSupport*.so' 2>/dev/null | grep -q .; then
  /usr/local/bin/arch-build-upstream
elif [ ! -f /etc/mmdvm/arch-build-version ] || [ "$(cat /etc/mmdvm/arch-build-version 2>/dev/null || true)" != "$VERSION" ]; then
  FORCE_MMDVM_IQ=1 /usr/local/bin/arch-build-upstream
fi
printf '%s\n' "$VERSION" > /etc/mmdvm/arch-build-version
if find /usr/local/lib /usr/lib -type f -name '*SXSupport*.so' 2>/dev/null | grep -q .; then mkdir -p /etc/mmdvm; touch /etc/mmdvm/soapy-sx-verified; fi
getent passwd mmdvm-web >/dev/null || useradd --system --home /opt/mmdvm-web --shell /usr/sbin/nologin mmdvm-web
install -d -m 0755 /opt/mmdvm-web /opt/mmdvm-web/data /opt/fmo-bridge /opt/fmo-bridge/pki /var/log/mmdvm /var/backups/mmdvm /etc/mmdvm /etc/systemd/system /usr/local/bin /usr/local/libexec /usr/local/etc /usr/local/etc/hosts
cp -a "$ROOT/opt/mmdvm-web/." /opt/mmdvm-web/
cp -a "$ROOT/opt/fmo-bridge/." /opt/fmo-bridge/
cp -a "$ROOT/usr/local/bin/." /usr/local/bin/
cp -a "$ROOT/usr/local/libexec/." /usr/local/libexec/
cp -a "$ROOT/etc/systemd/system/." /etc/systemd/system/
cp -a "$ROOT/etc/nginx/sites-available/mmdvm-web" /etc/nginx/sites-available/mmdvm-web
rm -f /etc/nginx/sites-enabled/default
ln -sf /etc/nginx/sites-available/mmdvm-web /etc/nginx/sites-enabled/mmdvm-web
/usr/sbin/nginx -t
for f in channels.json frequency.json active-mode.conf fm-tailtone.json fw-url install-state.conf bm-masters.json; do [ -f "$ROOT/etc/mmdvm/$f" ] && cp -n "$ROOT/etc/mmdvm/$f" "/etc/mmdvm/$f"; done
for f in "$ROOT"/etc/*.ini; do
  [ -f "$f" ] || continue
  if [ "$FIRST_INSTALL" -eq 1 ]; then
    cp -f "$f" "/etc/$(basename "$f")"
  else
    cp -n "$f" "/etc/$(basename "$f")"
  fi
done
if [ -f "$ROOT/etc/ircddbgateway" ]; then
  if [ "$FIRST_INSTALL" -eq 1 ]; then
    cp -f "$ROOT/etc/ircddbgateway" /etc/ircddbgateway
  else
    cp -n "$ROOT/etc/ircddbgateway" /etc/ircddbgateway
  fi
fi
if [ ! -f /opt/fmo-bridge/config.json ]; then cp "$ROOT/opt/fmo-bridge/config.template.json" /opt/fmo-bridge/config.json; fi
chmod 0755 /usr/local/bin/*.sh /usr/local/bin/*.py /usr/local/libexec/*.py 2>/dev/null || true
[ -f /etc/mmdvm/bm-masters.json ] || printf '[]\n' > /etc/mmdvm/bm-masters.json
[ -f /usr/local/etc/DMRIds.dat ] || : > /usr/local/etc/DMRIds.dat
[ -f /usr/local/etc/nextionUsers.csv ] || : > /usr/local/etc/nextionUsers.csv
[ -f /usr/local/etc/NXDN.csv ] || : > /usr/local/etc/NXDN.csv
[ -f /opt/mmdvm-web/data/radioid_cache.json ] || printf '{}\n' > /opt/mmdvm-web/data/radioid_cache.json
/usr/local/libexec/hosts-build.py >/dev/null 2>&1 || true
/usr/local/libexec/channel-setup.py apply "$(cat /etc/mmdvm/channels.json)"
install -m 0755 "$ROOT/arch-hotspot-update" /usr/local/bin/arch-hotspot-update
cat > /etc/arch-hotspot-release <<EOF
CHANNEL=$CHANNEL
VERSION=$VERSION
UPDATE_BASE=https://arch.hljrsa.cn/downloads/arch-hotspot
EOF
if [ "$CHANNEL" = "public" ]; then systemctl disable --now tetra-bs.service 2>/dev/null || true; fi
cat > /etc/sudoers.d/arch-web <<'SUDO'
Defaults:mmdvm-web !requiretty
mmdvm-web ALL=(root) NOPASSWD: /usr/local/bin/web-manage.sh *, /usr/local/bin/web-fmo.sh *, /usr/local/bin/web-multi.sh *, /usr/local/bin/sys-manage.sh *, /usr/local/bin/web-tetra.sh *, /usr/local/libexec/mmdvm-save.py *, /usr/local/libexec/hosts-build.py *
SUDO
chmod 0440 /etc/sudoers.d/arch-web
visudo -cf /etc/sudoers.d/arch-web >/dev/null
systemctl daemon-reload
systemctl enable --now mosquitto nginx mmdvm-web.service mmdvm-multi.service fmo-bridge.service
systemctl enable --now arch-data-bootstrap.timer
systemctl start --no-block arch-data-bootstrap.service 2>/dev/null || true
systemctl reload nginx
DMR_ID="$(awk -F= '/^[[:space:]]*Id=/{gsub(/[[:space:]]/,"",$2); print $2; exit}' /etc/DMRGateway.ini 2>/dev/null || true)"
case "$DMR_ID" in
  *[1-9]*) touch /etc/mmdvm/dmr-ready; systemctl enable --now dmrgateway.service ;;
  *) rm -f /etc/mmdvm/dmr-ready; systemctl disable --now dmrgateway.service 2>/dev/null || true ;;
esac
CHANNELS=$(python3 -c 'import json;print(int(json.load(open("/etc/mmdvm/channels.json")).get("channels",6)))' 2>/dev/null || echo 6)
for i in $(seq 1 "$CHANNELS"); do
  [ -f "/etc/MMDVM-IQ-$i.ini" ] && systemctl enable --now "mmdvm-iq@$i.service"
  [ -f "/etc/MMDVM-Host-$i.ini" ] && systemctl enable --now "mmdvmhost@$i.service"
done
systemctl restart mmdvm-web.service fmo-bridge.service
echo "ARCH 热点 $CHANNEL $VERSION 安装完成"
echo "管理页面：http://$(hostname -I | awk '{print $1}')/"
```

### `_build/updater_template.sh`

```bash
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "请使用 sudo 运行"; exit 1; }
. /etc/arch-hotspot-release
URL="$UPDATE_BASE/$CHANNEL/current.json"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
curl -fsSL "$URL" -o "$TMP/current.json" || curl -kfsSL "$URL" -o "$TMP/current.json"
FILE=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["file"])' "$TMP/current.json")
NEWVER=$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d["version"])' "$TMP/current.json")
if [ "$NEWVER" = "$VERSION" ]; then echo "已是最新版本 $VERSION"; exit 0; fi
WANT=$(python3 -c 'import json,sys; print((json.load(open(sys.argv[1])) or {}).get("sha256",""))' "$TMP/current.json")
curl -fsSL "$UPDATE_BASE/$CHANNEL/$FILE" -o "$TMP/update.tar.gz" || curl -kfsSL "$UPDATE_BASE/$CHANNEL/$FILE" -o "$TMP/update.tar.gz"
if [ -n "$WANT" ]; then
  echo "$WANT  $TMP/update.tar.gz" | sha256sum -c - || { echo "更新包校验失败，已中止"; exit 1; }
fi
tar -xzf "$TMP/update.tar.gz" -C "$TMP"
PKG=$(find "$TMP" -mindepth 1 -maxdepth 1 -type d -name 'arch-hotspot-*' | head -1)
bash "$PKG/install.sh"
```

### `_build/build_upstream_template.sh`

```bash
#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" -eq 0 ] || { echo "请使用 sudo 运行"; exit 1; }
export DEBIAN_FRONTEND=noninteractive
export CC=gcc CXX=g++
DEPS_BASE="${ARCH_DEPS_BASE:-https://arch.hljrsa.cn/downloads/arch-hotspot/deps}"
mkdir -p /opt/src-mmdvm /opt/arch-deps
apt-get install -y --no-install-recommends git build-essential cmake autoconf automake libtool pkg-config libasio-dev libssl-dev libcurl4-openssl-dev libsamplerate0-dev libsox-dev libmosquitto-dev libsoapysdr-dev soapysdr-tools libasound2-dev libliquid-dev nlohmann-json3-dev mosquitto libwxgtk3.2-dev wx-common
if [ ! -f /usr/include/liquid/liquid.h ]; then
  apt-get install -y --no-install-recommends autoconf automake libtool libfftw3-dev
fi
if [ ! -f /opt/arch-deps/arch-upstream-src.tar.gz ]; then
  curl -fsSL "$DEPS_BASE/arch-upstream-src.tar.gz" -o /opt/arch-deps/arch-upstream-src.tar.gz
fi
rm -rf /opt/src-mmdvm
mkdir -p /opt/src-mmdvm
tar -xzf /opt/arch-deps/arch-upstream-src.tar.gz -C /opt/src-mmdvm
python3 - <<'PY'
import re
from pathlib import Path
p = Path('/opt/src-mmdvm/MMDVM-Multi/MMDVM-Multi.cpp')
s = p.read_text(encoding='utf-8')
s = s.replace('deviceType.compare("plutosdr") == 0 || deviceType.compare("pluto") == 0',
              'deviceType.compare("plutosdr") == 0 || deviceType.compare("pluto") == 0')
p.write_text(s, encoding='utf-8')
c = Path('/opt/src-mmdvm/MMDVM-Multi/Constants.h')
t = c.read_text(encoding='utf-8').replace('PFB_FILTER_DELAY = 48U', 'PFB_FILTER_DELAY = 24U')
c.write_text(t, encoding='utf-8')

# Fix: in duplex DMR mode the full slot decoder was only fed while TX was on,
# and in idle mode it was not fed at all.  A local handheld transmitting during
# an idle hotspot was therefore never decoded.
io = Path('/opt/src-mmdvm/MMDVM-IQ/IO.cpp')
t = io.read_text(encoding='utf-8')
old = """        if (m_duplex) {
          // If the transmitter isn't on, use the DMR idle RX to detect the wakeup CSBKs
          if (m_tx)
            dmrRX.samples(DMRVals, rssi, control, RX_BLOCK_SIZE);
          else
            dmrIdleRX.samples(DMRVals, RX_BLOCK_SIZE);
        } else {"""
new = """        if (m_duplex) {
          // Always feed the full slot decoder.  The transmitter may be idle while
          // a local handheld is transmitting; gating this on m_tx loses RF voice.
          dmrRX.samples(DMRVals, rssi, control, RX_BLOCK_SIZE);
          if (!m_tx)
            dmrIdleRX.samples(DMRVals, RX_BLOCK_SIZE);
        } else {"""
if old not in t:
    raise SystemExit('MMDVM-IQ duplex RX patch anchor not found')
t = t.replace(old, new, 1)
idle_pat = re.compile(r'(?P<i>[ \t]+)if \(m_duplex\)\s*\n[ \t]*dmrIdleRX\.samples\(DMRVals, RX_BLOCK_SIZE\);\s*\n[ \t]*else\s*\n[ \t]*dmrDMORX\.samples\(DMRVals, rssi, RX_BLOCK_SIZE\);', re.M)
idle_match = idle_pat.search(t)
if not idle_match:
    raise SystemExit('MMDVM-IQ idle RX patch anchor not found')
i = idle_match.group('i')
idle_new = f"{i}if (m_duplex) {{\n{i}  dmrRX.samples(DMRVals, rssi, control, RX_BLOCK_SIZE);\n{i}  dmrIdleRX.samples(DMRVals, RX_BLOCK_SIZE);\n{i}}} else {{\n{i}  dmrDMORX.samples(DMRVals, rssi, RX_BLOCK_SIZE);\n{i}}}"
t = t[:idle_match.start()] + idle_new + t[idle_match.end():]
io.write_text(t, encoding='utf-8')
PY
if [ ! -f /usr/include/liquid/liquid.h ]; then
  cd /opt/src-mmdvm/liquid-dsp
  ./bootstrap.sh
  ./configure
  make -j"$(nproc)"
  make install
  ldconfig
fi
build_repo() {
  local name="$1" binary="$2"
  if [ -x "/usr/local/bin/$binary" ]; then echo "$binary 已存在，跳过编译"; return 0; fi
  make -C "/opt/src-mmdvm/$name" -j"$(nproc)"
  install -m 0755 "/opt/src-mmdvm/$name/$binary" "/usr/local/bin/$binary"
}
build_subdir() {
  local group="$1" name="$2" binary="$3"
  if [ -x "/usr/local/bin/$binary" ] && [ "${FORCE_MMDVM_IQ:-0}" != "1" ]; then echo "$binary 已存在，跳过编译"; return 0; fi
  make -C "/opt/src-mmdvm/$group/$name" -j"$(nproc)"
  make -C "/opt/src-mmdvm/$group/$name" install
}
build_ircddb() {
  if [ -x /usr/local/bin/ircddbgatewayd ] && [ "${FORCE_MMDVM_IQ:-0}" != "1" ]; then echo "ircddbgatewayd 已存在，跳过编译"; return 0; fi
  make -C /opt/src-mmdvm/ircDDBGateway -j"$(nproc)" ircDDBGateway/ircddbgatewayd
  install -m 0755 /opt/src-mmdvm/ircDDBGateway/ircDDBGateway/ircddbgatewayd /usr/local/bin/ircddbgatewayd
}
build_repo MMDVM-Multi MMDVM-Multi
if [ "${FORCE_MMDVM_IQ:-0}" = "1" ]; then rm -f /usr/local/bin/MMDVM-IQ; fi
build_repo MMDVM-IQ MMDVM-IQ
build_repo MMDVM-Host MMDVM-Host
build_repo DMRGateway DMRGateway
build_subdir YSFClients YSFGateway YSFGateway
build_subdir P25Clients P25Gateway P25Gateway
build_subdir NXDNClients NXDNGateway NXDNGateway
build_ircddb
if [ -d /opt/src-mmdvm/sxxcvr/SoapySX ]; then
  SOAPY_SX_SRC=/opt/src-mmdvm/sxxcvr/SoapySX
  SOAPY_SX_MODULE="$(find /usr/local/lib /usr/lib -type f -name '*SXSupport*.so' 2>/dev/null | head -1 || true)"
  if [ -z "$SOAPY_SX_MODULE" ]; then
    python3 - "$SOAPY_SX_SRC/CMakeLists.txt" <<'PY'
from pathlib import Path
import re, sys
p = Path(sys.argv[1])
s = p.read_text(encoding="utf-8")
s = s.replace("find_package(Git REQUIRED)\n", "")
s = re.sub(r"set\(VERSION_SCRIPT[^\n]*\)\n", "", s)
s = re.sub(r"\nadd_custom_command\(.*?\n\)\n", "\n", s, flags=re.S)
p.write_text(s, encoding="utf-8")
PY
    cat > "$SOAPY_SX_SRC/version.cpp" <<'EOF'
const char *SoapySX_tag = "arch-hotspot";
const char *SoapySX_commit = "release";
EOF
    rm -rf /opt/src-mmdvm/sxxcvr/build
    cmake -S "$SOAPY_SX_SRC" -B /opt/src-mmdvm/sxxcvr/build -DCMAKE_BUILD_TYPE=Release
    cmake --build /opt/src-mmdvm/sxxcvr/build -j"$(nproc)"
    cmake --install /opt/src-mmdvm/sxxcvr/build
    ldconfig
    SOAPY_SX_MODULE="$(find /usr/local/lib /usr/lib -type f -name '*SXSupport*.so' 2>/dev/null | head -1 || true)"
  fi
  [ -n "$SOAPY_SX_MODULE" ] || { echo "SoapySX 模块安装失败" >&2; exit 1; }
  mkdir -p /etc/mmdvm
  if SoapySDRUtil --info 2>/dev/null | grep -q "Available factories... sx"; then touch /etc/mmdvm/soapy-sx-verified; fi
  echo "SoapySX 模块：$SOAPY_SX_MODULE"
fi
echo "ARCH 官方组件安装完成"
```

### `_build/make_release.py`

```python
from pathlib import Path
import re, shutil, json, hashlib, tarfile, os

ROOT = Path(__file__).resolve().parent
SRC = ROOT / 'source'
OUT = ROOT / 'out'
VERSION = '1.0.25'   # ★ 发版时只改这一行


def drop_between(text, start, end, keep_end=True):
    i = text.find(start)
    if i < 0:
        raise RuntimeError('start not found: ' + start[:100])
    j = text.find(end, i)
    if j < 0:
        raise RuntimeError('end not found: ' + end[:100])
    return text[:i] + (text[j:] if keep_end else text[j+len(end):])


def remove_card(text, marker):
    i = text.find(marker)
    if i < 0:
        raise RuntimeError('card marker not found: ' + marker)
    start = text.rfind('<div', 0, i)
    if start < 0:
        raise RuntimeError('card start not found: ' + marker)
    pos = start
    depth = 0
    token = re.compile(r'<div\b|</div>', re.I)
    for m in token.finditer(text, start):
        if m.group(0).lower().startswith('</'):
            depth -= 1
            if depth == 0:
                return text[:start] + text[m.end():]
        else:
            depth += 1
    raise RuntimeError('card end not found: ' + marker)


def remove_js_function(text, name):
    start = text.find('function ' + name + '(')
    if start < 0:
        raise RuntimeError('function not found: ' + name)
    brace = text.find('{', start)
    if brace < 0:
        raise RuntimeError('function brace not found: ' + name)
    depth=0
    i=brace
    quote=None
    esc=False
    while i < len(text):
        c=text[i]
        if quote:
            if esc: esc=False
            elif c=='\\': esc=True
            elif c==quote: quote=None
        else:
            if c in ('"', "'", '`'): quote=c
            elif c=='{': depth+=1
            elif c=='}':
                depth-=1
                if depth==0: return text[:start] + text[i+1:]
        i+=1
    raise RuntimeError('function end not found: ' + name)


def public_app(text):
    # 公共版按语义块删除集群功能，不再依赖容易漂移的固定行号。
    text=re.sub(r"try:\n    from tetra_page import TETRA_HTML\nexcept Exception:\n    TETRA_HTML = .*?\n", "", text, count=1, flags=re.S)
    text=re.sub(r"^.*data-page=\"tetra-external\".*\n", "", text, flags=re.M)
    try:
        text=remove_card(text, 'id="tetraGrid"')
    except Exception:
        pass
    text=drop_between(text,
        '      <!-- ===== ① 运行模式',
        '      <!-- ===== ② 台站与射频参数 ===== -->')
    text=drop_between(text,
        '/* ==================== 运行模式：常规模式 <-> TDR Link 模式',
        '/* ==================== 各通道状态')
    def _drop_block(src, start_marker, end_marker):
        i=src.find(start_marker)
        if i<0: return src
        j=src.find(end_marker, i)
        if j<0: return src
        return src[:i] + src[j+len(end_marker):]
    text=_drop_block(text, '<!-- ===== TDR Link 高级参数', '</script>')
    text=_drop_block(text, '<!-- ===== TDR Link 模式状态宫格', '</script>')
    text=drop_between(text,
        '# ==================== TDR Link模式状态（首页宫格用）',
        "@app.route('/api/bm-status')")
    text=_drop_block(text, '# ==================== TDR Link 配置页', "if __name__ == '__main__':")
    start=text.find('function mcModeLite(cb){')
    if start<0: raise RuntimeError('mcModeLite missing')
    end=text.find('\nfunction mcRenderEng',start)
    if end<0: raise RuntimeError('mcModeLite end missing')
    text=text[:start]+"function mcModeLite(cb){ MC_MODE_TXT='正常模式'; if(cb) cb(); }\n"+text[end:]
    text=re.sub(r'^.*(?:TETRA|TDR Link|FlowStation|Bluestation).*\n','',text,flags=re.I|re.M)
    text=re.sub(r'^.*(?:/opt/tetra|pgtetra).*\n','',text,flags=re.I|re.M)
    text=text.replace('_RADIOID_SEED_FILE = "/opt/tetra/radioid_cache.json"\n','')
    return text

def public_rfmode_js(text):
    start = text.find('/* ==================== 运行模式')
    if start < 0: raise RuntimeError('rfmode run block missing')
    end = text.find('/* ==================== 各通道状态', start)
    if end < 0: raise RuntimeError('rfmode run end missing')
    return text[:start] + text[end:]


def public_rfmode_section(text):
    text = drop_between(text, '      <!-- ===== ① 运行模式', '      <!-- ===== ② 台站与射频参数 ===== -->')
    return re.sub(r'^.*option value="tetra-bs".*\n', '', text, flags=re.M)


def sanitize(text):
<div class="author">作者：BG2GA · <a href="https://arch.hljrsa.cn/disclaimer/">免责声明</a></div>
    sentinel = '<div class="author">作者：BG2GA · <a href="https://arch.hljrsa.cn/disclaimer/">免责声明</a></div>'
    text = author_line.sub(sentinel, text)
    repl = {
        '000000000':'000000000','000000000':'000000000','0000000':'0000000','0000000':'0000000',
        'NOCALL':'NOCALL','nocall':'nocall','NOCALL':'NOCALL','nocall':'nocall','"uid": 0':'"uid": 0','"uid": 0':'"uid": 0','':'','CHANGE_ME':'CHANGE_ME','CHANGE_ME':'CHANGE_ME',
        'nocall.org.cn':'example.com','192.168.1.10':'192.168.1.10','192.168.1.20':'192.168.1.20',
        'CHANGE_ME':'CHANGE_ME',
    }
    for a,b in repl.items(): text=text.replace(a,b)
<div class="author">作者：BG2GA · <a href="https://arch.hljrsa.cn/disclaimer/">免责声明</a></div>
    return text


def prepare(channel):
    dst=OUT/channel
    if dst.exists(): shutil.rmtree(dst)
    shutil.copytree(SRC,dst,ignore=shutil.ignore_patterns('__pycache__','*.pyc','*.bak*','*.pre[0-9]*','*.old*','*.orig','*.tmp'))
    app_path=dst/'opt/mmdvm-web/app.py'
    if channel=='public':
        app_path.write_text(public_app(app_path.read_text(encoding='utf-8')),encoding='utf-8')
        for name in ('tetra_page.py','tetra_adv_backend.py','tetra_adv_spec.py'):
            p=dst/'opt/mmdvm-web'/name
            if p.exists(): p.unlink()
        for rel in ('usr/local/libexec/tetra-api.py','usr/local/bin/web-tetra.sh','etc/systemd/system/tetra-bs.service','etc/systemd/system/arch-tetra-mode-sync.service','etc/systemd/system/arch-tetra-mode-sync.timer','etc/systemd/system/arch-callsign-cache-update.service','etc/systemd/system/arch-callsign-cache-update.timer'):
            p=dst/rel
            if p.exists(): p.unlink()
        for name,fn in (('rfmode2_js.py',public_rfmode_js),('rfmode2_section.py',public_rfmode_section)):
            p=dst/'opt/mmdvm-web'/name
            p.write_text(fn(p.read_text(encoding='utf-8')),encoding='utf-8')
        p=dst/'opt/mmdvm-web/console_new.py'
        p.write_text(re.sub(r'^.*href="/tetra".*\n','',p.read_text(encoding='utf-8'),flags=re.M),encoding='utf-8')
        p=dst/'usr/local/bin/web-manage.sh'
        p.write_text(p.read_text(encoding='utf-8').replace('|tetra-bs',''),encoding='utf-8')
        p=dst/'opt/mmdvm-web/multi2_js.py'
        t=p.read_text(encoding='utf-8')
        a=t.find('function mcModeLite(cb){')
        b=t.find('function mcRenderEng', a)
        if a >= 0 and b > a:
            t=t[:a] + "function mcModeLite(cb){ MC_MODE_TXT='正常模式'; if(cb) cb(); }\n" + t[b:]
        p.write_text(t,encoding='utf-8')
        p=dst/'opt/mmdvm-web/rfmode_standalone.py'
        p.write_text(p.read_text(encoding='utf-8').replace('/tetra','/configuration'),encoding='utf-8')
    for p in dst.rglob('*'):
        if not p.is_file() or p.stat().st_size>2*1024*1024: continue
        try:
            raw=p.read_bytes()
            if b'\x00' in raw[:1024]: continue
            txt=sanitize(raw.decode('utf-8'))
            if channel=='public':
                txt=re.sub(r'^.*(?:TETRA|TDR Link|FlowStation|Bluestation).*\n','',txt,flags=re.I|re.M)
            p.write_text(txt,encoding='utf-8')
        except Exception:
            pass
    (dst/'DISCLAIMER.md').write_text('''# ARCH 及相关项目开源许可、技术披露与责任限制声明（通用版）

一、适用范围与项目性质

本声明适用于与 ARCH / ARC 热点、AI Passport 相关软件、APP、网站、固件、脚本、配置、文档及在线服务等有关的各类项目（以下统称“本项目”）。本项目可能由多个独立或相互关联的软件、硬件配置、网页、服务端程序和文档组成，具体功能、许可证和运行方式以各项目页面、仓库或实际版本为准。

本项目中的 APP、固件及在线服务目前处于内测/测试阶段，仅向受邀测试者或有限范围用户提供，不面向公众公开分发，不进行商业运营，不收取费用。开发者保留随时终止内测、停止分发、调整或删除任何功能的权利，且无需事先通知。项目主页、开源代码仓库和文档可公开访问，但公开访问代码或文档不等于公开提供在线通信服务，也不构成面向不特定公众提供服务的承诺。内测状态不改变本声明其他条款的效力，也不构成对任何法定义务的豁免。

二、许可证与无担保声明

本项目各部分可能采用不同许可证。明确标注 GNU General Public License v3.0 或更高版本的部分，按该许可证发布，你可以在遵守 GPL v3 的前提下自由使用、研究、修改和再发布；再发布时必须履行相应的源代码公开和同一许可证要求。完整条款见 https://www.gnu.org/licenses/gpl-3.0.html 。未标注 GPL v3 或标注其他许可证的部分，按其各自许可证条款执行。具体以各仓库、页面或文件中的许可证声明为准。

本项目按“现状”提供，不提供任何明示或默示担保，包括但不限于适销性、特定用途适用性、无侵权、无中断、无错误、无数据丢失的担保。GPL v3 或其他许可证本身包含的免责条款与本节共同适用；若二者在免责范围上存在不一致，以更宽泛者为准。测试者向他人分发本项目的二进制、固件或其他可执行形式时，应自行履行相应许可证要求的源代码提供或其他义务。

三、功能性质与技术角色披露

本项目可能包含但不限于以下功能或组件，具体以实际版本为准：实时语音对讲及语音中转、RSS 播客阅读与直链播放、热点/无线电相关配置与控制、AI Passport 等硬件平台相关固件、网站/文档/脚本/配置等。开发者在此披露以下通用技术事实：

1. 语音中转服务（如有）：本项目可能包含由开发者个人维护的中转服务器，仅用于内测阶段的技术测试与合法通信。该服务器提供的是技术性语音数据中转，开发者不编辑、不筛选、不主动推荐通信内容。服务器可能记录维持连接所必需的最小元数据（连接时间、设备标识、IP 地址、通信时长等），不承诺不记录；是否记录语音内容本身，以实际服务器配置为准。语音内容在技术层面可能未进行端到端加密。中转服务器仅服务于对讲相关功能，与 RSS 播客模块无关。
2. RSS 播客阅读模块（如有）：该模块的功能是读取用户自行添加的、公开可访问的 RSS 地址，并直接从该地址所指向的原始音频服务器流式播放。该模块不依赖开发者控制的服务器，开发者不存储、不转码、不代理、不重新托管任何音频文件；音频数据从 RSS 发布者的原始服务器直接传输至用户设备。开发者不主动选择、编辑、推荐或审查任何 RSS 源，用户添加什么 RSS 地址，就播放什么。该模块在技术形态上属于信息定位与链接工具，而非内容提供者。
3. 热点/无线电/硬件相关组件（如有）：本项目可能包含与热点、无线电通信、硬件固件相关的配置、脚本或程序。本项目不提供规避频率许可、发射功率限制、业余无线电执照条件或呼号使用规则的功能。相关硬件平台和第三方网络基础设施的底层合规性、安全性与可用性，由相应提供方负责。
4. 本项目依赖第三方硬件平台、第三方网络基础设施及第三方 RSS 源，其可用性、合法性、安全性和内容质量由相应提供方或发布者负责。

四、使用边界与用户合规义务

使用者应将本项目严格用于合法通信和合法内容收听目的。禁止将本项目及其任何组件用于任何违反中华人民共和国法律法规的活动，包括但不限于：电信网络诈骗、组织赌博、传播违法信息、侵犯他人隐私或著作权、骚扰跟踪、未经授权截获通信，或用于任何需要取得而未取得电信业务经营许可、无线电频率使用许可、互联网信息服务备案的场景。使用者不得将本项目用于面向不特定公众提供商业化或半商业化通信服务。

使用者应自行确认其联网、注册、录音、定位、语音数据处理、RSS 源添加与收听、无线电设备使用等行为，符合所在司法辖区的法律、无线电管理规定和隐私要求。本条不构成开发者对其自身依法应承担的备案、许可、内容安全、数据保护等义务的免除或转移。

五、责任限制（在适用法律允许的最大范围内）

在适用法律允许的最大范围内，开发者、贡献者、维护者不对因使用或无法使用本项目而产生的任何间接、附带、特殊、惩罚性或后果性民事损失承担责任，包括但不限于通信失败、音频损坏、设备损坏、数据丢失、业务中断或第三方民事索赔。

本条款不排除或限制依法不得排除或限制的责任，包括因开发者故意或重大过失造成的人身损害、财产损失，以及依法应由开发者承担的行政责任或刑事责任。任何试图通过本声明免除行政责任或刑事责任的解释，均超出本声明的意图和效力范围。

六、第三方组件与许可证合规

本项目可能使用、参考或链接多个第三方开源项目、组件、库、脚本或配置。各组件版权归原作者所有，其许可证条款独立适用于相应组件。具体清单和许可证信息，以各项目仓库中的 THIRD_PARTY_NOTICES、LICENSES、NOTICE 或类似文件为准；如未单独附列，以相应上游项目的许可证为准。本项目与任何第三方网络运营方、设备厂商或服务提供方不存在隶属、代理或担保关系。测试者再分发本项目时，应自行核对并遵守各第三方组件的许可证要求。

七、服务、网络与第三方源的中立性

中转服务器、反射器、热点、无线电设备、网络服务和第三方 RSS 源可能随时变更、中断或停止。内测期间的注册、登录、互通和音频质量不构成任何服务承诺。开发者不保证中转服务器的持续可用性。

RSS 播放功能的可用性取决于第三方 RSS 源是否持续公开可访问，开发者不控制、不保证该等第三方源的可用性、合法性或内容质量，也不对第三方源的内容承担责任。使用第三方服务器、网络或 RSS 源时，还须遵守该服务器、网络或发布者的规则和隐私政策。中转服务器的技术性介入，不构成对任何通信内容的编辑控制、推荐或背书；RSS 模块的链接功能，不构成对任何音频内容的编辑控制、推荐或背书。

八、无实时监控义务，但有响应义务

开发者没有义务、也没有技术能力实时监控使用者如何使用本项目，亦无义务主动审查使用者添加的 RSS 源是否侵权。但是，如果开发者通过平台通知、监管机关告知、权利人通知或其他可靠渠道明确得知特定版本、中转服务或 RSS 模块正在被持续用于违法或侵权活动，开发者保留采取合理措施的权利，包括但不限于：增加使用提示、限制特定功能、暂停或终止对特定测试者的服务、停止分发特定版本，或直接结束内测。在明确得知后继续原样分发或不作任何合理响应，不在本声明的保护意图之内。

九、声明性质、可分割性与接受

本声明是开发者对项目功能、技术角色、数据流和自身角色的披露与风险告知，不构成对任何法定义务的豁免，不免除开发者依法应承担的责任，也不构成对测试者违法行为的授权或鼓励。本声明不替代任何必要的行政许可、备案、安全评估或平台审核。

本声明任何条款被认定无效或不可执行，不影响其余条款的效力。

安装、运行、访问或继续使用本项目，即表示你已阅读、理解并同意上述声明。如果你不同意，请立即停止使用、停止访问并卸载或删除相关程序。

项目作者：BG2GA
项目主页：https://arch.hljrsa.cn
GPL v3 全文：https://www.gnu.org/licenses/gpl-3.0.txt
''',encoding='utf-8')
    (dst/'LICENSE').write_text('GNU GPL v3 or later\nhttps://www.gnu.org/licenses/gpl-3.0.txt\n',encoding='utf-8')
    (dst/'VERSION').write_text(VERSION+'\n',encoding='utf-8')
    (dst/'CHANNEL').write_text(channel+'\n',encoding='utf-8')
    for src_name,dst_name in (('installer_template.sh','install.sh'),('updater_template.sh','arch-hotspot-update'),('build_upstream_template.sh','build_upstream.sh')):
        txt=(ROOT/src_name).read_text(encoding='utf-8').replace('@CHANNEL@',channel).replace('@VERSION@',VERSION)
        if channel == 'public':
            txt=re.sub(r'^.*(?:tetra|web-tetra).*\n','',txt,flags=re.I|re.M)
        out=dst/dst_name
        out.write_text(txt,encoding='utf-8')
        out.chmod(0o755)
    for fp in dst.rglob('*'):
        if fp.is_file() and (fp.suffix in ('.sh', '.py') or fp.name == 'arch-hotspot-update'):
            # Normalize all CR bytes as well as CRLF; Set-Content on Windows can leave one trailing CR.
            fp.write_bytes(fp.read_bytes().replace(b'\r', b''))
    return dst


def archive(channel):
    dst=prepare(channel)
    target=OUT/f'arch-hotspot-{channel}-{VERSION}.tar.gz'
    if target.exists(): target.unlink()
    with tarfile.open(target,'w:gz') as tf: tf.add(dst,arcname=f'arch-hotspot-{channel}-{VERSION}')
    digest=hashlib.sha256(target.read_bytes()).hexdigest()
    target.with_suffix('.sha256').write_text(digest+'  '+target.name+'\n',encoding='utf-8')
    web=ROOT/'web'/channel
    if web.exists(): shutil.rmtree(web)
    web.mkdir(parents=True)
    shutil.copy2(target, web/target.name)
    (web/'current.json').write_text(json.dumps({'channel':channel,'version':VERSION,'file':target.name,'sha256':digest},ensure_ascii=False,indent=2),encoding='utf-8')
    bootstrap=f'''#!/usr/bin/env bash
set -euo pipefail
[ "$(id -u)" -eq 0 ] || {{ echo "请使用 sudo 运行"; exit 1; }}
BASE="https://arch.hljrsa.cn/downloads/arch-hotspot/{channel}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
curl -fsSL "$BASE/{target.name}" -o "$TMP/{target.name}" || curl -kfsSL "$BASE/{target.name}" -o "$TMP/{target.name}"
echo "{digest}  $TMP/{target.name}" | sha256sum -c -
tar -xzf "$TMP/{target.name}" -C "$TMP"
PKG="$(find "$TMP" -mindepth 1 -maxdepth 1 -type d -name 'arch-hotspot-*' | head -1)"
bash "$PKG/install.sh"
'''
    (web/'install.sh').write_text(bootstrap,encoding='utf-8')
    (web/'install.sh').write_bytes((web/'install.sh').read_bytes().replace(b'\r\n', b'\n'))
    (web/'install.sh').chmod(0o755)
    print(channel,target.stat().st_size,target,digest)

if __name__=='__main__':
    OUT.mkdir(parents=True,exist_ok=True)
    archive('public')
    archive('internal')
```


---

## 附：ARCH热点侧-完整技术说明与代码架构

# ARCH 热点侧 —— 完整技术说明与代码架构

> 文档版本：2026-09-23 · 对应固件 **1.0.24**
> 适用对象：接手 ARCH 热点盒子（树莓派 + SX1255/MMDVM 射频板）开发与运维的人
> 配套备份：`03-配置备份\ARCH热点固件-1.0.24-完整备份-20260923\`

---

## 第 0 章　一句话说清这套东西

ARCH 热点侧 = **一台树莓派 + 一块 SX1255 多通道射频板**，上面跑一套自研 Web 控制台，
把 DMR / C4FM(YSF) / D-STAR / P25 / NXDN / FM(FMO) / POCSAG 这 7 种制式
**同时**接到 46002 多模融合网络里，用户用浏览器就能配置全部参数。

它**不是** Pi-Star 的换皮：射频前端换成了自研的 `MMDVM-Multi`（一次采一段宽带 I/Q，软件分出 N 个信道），
Web 端是整个重写的，服务器列表、身份、语音链路都指向 ARCH 自己的 93 服务器 + 北京公网入口。

---

## 第 1 章　系统边界：热点侧 vs 服务器侧

必须先把边界分清，改错地方会白忙。

| 归属 | 在哪里 | 负责什么 | 代码位置 |
| --- | --- | --- | --- |
| **热点侧**（本文档） | 用户家的树莓派 | 射频收发、各模式网关、Web 控制台、FMO 桥、本地录音/播报触发 | 本备份 `source\` |
| **服务器侧** | 93 / 92 / 北京 | FreeDMR 主站、XLXD、YSF/P25/NXDN 反射器、TETRA Brew、MQTT、录音归档、Web 管理平台、官网 | `07-当前工程工作区\...\work\`、93 上 `/opt/*` |
| **镜像侧** | Cloudflare Pages（`arch.hljrsa.cn`） | 发布安装包、数据镜像（DMR ID / 各模式服务器列表）、官网、反馈页 | `server-mirror\` + 93 `/opt/arch-site` |
| **手机 APP** | 用户手机 | 音频 + 通话界面 | 独立一条线，不在本文范围 |

**一句话**：热点侧只负责"把电台和网络接起来 + 把界面做出来"，所有"全球列表/谁在线/录音"之类都在服务器和镜像侧。

---

## 第 2 章　两条发布线（内测版 / 公开版）

同一套源码，打包成两个版本，**互相独立升级**：

| | 内测版 internal | 公开版 public |
| --- | --- | --- |
| 频道名 | `internal` | `public` |
| 下载地址 | `https://arch.hljrsa.cn/downloads/arch-hotspot/internal/` | `.../public/` |
| 集群功能（TETRA / TDR Link / FlowStation） | **保留** | **彻底移除** |
| 免责声明 | 有 | 有（更详细） |
| 是否对外公开 | 否，仅自用/内测 | 是，官网公开下载 |
| 当前版本 | 1.0.24 | 1.0.24 |

**公开版是怎么"彻底移除"集群的**：不是运行时开关，而是在打包时对源码做**语义级裁剪**——
删掉集群页面、删掉集群后端路由、删掉 `tetra_*.py`、`web-tetra.sh`、`tetra-bs.service` 等文件，
再对全树做一次正则清洗（任何含 `TETRA` / `TDR Link` / `FlowStation` / `Bluestation` 的整行都删除）。
打包后会用关键字全树校验，必须命中 0 个文件才算通过（见第 11 章）。

> ⚠️ 以后改公共部分（射频、Web、升级逻辑）时**两边都要改**；
> 只改集群相关的东西时**只动内测版**，公开版会因为裁剪规则自动不含。

---

## 第 3 章　发布包结构（解包后长什么样）

包内目录结构 = 设备上的绝对路径，安装脚本就是"照抄"到 `/`。

```
arch-hotspot-<通道>-<版本>/
├── install.sh                     ← 主安装脚本（见第 4 章）
├── arch-hotspot-update            ← 设备上的在线升级命令
├── build_upstream.sh              ← 编译上游 MMDVM 各组件
├── VERSION / CHANNEL / LICENSE / DISCLAIMER.md
├── opt/
│   ├── mmdvm-web/                 ← ★ Web 控制台（自研核心）
│   │   ├── app.py                 ← 主程序：Flask + 前端 HTML/CSS/JS 全在一个文件里
│   │   ├── fmo_page.py            ← FMO 模拟互联页面
│   │   ├── console_new.py         ← 新版控制台页面骨架
│   │   ├── rfmode_page.py         ← 射频模式页面
│   │   ├── rfmode_js.py / rfmode_section.py / rfmode2_js.py / rfmode2_section.py
│   │   ├── multi2_js.py / multi2_section.py   ← 多信道矩阵
│   │   ├── tetra_page.py / tetra_adv_backend.py / tetra_adv_spec.py   ← 集群页（仅内测）
│   │   └── rfmode_standalone.py
│   └── fmo-bridge/                ← ★ FMO 模拟互联桥（自研）
│       ├── fmo_bridge.py          ← 主程序（RF↔网络音频、MQTT、PCM 编解码）
│       ├── fmo_voice.py / fmo_dsp.py / fmo_fm.py / fmo_packet.py
│       ├── fmo_proto.py / fmo_reg.py / fmo_presence.py
│       ├── fmo_app_uplink_relay.py
│       └── config.template.json
├── usr/local/bin/                 ← 运维脚本（被 Web 调用）
│   ├── web-manage.sh              ← Web 的"总调度"（状态/切换/保存/日志/备份/更新）
│   ├── web-multi.sh / web-fmo.sh / web-tetra.sh
│   ├── channel-mode.sh            ← 单通道模式切换
│   ├── update-database.sh         ← 从 ARCH 镜像更新数据（本版新增 dmrhosts）
│   ├── update-hosts.sh            ← 全量主机库更新（镜像优先，Pi-Star 兜底）
│   ├── update-dstar.sh
│   ├── arch-data-bootstrap.sh     ← 首次开机自动拉数据
│   ├── arch-fw-online.sh          ← 在线固件升级
│   ├── sys-manage.sh              ← 系统设置（品牌/主题/密码/WiFi）
│   ├── arch-sync-fm-qso.py
│   ├── backup.sh / diagnose.sh / DMRIDUpdate.sh / dmrid-update-wrapper.sh
│   └── hw-detect.sh
├── usr/local/libexec/             ← Python 工具（真正干活的地方）
│   ├── mmdvm-save.py              ← ★ 所有"保存配置"都走这里
│   ├── channel-setup.py           ← ★ 通道规划落盘 + 模式互斥校验
│   ├── hosts-build.py             ← ★ 把上游 txt 转成 Web 用的 JSON（含 ARCH 置顶）
│   ├── mmdvm-status.py            ← ★ 状态与通话记录（Web 每次刷新都调它）
│   ├── fmo-api.py                 ← FMO 桥的 API 后端
│   ├── arch-channel-limit.py      ← 硬件通道上限判定
│   ├── tetra-api.py               ← 集群 API（仅内测）
│   └── ini-edit.py
└── etc/
    ├── mmdvm/                     ← 运行期状态与规划
    │   ├── channels.json          ← ★ 多通道规划（通道数、每路模式、频点）
    │   ├── frequency.json         ← 射频中心频率
    │   ├── active-mode.conf       ← 当前生效模式/通道数
    │   ├── install-state.conf     ← 安装状态 + 各上游组件 commit 号
    │   ├── bm-masters.json        ← 指向 DMR_Hosts.json 的软链接
    │   ├── fm-tailtone.json / fw-url
    ├── MMDVM-Multi.ini            ← 多通道前端（宽带采样）
    ├── MMDVM-IQ.ini / MMDVM-IQ-{1..11}.ini   ← 每路 SDR 调制解调器
    ├── MMDVM-Host.ini / MMDVM-Host-{1..11}.ini ← 每路协议栈（模式、呼号、频点）
    ├── DMRGateway.ini             ← DMR ↔ BM/FreeDMR 网关
    ├── YSFGateway.ini / P25Gateway.ini / NXDNGateway.ini
    ├── ircddbgateway              ← D-Star 网关（反射器、模块）
    ├── nginx/sites-available/mmdvm-web   ← 80 端口反代到 127.0.0.1:18080
    └── systemd/system/            ← 全部服务单元（见第 6 章）
```

**代码量参考**：源码树 103 个文件 ≈ 1.0 MB，其中 `opt/mmdvm-web/app.py` 一个文件就 ≈ 470 KB
（Flask 后端 + 整个前端 HTML/CSS/JS 都塞在里面，是历史原因，改的时候务必用锚点式精确替换）。

---

## 第 4 章　安装流程（从一行命令到能用）

用户在树莓派上执行：

```bash
curl -kfsSL https://arch.hljrsa.cn/downloads/arch-hotspot/public/install.sh | sudo bash
```

### 4.1 第一层：云端引导脚本 `install.sh`

```bash
BASE="https://arch.hljrsa.cn/downloads/arch-hotspot/<通道>"
下载 arch-hotspot-<通道>-<版本>.tar.gz     ← 带 curl -k 回退（时钟没同步时证书会 not yet valid）
sha256sum -c                              ← 校验哈希，不符就中止
tar 解包 → 找到包目录 → 执行包内 install.sh
```

### 4.2 第二层：包内 `install.sh`

1. 检查 root、记录是否首次安装（看 `/etc/arch-hotspot-release` 是否存在）
2. `apt-get install` 基础依赖（python3 / nginx / network-manager / git …）
3. 装 Python 依赖：优先用 apt 的 python3-flask/gunicorn/…，失败则用**离线轮子**
   `deps/arch-python-wheels.tar.gz`
4. 调 `build_upstream.sh` 编译上游组件（见 4.3）
5. 建 `mmdvm-web` 系统用户与目录，把包内 `opt/` `usr/local/` `etc/` 覆盖到系统
6. **首次安装**才覆盖 `etc/*.ini` 与 `ircddbgateway`；**升级**用 `cp -n`（不覆盖用户配置）
7. `hosts-build.py` 重建主机库、`channel-setup.py apply` 应用通道规划
8. 装 `arch-hotspot-update`，写 `/etc/arch-hotspot-release`（记录通道/版本/更新源）
9. 写 sudoers：`/etc/sudoers.d/arch-web`（见第 10 章）
10. `systemctl enable --now` 各服务，启动 `arch-data-bootstrap`（首启拉数据）
11. 根据 `DMRGateway.ini` 里有没有 ID 决定要不要起 DMR 网关
12. 按 `channels.json` 逐路 enable `mmdvm-iq@N` 和 `mmdvmhost@N`

### 4.3 上游组件怎么来的：`build_upstream.sh`

- 依赖包：`git build-essential cmake autoconf … libliquid-dev libsoapysdr-dev nlohmann-json3-dev mosquitto libwxgtk3.2-dev`
- 源码：从镜像下载 `deps/arch-upstream-src.tar.gz`（**不放 GitHub，国内直连镜像**），解到 `/opt/src-mmdvm`
- **三处本地补丁（很重要，别丢）**：
  1. `MMDVM-IQ/IO.cpp` —— 双工 DMR 模式下原来只在发射时喂全时隙解码器，
     导致**本机手台在空闲热点上发射时收不到**；改成始终喂 `dmrRX`。
  2. `MMDVM-Multi/Constants.h` —— `PFB_FILTER_DELAY 48 → 24`（降延迟）。
  3. `sxxcvr/SoapySX` —— 去掉对 git 的依赖，写死版本号，保证离线可编。
- 编译顺序：MMDVM-Multi → MMDVM-IQ → MMDVM-Host → DMRGateway → YSFGateway → P25Gateway → NXDNGateway → ircddbgatewayd → SoapySX 模块
- 已存在就跳过（`FORCE_MMDVM_IQ=1` 可强制重编）

> 注意：`/usr/local/bin` 里那几个大二进制是**上游编译产物**，不属于本备份范围（本备份只收 `*.sh` / `*.py`）。

---

## 第 5 章　升级机制

设备上执行：

```bash
sudo arch-hotspot-update
```

逻辑（`updater_template.sh`）：

1. 读 `/etc/arch-hotspot-release` 拿到 `CHANNEL` / `VERSION` / `UPDATE_BASE`
2. 拉 `$UPDATE_BASE/$CHANNEL/current.json`（带 `curl -k` 回退）
3. 版本相同 → 直接退出
4. 下载新包 → **用 current.json 里的 sha256 校验** → 解包 → 执行包内 `install.sh`
5. `install.sh` 检测到不是首次安装，**用户配置不会被覆盖**

`current.json` 长这样：

```json
{ "channel": "public", "version": "1.0.24",
  "file": "arch-hotspot-public-1.0.24.tar.gz",
  "sha256": "95801d01…" }
```

---

## 第 6 章　运行时架构

### 6.1 进程与服务

| systemd 单元 | 作用 | 数量 |
| --- | --- | --- |
| `mmdvm-multi.service` | 宽带 I/Q 采样 + 数字信道化（自研前端） | 1 |
| `mmdvm-iq@N.service` | 第 N 路软件调制解调器（MMDVM-IQ） | 按通道数 1–7 |
| `mmdvmhost@N.service` | 第 N 路协议栈（DMR/D-Star/YSF/P25/NXDN/FM/POCSAG） | 按通道数 1–7 |
| `dmrgateway.service` | DMR ↔ BrandMeister/FreeDMR | 0/1 |
| `ysfgateway.service` / `p25gateway.service` / `nxdngateway.service` | 各模式网络网关 | 各 0/1 |
| `ircddbgatewayd.service` | D-Star（DCS/XRF/REF/XLX）网关 | 0/1 |
| `fmo-bridge.service` | FMO 模拟互联桥（FM 房间 + MQTT + APP 中继） | 1 |
| `mmdvm-web.service` | Web 控制台（gunicorn，`--workers 1 --threads 6`，监听 127.0.0.1:18080） | 1 |
| `nginx.service` | 80 端口反代到 18080，对外提供 Web | 1 |
| `mosquitto.service` | 本机 MQTT（FMO/RAW 主题） | 1 |
| `arch-data-bootstrap.service/timer` | 定时/首启更新 DMR ID 与各模式服务器列表 | 1 |
| `arch-fw-online.service`（可选） | 在线固件升级 | — |
| `tetra-bs.service`（仅内测） | TETRA Brew 基站 | 0/1 |

### 6.2 数据流（接收方向示例：网络上有人讲话）

```
46002 网络 → 93 服务器 → 北京公网入口
        ↓ UDP
   YSFGateway / P25Gateway / NXDNGateway / DMRGateway / ircddbgatewayd
        ↓ 本机 UDP（每个模式一个端口段）
   MMDVM-Host（第 N 路，含该路模式）→ 音频帧
        ↓ UDP（MMDVM 协议）
   MMDVM-IQ（第 N 路）→ 基带 I/Q
        ↓
   MMDVM-Multi → SX1255 射频 → 手台听到

FM/FMO 分支：
   MQTT(FMO/RAW) → fmo-bridge → 声码器 → ALSA / 射频
```

### 6.3 端口速查

| 端口 | 用途 |
| --- | --- |
| 80 | Web 控制台（nginx → 18080） |
| 18080 | gunicorn 内部端口（仅 127.0.0.1） |
| 1883 | 本机 MQTT（mosquitto） |
| 62030 | FreeDMR（服务器侧） |
| 62031 / 62032 | DMRGateway ↔ MMDVM-Host |
| 42000 / 41000 / 41400 | YSF / P25 / NXDN 反射器 |
| 30051 | D-Star（DCS/XLX） |

---

## 第 7 章　Web 控制台架构

### 7.1 三层

```
浏览器
  │ HTTP
nginx (80)  ──反向代理──▶  gunicorn (127.0.0.1:18080)
                              │
                              ▼
                        /opt/mmdvm-web/app.py（Flask）
                              │ subprocess + sudo
                              ▼
   /usr/local/bin/web-manage.sh   ← 总调度
   /usr/local/libexec/mmdvm-save.py / mmdvm-status.py / hosts-build.py / fmo-api.py
                              │
                              ▼
                    /etc/*.ini、/etc/mmdvm/*.json、systemctl
```

**设计要点**：Flask 本身不碰系统文件，全部通过 `sudo` 调用白名单里的脚本；
脚本都是"可单独在命令行跑"的，方便排障（出问题时先手工跑脚本，再看 Web 是不是也有问题）。

### 7.2 API 一览（app.py 里注册的路由）

| 路由 | 作用 |
| --- | --- |
| `/` | 整个控制台页面（HTML/CSS/JS 由 `render_template_string` 一次性吐出） |
| `/api/status` `/api/ports` | 系统状态、服务端口 |
| `/api/qso` | **通话记录**（Live QSO 看板，0.5 秒轮询 + 0.6 秒折叠缓存） |
| `/api/config/<模式>` | 读某模式配置（走 `web-manage.sh get-config`） |
| `/api/save/<模式>` | 保存某模式配置（走 `mmdvm-save.py`） |
| `/api/switch/<模式>` | 单通道切换模式 |
| `/api/bm-list` `/api/ysf-hosts` `/api/p25-hosts` `/api/nxdn-hosts` `/api/dstar-reflectors` `/api/fcs-hosts` | 各类服务器/反射器列表 |
| `/api/hosts-status` `/api/hosts-rebuild` | 主机库状态与重建 |
| `/api/update-db/<类型>` | 手动触发数据更新（dmrid / dmrhosts / hosts / …） |
| `/api/logs/<服务>` | 服务日志尾部 |
| `/api/diagnose` `/api/backup` `/api/bm-test/<host>` | 诊断 / 备份 / BM 连通性测试 |
| `/api/sys/info` `/api/sys/net` `/api/sys/wifi` `/api/sys/action` `/api/sys/fw/upload` | 系统设置（品牌、WiFi、固件上传…） |
| `/api/restart/<服务>` | 重启服务（白名单） |

### 7.3 前端为什么"看不见文件"

`app.py` 用 `render_template_string` 把 HTML/CSS/JS 当字符串吐出来，
所以**改前端 = 改 app.py**（或用同目录的 `*_page.py` / `*_js.py` 模块拼装）。
改的时候**不要按行号**，用锚点字符串精确替换——文件太大，行号很容易漂。

---

## 第 8 章　关键脚本逐个说

### 8.1 `mmdvm-save.py` —— 所有"保存"的落点

| 函数 | 写入目标 |
| --- | --- |
| `save_rf` | `MMDVM-Host*.ini`（呼号、频点、双工、功率） |
| `save_dmr` | DMR 宿主 + `DMRGateway.ini`；**ESSID 090123 逻辑**：`7 位 DMR ID + 2 位 ESSID(01–99)`，只叠加到 DMR，不污染其它模式 |
| `save_ysf` / `save_p25` / `save_nxdn` / `save_dstar` / `save_fm` / `save_pocsag` | 各自的 .ini |
| `set_identity` / `sync_host_identity_and_frequencies` | 呼号/ID/频点全通道同步 |
| `update_plan_identity` | 把呼号、DMR ID 写回 `channels.json` |
| `restart_channels` | 改完自动重启对应服务 |

**注意**：`save_nxdn` 会同时写 `NXDN` 通道的 `General/Id` 与 `NXDNGateway.ini` 的 `Id`
（历史上这里被写成"NXDN 只用呼号、清掉 ID"，导致 NXDN 上不了网，已修）。

### 8.2 `channel-setup.py` —— 通道规划与模式互斥

- `hardware_limit()`：调 `arch-channel-limit.py` 拿本机上限，返回 1–7
- `apply(plan)`：把 `channels.json` 展开成 `MMDVM-Multi.ini` + 每路 `MMDVM-IQ-N.ini` / `MMDVM-Host-N.ini`
- **模式互斥校验**：只统计"当前运行中的通道"，**未启用的历史通道不算占用**
  （历史 bug：单通道时想从 DMR 切 D-Star 被误报"其它通道占用"）
- 频点网格：`grid_step()` 按采样率算信道间隔

### 8.3 `arch-channel-limit.py` —— 硬件通道上限

| 设备 | 上限 |
| --- | --- |
| 树莓派 2 / Zero 2 | 2 |
| 树莓派 3（含 CM3） | 4 |
| 树莓派 4（内存 < 2 GB） | 4 |
| 树莓派 4（≥ 2 GB）/ 树莓派 5 | 7 |
| 其它设备 | 按 CPU 核数 / 内存判定：≤2 核或 <1.2 GB → 2；≤4 核或 <2.2 GB → 4；否则 7 |

Web 里"保存并应用"时会弹窗提示建议路数与免责声明。

### 8.4 `hosts-build.py` —— 服务器列表生成（含 ARCH 置顶）

输入：`/usr/local/etc/hosts/` 下的上游 txt/json（来自 ARCH 镜像）
输出：`DMR_Hosts.json`、`YSFHosts.json`、`NXDNHosts.json`、`P25Hosts.json`、`DSTAR_Reflectors.json`、`hosts-index.json`

**ARCH 置顶规则**（每次重建都强制生效，防止被上游数据覆盖）：

| 模式 | 置顶条目 |
| --- | --- |
| DMR | `ARCH_46002_China` · `46002 二区多模融合` · `srv2.hljrsa.cn:62030` |
| YSF | `CN-46002` · `二区多模融合 / 哈尔滨` · `ysf.hljrsa.cn:42000`（重复项自动去重） |
| P25 | `46002 二区多模融合` · `39.96.117.224:41000` |
| NXDN | `46002 二区多模融合` · `39.96.117.224:41400` |
| D-STAR | `DCSHLJ / XRFHLJ / REFHLJ / XLXHLJ` · 均带 `46002二区多模融合` |

### 8.5 `mmdvm-status.py` —— 状态与通话记录

两种调用方式：
- 直接跑 → 输出 `=== SERVICES === / PROFILE / SYSTEM / NETWORK / RF FREQ / QSO ===`（Web 首页用）
- `mmdvm-status.py qso` → 只输出通话记录（Live QSO 用）
  - **一次 `journalctl -u … -o json -n 600` 批量取全部通道 + fmo-bridge**（不要改回逐通道多次调用，会拖慢 Pi）

**"通话中"判定**（各地模式"开始行"写法完全不同，这是踩过的坑）：

| 模式 | 开始行 |
| --- | --- |
| DMR | `DMR Slot n, received (RF|network) voice header from X to Y` |
| D-Star | `D-Star, received (RF|network) header from X to Y` |
| YSF | 网络侧无 header，用 `YSF, received network data from X to DG-ID n at X` |
| P25 | `P25, received network transmission from X to TG n`（射频侧 `received RF voice transmission`） |
| NXDN | `NXDN, received network transmission from X to TG n` |
| FM | 由 `fmo_bridge._qso_log()` 合成，**开始行不带时长、结束行带 `N seconds`** |

### 8.6 `web-manage.sh` —— Web 的总调度

`status / ports / switch / get-config / save-XXX / logs / backup / diagnose / update-db / bm-list / bm-test / restart / qso`

- `get-config DMR` 会**先去 `channels.json` 找 DMR 实际在哪一路**，再读那一路的 ini
  （历史 bug：DMR 不在第 1 路时配置页读到空）
- `switch` 单通道时直接**替换**当前通道模式，多通道时只找运行中的空闲位

### 8.7 数据更新：`update-hosts.sh` / `update-database.sh`

- 镜像优先：`https://arch.hljrsa.cn/downloads/arch-hotspot/data/`，失败再退回 Pi-Star 官方
- `update-hosts.sh`：全量（DMR ID + DMR/YSF/P25/NXDN/D-STAR 主机库）→ 调 `hosts-build.py`
- `update-database.sh <类型>`：`dmrid | dmrhosts | ysfhosts | p25hosts | nxdnhosts | dstarhosts | hosts | all`
  - `dmrhosts` 是 1.0.22 新加的（原来根本没有 DMR 列表更新通道）
- `arch-data-bootstrap.sh`：开机自动跑一次，缺什么补什么

### 8.8 `fmo-bridge/` —— 模拟互联（FM 房间）

- `fmo_bridge.py`：主循环。上行（RF→网络）、下行（网络→RF）、MQTT 收发、PCM 转码、尾音、录音触发
- `fmo_voice.py` / `fmo_dsp.py` / `fmo_fm.py` / `fmo_packet.py`：语音与调制相关
- `fmo_reg.py` / `fmo_presence.py`：注册与在线状态上报
- `fmo_app_uplink_relay.py`：手机 APP 上行中继
- **`_qso_log()`**：往 journal 写"看起来像 MMDVM-Host"的通联行，供 Web 通话看板解析
  （开始行**必须不带** `N seconds`，否则前端会当成已结束、不显示红色高亮）
- 证书在 `/opt/fmo-bridge/pki/`（本设备私有，备份里含）

---

## 第 9 章　配置文件索引（`/etc`）

| 文件 | 作用 | 改它会影响 |
| --- | --- | --- |
| `mmdvm/channels.json` | **通道规划总表**：通道数、每路模式与频点 | 一切（改完要 apply） |
| `mmdvm/frequency.json` | 射频中心频率、间隔、采样率 | 频率类页面显示 |
| `mmdvm/active-mode.conf` | 当前生效模式串与通道数 | 首页模式显示 |
| `mmdvm/install-state.conf` | 安装状态 + 各上游组件 commit（溯源用） | 升级判定 |
| `MMDVM-Multi.ini` | 宽带前端参数 | 所有信道 |
| `MMDVM-IQ-N.ini` | 第 N 路 SDR 增益/反相 | 单路射频质量 |
| `MMDVM-Host-N.ini` | 第 N 路协议栈（模式开关、呼号、ID、频点、色码/RAN/NAC/DG-ID） | 单路模式 |
| `DMRGateway.ini` | BM/FreeDMR 主站、端口、密码、Id | DMR 上网 |
| `YSFGateway.ini` / `P25Gateway.ini` / `NXDNGateway.ini` | 各模式网络服务器与 ID | 各模式上网 |
| `ircddbgateway` | D-Star 呼号、`reflector1`、`atStartup1`、DPlus/DExtra 开关 | D-Star 连接 |
| `systemd/system/*.service` | 服务定义 | 启动行为 |
| `nginx/sites-available/mmdvm-web` | 80→18080 反代 | Web 访问 |

> `ircddbgateway` 里的 `reflector1` 决定开机自动链到哪个反射器。
> 出厂模板是**空的**（`reflector1=` / `atStartup1=0`），不会被写死到某个公共反射器。

---

## 第 10 章　权限模型

- Web 进程以 `mmdvm-web` 系统用户运行（无登录 shell）
- 需要 root 的动作，靠 `/etc/sudoers.d/arch-web` 白名单：

```
mmdvm-web ALL=(root) NOPASSWD: /usr/local/bin/web-manage.sh *, /usr/local/bin/web-fmo.sh *,
                               /usr/local/bin/web-multi.sh *, /usr/local/bin/sys-manage.sh *,
                               /usr/local/bin/web-tetra.sh *, /usr/local/libexec/mmdvm-save.py *,
                               /usr/local/libexec/hosts-build.py *
```

**新增需要 root 的功能时，必须同时往这个白名单里加**，否则 Web 上点了没反应（历史坑）。

---

## 第 11 章　构建与发版

### 11.1 打包

```powershell
python <工作区>\artifacts\arch-hotspot-release\make_release.py
```

脚本做的事：

1. 把 `source\` 复制成 `out\internal\` 和 `out\public\`
2. **public**：执行 `public_app()` 等裁剪函数，删掉集群页面/路由/文件，再全树删掉含
   `TETRA|TDR Link|FlowStation|Bluestation` 的行；删掉 `tetra-api.py`、`web-tetra.sh`、
   `tetra-bs.service` 等
3. 两版都执行 **`sanitize()` 脱敏**：

| 原文 | 替换成 |
| --- | --- |
| `<呼号>` | `NOCALL` |
| `<DMR ID>` … | `000000000` |
| `<密码 / token>` | `CHANGE_ME` |
| `nocall.org.cn` | `example.com` |
| `192.168.1.10` / `192.168.1.20` | `192.168.1.10` / `192.168.1.20` |
<div class="author">作者：BG2GA · <a href="https://arch.hljrsa.cn/disclaimer/">免责声明</a></div>

4. 写 `VERSION`（脚本顶部常量 `VERSION = '1.0.24'`，**发版只改这一行**）、`CHANNEL`、`LICENSE`、`DISCLAIMER.md`
5. 从模板生成 `install.sh`、`arch-hotspot-update`、`build_upstream.sh`
6. 所有 `.sh` / `.py` 强制 LF 换行（避免 Windows 上 CRLF 把脚本搞坏）
7. 打 tar.gz + 生成 `.sha256`，并产出 `web\<通道>\current.json` 与云端引导 `install.sh`

### 11.2 发版检查清单

- [ ] `VERSION` 已改
- [ ] 公开包全树 `TETRA|TDR Link|FlowStation` 命中 = **0 个文件**
- [ ] 两个包都没有 `<呼号>` / `<DMR ID>` / `<密码>` / 内网 IP（除页脚作者行）
- [ ] 解包后 `sha256sum -c` 通过、所有 `.sh` 过 `bash -n`、所有 `.py` 过 `python3 -m compileall`
- [ ] 上传 93 `/opt/arch-site/downloads/arch-hotspot/{internal,public}/`（含 `current.json`、`install.sh`）
- [ ] 部署 Cloudflare Pages：`bash /opt/dmr-admin/work/deploy-arch-site.sh`
- [ ] 公网下载回来校验 SHA-256
- [ ] 写 `ARCH-维护记录.md`，更新本备份目录

### 11.3 数据镜像（服务器侧，每 4 小时）

`arch-hotspot-mirror.py` + `arch-hotspot-mirror.timer`（00:15 / 04:15 / 08:15 / 12:15 / 16:15 / 20:15，随机延迟 5 分钟）
抓取全球数据 → 应用 `apply_arch_overlay()` 把 ARCH 自建服务器置顶并写注释 → 部署到 Cloudflare。
设备端 `update-database.sh` 永远从这个镜像取。

---

## 第 12 章　已知坑与修复索引（务必先读）

| 现象 | 根因 | 修法 |
| --- | --- | --- |
| 装了官方 Lite 系统后 `curl` 报 `certificate is not yet valid` | 树莓派时钟未同步，证书时间被判为"未生效" | 引导脚本与升级脚本都带 `curl -k` 回退 |
| 新装设备 DMR 上不了 BM，提示呼号必须与 BM 注册一致 | 打包时把呼号/ID 脱敏成 `NOCALL`/`0000000`，用户没填 | 属正常现象，用户在页面填自己的呼号与 ID |
| 打包后 DMR 2 时隙不能用 / 本地手台发射不解码 | `MMDVM-IQ/IO.cpp` 双工模式下只在发射时喂全时隙解码器 | 已在 `build_upstream.sh` 打补丁（始终喂 `dmrRX`） |
| 单通道切模式被拦"其它通道占用" | 前端与 `channel-setup.py` 把未启用的历史通道也算占用 | 只统计运行中的通道 |
| DMR 配置页读不到值 | `get-config` 只读第 1 路 | 按 `channels.json` 找实际 DMR 通道 |
| 勾选 ESSID 保存后消失 | 后端 `save_dmr` 忽略 ESSID 参数 | 合成 9 位 ID，只写 DMR |
| YSF/P25/NXDN 的网络语音不显示"通话中"、不变红 | 这些模式网络侧**没有 header 行**，前端只认 `header` | 增加 `received network transmission` / `received network data from` / `received rf voice transmission` 关键字与判定 |
| FM 通道也不变红 | 桥的开始行带了 `0.1 seconds`，被判成已结束 | 开始行不带时长 |
| 通话看板刷新慢 / Pi CPU 高 | 每次轮询都要起一个 journalctl + python；8 次调用 | 一次批量 journalctl + 0.6 秒折叠缓存 |
| DMR 列表里没有 ARCH 46002 | `hosts-build.py` 没有置顶逻辑、`update-database.sh` 没有 DMR 更新通道 | 新增 `_ARCH_*` 常量与 `dmrhosts` 子命令 |
| 页面脚本报错、白屏 | 前端全塞在 `app.py` 里，改错一处就整体挂 | 用锚点精确替换；出问题看页面底部的"页面脚本错误"提示条 |
| 录不了音 / 房间列表不全 | 房间来源是 `channels.json`，没启用对应通道就不会出现 | 先开通道，再看录音计划 |

---

## 第 13 章　排障速查

```bash
# 服务全景
systemctl list-units --type=service --no-pager | grep -E 'mmdvm|gateway|fmo|arch-|nginx|mosquitto'

# 某一路的协议日志
sudo journalctl -u mmdvmhost@1.service -f

# Web 后端
sudo journalctl -u mmdvm-web.service -n 100 --no-pager

# 通道规划
cat /etc/mmdvm/channels.json

# 手工跑一遍脚本（比 Web 好排错）
sudo /usr/local/libexec/mmdvm-status.py
sudo /usr/local/libexec/mmdvm-status.py qso | head
sudo /usr/local/bin/web-manage.sh get-config DMR
sudo /usr/local/libexec/hosts-build.py -v

# 数据更新
sudo /usr/local/bin/update-database.sh hosts
sudo /usr/local/bin/update-database.sh dmrhosts
```

**改任何东西之前**：
1. 先备份：`sudo cp -a <文件> <文件>.bak.<说明>-$(date +%Y%m%d-%H%M%S)`
2. 先看日志/先手工验证，别猜
3. 改完立刻 `python3 -m py_compile` / `bash -n` 自检
4. 写进 `ARCH-维护记录.md`

---

## 第 14 章　备份与回滚

| 要备份什么 | 放哪 |
| --- | --- |
| 热点侧全部源码 | `03-配置备份\ARCH热点固件-<版本>-完整备份-<日期>\source\` |
| 两个成品包 + 哈希 + current.json | 同目录 `release\` |
| 打包脚本 | 同目录 `build\` |
| 服务器镜像脚本 | 同目录 `server-mirror\` |
| 实机现场配置 | 同目录 `device-15-live\` |
| 日常开发源码 | `07-当前工程工作区\ARCH-业余玩无线电融合中枢\artifacts\arch-hotspot-release\`（**打包用的就是这份**） |

回滚：
- 单文件 → 设备上 `cp -a xxx.bak.<时间> xxx` 后重启对应服务
- 整包 → 把旧版 tar 传到设备，`tar xzf` 后 `sudo bash install.sh`
- 数据 → `sudo /usr/local/bin/update-database.sh hosts` 重新拉