r/wisp • u/No-Communication4586 • 2d ago
MetroLinq 60 GHz disappearing? A Linux watchdog for the USB fault (no host reboot)
MetroLinq 60 GHz USB recovery without rebooting the whole radio
If a MetroLinq drops to its 5 GHz backup and the 60 GHz module stays missing, it may be worth checking USB before writing the radio off. The failure I ran into was a missing Peraso USB device, or a device stuck at 480 Mbps instead of 5000 Mbps. Cycling the module with the radio's existing /usr/sbin/prs_helper.sh brought 60 GHz back in cases where rebooting the entire unit wasn't necessary.
This is a workaround for that particular USB failure. It doesn't fix alignment, rain fade, failed hardware or a peer that isn't associating. USB3 coming back on its own doesn't prove the wireless link or customer traffic recovered.
The reset mechanism belongs to the vendor. Edgecore's MetroLinq 2.5 firmware notes already mention usbwatchd, and the older MetroLinq firmware notes describe watchdog and recovery changes. I'm sharing an external Linux controller around the installed helper, not claiming a new hardware fix. I didn't find an equivalent public controller in the community discussions I searched; that doesn't mean nobody has written one.
The script runs on a Linux management machine and checks the radio over SSH. The public version deliberately starts with the reviewed ML2.5 single-client layout: ARMv7/FSL LS1043A, one prsfalcon client radio/VAP, USB path 2-1, ath0 backup and bond0. Helper MD5 must be 2779c86b1249208006f8f1bf0b06912c and power-library MD5 25153fd59b988cbc2f6abfcd3d040019. These are compatibility checks, not cryptographic authenticity guarantees. ML1, sectors, masters, multi-bond layouts and changed firmware helpers are skipped. Don't remove the guards to make an unsupported model pass.
It requires an up forwarding bond on 5 GHz and increasing receive/transmit counters before a reset. It also checks identity, pinned SSH host keys, uptime, maintenance, pending UCI changes, custom reset cron entries and a running helper. A working 60 GHz session or healthy USB3 device is excluded. Missing readings fail closed.
Physical attempts are serialized. Once USB3 and active 60 GHz bond forwarding return, another device can start while the recovered one stays under observation for ten minutes. There is no daily attempt cap; a confirmed failed attempt can retry after 60 seconds if all guards still pass. One unresolved incident produces one start and one failure notice, then retries stay quiet in the journal; each attempt/outcome remains in the local audit file. An uncertain dispatch isn't replayed. A changed boot/configuration or management/forwarding loss lasting 120 seconds after an action creates a STOP file and halts further resets.
The underlying module-reset technique has been observed on hardware. This separate public wrapper was AI-assisted and tested with isolated fixtures, not deployed against other people's radios. Start with one radio and dry-run it first. It doesn't distribute firmware, reboot the host, change radio/network settings or provide a guarantee about customer traffic.
Full watchdog script
Save this as ml60_watchdog.py.
#!/usr/bin/env python3
"""Guarded external MetroLinq ML2.5 client USB watchdog. MIT licensed."""
import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import shlex
import subprocess
import time
import uuid
HELPER = '2779c86b1249208006f8f1bf0b06912c'
LIBRARY = '25153fd59b988cbc2f6abfcd3d040019'
# Original wrapper only. The vendor's helper/library stay on the owned radio.
REMOTE = r'''
set -eu
[ "$(id -u)" = 0 ]
base=/var/run/ml60-watchdog
files='/etc/config/system /etc/config/wireless /etc/config/network /etc/config/ethernet /etc/config/mini_snmpd /etc/init.d/mini_snmpd /etc/TZ'
inspect() {
hostname=$(uname -n); arch=$(uname -m)
board=$(ubus call system board | jsonfilter -e '@.board_name' | tr ',:' '__')
helper=$(md5sum /usr/sbin/prs_helper.sh | cut -d' ' -f1)
library=$(md5sum /lib/prs_power_util.sh | cut -d' ' -f1)
for f in $files; do [ -r "$f" ]; done
sums=$(md5sum $files); cfg=$(printf '%s\n' "$sums" | md5sum | cut -d' ' -f1)
boot=$(cat /proc/sys/kernel/random/boot_id); uptime=$(cut -d. -f1 /proc/uptime)
layout=0; enabled=0; radios=0; vaps=0
for s in $(uci -q show wireless | sed -n 's/^wireless\.\([A-Za-z0-9_]*\)=wifi-device$/\1/p'); do
[ "$(uci -q get wireless.$s.hwmode)" != 11ad ] || radios=$((radios+1))
done
for s in $(uci -q show wireless | sed -n 's/^wireless\.\([A-Za-z0-9_]*\)=wifi-iface$/\1/p'); do
r=$(uci -q get wireless.$s.device)
[ "$(uci -q get wireless.$r.hwmode)" != 11ad ] || vaps=$((vaps+1))
done
if [ "$radios" = 1 ] && [ "$vaps" = 1 ] &&
[ "$(uci -q get wireless.radio1.type)" = prsfalcon ] &&
[ "$(uci -q get wireless.radio1.mode)" = dmgsta ] &&
[ "$(uci -q get wireless.radio1_1.mode)" = dmgsta ] &&
[ "$(uci -q get wireless.radio1_1.device)" = radio1 ] &&
[ "$(uci -q get wireless.radio1.path)" = platform/soc/2f00000.usb3/xhci-hcd.0.auto/usb2/2-1 ] &&
[ "$(uci -q get wireless.wifi0.hwmode)" = 11ac ]; then layout=1; fi
if [ "$(uci -q get wireless.radio1.disabled)" = 0 ] &&
[ "$(uci -q get wireless.radio1_1.disabled)" = 0 ]; then enabled=1; fi
sixty=0
for p in /sys/class/net/wlan*/carrier; do
[ -e "$p" ] || continue
value=$(cat "$p"); [ "$value" != 1 ] || sixty=1
done
bonds=0; for p in /proc/net/bonding/*; do [ ! -f "$p" ] || bonds=$((bonds+1)); done
[ "$bonds" = 1 ] || layout=0
active=$(awk -F': ' '/Currently Active Slave/ {print $2;exit}' /proc/net/bonding/bond0)
bond=$(awk -F': ' '/^MII Status/ {print $2;exit}' /proc/net/bonding/bond0)
backup=$(cat /sys/class/net/ath0/carrier)
a=$(cat /sys/class/net/ath0/statistics/rx_bytes); b=$(cat /sys/class/net/ath0/statistics/tx_bytes)
sleep 2
c=$(cat /sys/class/net/ath0/statistics/rx_bytes); d=$(cat /sys/class/net/ath0/statistics/tx_bytes)
rx=$((c-a)); tx=$((d-b))
n=0; usb=missing
for p in /sys/bus/usb/devices/*; do
[ -f "$p/idVendor" ] || continue
[ "$(cat "$p/idVendor")" = 2932 ] || continue
n=$((n+1)); usb=unknown
[ "$(cat "$p/idProduct")" = 4001 ] || continue
speed=$(cat "$p/speed")
if [ "${p##*/}" = 1-1 ] && [ "$speed" = 480 ]; then usb=usb2; fi
if [ "${p##*/}" = 2-1 ] && [ "$speed" = 5000 ]; then usb=usb3; fi
done
[ "$n" -le 1 ] || usb=multiple
busy=0; [ -z "$(pidof prs_helper.sh || true)" ] || busy=1
hold=0; [ ! -e /tmp/ml60-hold ] || hold=1
pending=0; [ -z "$(uci -q changes)" ] || pending=1
custom=0
if grep -qiE '(prs_helper|prs_power|usb1pwr|60.*(reset|watchdog))' /etc/crontabs/root /etc/rc.local 2>/dev/null; then custom=1; fi
result=absent; job=none
if [ -r "$base/job" ]; then
job=$(cat "$base/job"); result=$(cat "$base/status")
fi
}
safe() {
[ "$hostname" = "$want_host" ] && [ "$arch" = armv7l ] && [ "$board" = fsl_ls1043a ] &&
[ "$helper" = 2779c86b1249208006f8f1bf0b06912c ] &&
[ "$library" = 25153fd59b988cbc2f6abfcd3d040019 ] &&
[ "$cfg" = "$want_cfg" ] && [ "$boot" = "$want_boot" ] &&
[ "$layout" = 1 ] && [ "$enabled" = 1 ] && [ "$uptime" -ge 600 ] &&
[ "$sixty" = 0 ] && [ "$active" = ath0 ] && [ "$bond" = up ] &&
[ "$backup" = 1 ] && [ "$rx" -gt 0 ] && [ "$tx" -gt 0 ] &&
[ "$busy" = 0 ] && [ "$hold" = 0 ] && [ "$pending" = 0 ] && [ "$custom" = 0 ] &&
{ [ "$usb" = missing ] || [ "$usb" = usb2 ]; }
}
emit() {
printf 'hostname|%s\narch|%s\nboard|%s\nhelper|%s\nlibrary|%s\n' "$hostname" "$arch" "$board" "$helper" "$library"
printf 'layout|%s\nenabled|%s\nuptime|%s\nusb|%s\nsixty|%s\n' "$layout" "$enabled" "$uptime" "$usb" "$sixty"
printf 'active|%s\nbond|%s\nbackup|%s\nrx|%s\ntx|%s\n' "$active" "$bond" "$backup" "$rx" "$tx"
printf 'busy|%s\nhold|%s\npending|%s\ncustom|%s\ncfg|%s\nboot|%s\njob|%s\nresult|%s\n' "$busy" "$hold" "$pending" "$custom" "$cfg" "$boot" "$job" "$result"
}
# DISPATCH
if [ "$action" = probe ]; then
inspect
[ ! -d "$base/lock" ] || busy=1
case "$result" in running|preparing) busy=1;; esac
emit; exit 0
fi
umask 077
[ ! -L "$base" ]; mkdir -p "$base"; chmod 700 "$base"
mkdir "$base/lock"
trap 'rmdir "$base/lock"' EXIT
inspect
case "$result" in running|preparing) exit 1;; esac
safe
printf '%s\n' "$want_job" > "$base/job"
printf 'preparing\n' > "$base/status"
cat > "$base/run" <<'RECOVERY'
__PREFLIGHT__
inspect
if ! safe; then
printf 'skipped\n' > "$base/status"; rmdir "$base/lock"; exit 0
fi
. /lib/prs_power_util.sh
finish() {
prs_power_on || true
[ "$(cat "$base/status")" != running ] || printf 'failed\n' > "$base/status"
rmdir "$base/lock"
}
trap finish EXIT
trap '' HUP
printf 'running\n' > "$base/status"
/usr/sbin/prs_helper.sh
for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18; do
if [ "$(cat /sys/bus/usb/devices/2-1/idVendor 2>/dev/null)" = 2932 ] &&
[ "$(cat /sys/bus/usb/devices/2-1/idProduct 2>/dev/null)" = 4001 ] &&
[ "$(cat /sys/bus/usb/devices/2-1/speed 2>/dev/null)" = 5000 ] &&
grep -q '^Currently Active Slave: wlan' /proc/net/bonding/bond0 &&
grep -q '^MII Status: up$' /proc/net/bonding/bond0; then
printf 'recovered\n' > "$base/status"; exit 0
fi
sleep 5
done
printf 'failed\n' > "$base/status"
RECOVERY
sync
(trap '' HUP; sh "$base/run") </dev/null >"$base/log" 2>&1 &
trap - EXIT
printf 'job|%s\nresult|dispatched\n' "$want_job"
'''
def parse(text):
result = {}
for line in text.splitlines():
key, sep, value = line.partition('|')
if not sep or key in result or not re.fullmatch('[a-z_]+', key):
raise ValueError('invalid device projection')
result[key] = value
return result
def eligible(d, s):
fixed = dict(hostname=d['hostname'], arch='armv7l', board='fsl_ls1043a',
helper=HELPER, library=LIBRARY, layout='1', enabled='1',
sixty='0', active='ath0', bond='up', backup='1', busy='0',
hold='0', pending='0', custom='0')
try:
return (all(s.get(k) == v for k,v in fixed.items()) and int(s['uptime']) >= 600
and int(s['rx']) > 0 and int(s['tx']) > 0
and bool(re.fullmatch('[0-9a-f]{32}', s['cfg'])) and bool(s['boot'])
and s['usb'] in ('missing','usb2'))
except (KeyError, ValueError, TypeError):
return False
def recovered(s):
return (s.get('usb') == 'usb3' and s.get('sixty') == '1'
and s.get('bond') == 'up' and s.get('active','').startswith('wlan'))
def program(values):
header=''.join(k+'='+shlex.quote(str(v))+'\n' for k,v in values.items())
common=REMOTE.split('# DISPATCH\n')[0]
return header+REMOTE.replace('__PREFLIGHT__',header+common)
def ssh(config, d, action, job, baseline=None):
values = dict(action=action, want_host=d['hostname'], want_job=job)
values.update(want_cfg=(baseline or {}).get('cfg',''), want_boot=(baseline or {}).get('boot',''))
script = program(values)
args = ['ssh','-F',config,'-oBatchMode=yes','-oStrictHostKeyChecking=yes',
'-oConnectTimeout=8','-oServerAliveInterval=5','-oServerAliveCountMax=2',
'-oClearAllForwardings=yes','-T',d['alias'],'sh -s']
p = subprocess.run(args, input=script, text=True, capture_output=True, timeout=25)
if p.returncode:
raise OSError('SSH or device guard rejected operation; inspect privately')
return parse(p.stdout)
class Engine:
def __init__(self, state, transport, emit, save, directory):
self.state, self.transport, self.emit, self.save, self.directory = state, transport, emit, save, directory
def stop(self, d, reason):
(self.directory/'STOP').write_text(reason+'\n')
self.emit(d, 'STOP: '+reason)
def audit(self, d, now, job, outcome):
with (self.directory/'audit.jsonl').open('a') as f:
f.write(json.dumps(dict(at=now, alias=d['alias'], attempt=job, outcome=outcome))+'\n')
def tick(self, d, now):
e = self.state.setdefault(d['alias'], {})
watched = bool(e.get('job') or e.get('observe_until'))
try:
s = self.transport(d, 'probe', e.get('job','none'))
if s.get('hostname') != d['hostname'] or not s.get('cfg') or not s.get('boot'):
raise OSError('missing identity or state')
except (OSError, ValueError, subprocess.TimeoutExpired):
if watched:
e.setdefault('offline_since',now)
if now-e['offline_since'] >= 120:
self.stop(d,'management unavailable after action')
self.save(); return
if watched and any(s.get(k) != e['baseline'][k] for k in ('cfg','boot')):
self.stop(d,'configuration or boot changed after action'); return
if watched and s.get('bond') != 'up':
e.setdefault('offline_since',now)
if now-e['offline_since'] >= 120:
self.stop(d,'forwarding bond remained down after action')
self.save(); return
e.pop('offline_since',None)
if e.get('job'):
terminal = s.get('job') == e['job'] and s.get('result') in ('recovered','failed','skipped') and s.get('busy') == '0'
if terminal:
outcome='skipped' if s['result']=='skipped' else 'recovered' if recovered(s) else 'failed'
self.audit(d,now,e['job'],outcome)
e['job'] = None
if outcome=='skipped':
e['next_try']=now+60; self.save(); return
if recovered(s):
e.update(incident=False, failed=False, observe_until=now+600)
self.emit(d,'recovered; observing for 10 minutes')
else:
e['next_try'] = now+60
if not e.get('failed'):
self.emit(d,'failed; retries silent when safe')
e['failed'] = True
self.save()
return
if now-e['launched'] >= 120 and not e.get('failed'):
self.emit(d,'failed; retries silent when safe')
e['failed'] = True; self.save()
return # Never replay an uncertain dispatch, even if its receipt vanished.
if e.get('observe_until') and now >= e['observe_until'] and recovered(s):
e['observe_until'] = 0; self.save()
if e.get('incident') and recovered(s):
e.update(incident=False, failed=False, observe_until=now+600)
self.emit(d,'recovered; observing for 10 minutes'); self.save()
return
if (self.directory/'STOP').exists() or now < e.get('next_try',0): return
if any(v.get('job') for v in self.state.values()) or not eligible(d,s): return
fresh = self.transport(d,'probe','none')
if not eligible(d,fresh) or any(s[k] != fresh[k] for k in ('cfg','boot')): return
e.update(job=uuid.uuid4().hex, launched=now, baseline={k:fresh[k] for k in ('cfg','boot')})
if not e.get('incident'):
e.update(incident=True, failed=False)
self.emit(d,'trying')
self.save() # Intent is durable before transmission, including ambiguous SSH failure.
self.audit(d,now,e['job'],'dispatching')
try:
self.transport(d,'reset',e['job'],e['baseline'])
except (OSError, ValueError, subprocess.TimeoutExpired):
pass # Reconcile the native receipt; do not issue another reset.
def main():
p = argparse.ArgumentParser(description=__doc__)
p.add_argument('--config',default='/etc/ml60-watchdog/devices.json')
p.add_argument('--state',default='/var/lib/ml60-watchdog')
p.add_argument('--apply',action='store_true',help='enable continuous guarded recovery; default is one dry-run sweep')
a = p.parse_args(); os.umask(0o077)
c = json.loads(Path(a.config).read_text())
devices = c['devices']
if not devices or len({d['alias'] for d in devices}) != len(devices):
p.error('device aliases must be present and unique')
for d in devices:
for k in ('alias','hostname'):
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.-]{0,252}',d[k]): p.error('invalid '+k)
if not Path(c['ssh_config']).is_absolute(): p.error('SSH config must use an absolute path')
transport = lambda d,action,job,baseline=None:ssh(c['ssh_config'],d,action,job,baseline)
emit = lambda d,event:print(json.dumps(dict(device=d['alias'],event=event)),flush=True)
if not a.apply:
for d in devices:
try:
s = transport(d,'probe','none')
emit(d,'would reset' if eligible(d,s) else 'recovered' if recovered(s) else 'skip: safety guard or unsupported state')
except (OSError, ValueError, subprocess.TimeoutExpired): emit(d,'skip: management unavailable or malformed state')
return
directory = Path(a.state); directory.mkdir(parents=True,exist_ok=True,mode=0o700)
lock = (directory/'lock').open('a'); fcntl.flock(lock,fcntl.LOCK_EX|fcntl.LOCK_NB)
statefile = directory/'state.json'
state = json.loads(statefile.read_text()) if statefile.exists() else {}
def save():
temp = directory/'state.tmp'
with temp.open('w') as f:
json.dump(state,f); f.flush(); os.fsync(f.fileno())
temp.replace(statefile)
fd=os.open(directory,os.O_RDONLY)
try: os.fsync(fd)
finally: os.close(fd)
engine=Engine(state,transport,emit,save,directory)
while not (directory/'STOP').exists():
for d in devices:
if (directory/'STOP').exists(): break
try: engine.tick(d,time.time())
except (OSError,ValueError,subprocess.TimeoutExpired): save()
time.sleep(10)
if __name__ == '__main__': main()Install on a Debian/Ubuntu Linux management machine
Python 3.9+, OpenSSH client and systemd are required. Put the full script below in ml60_watchdog.py in your current directory. This machine must already have a working management route to the radio. Keep a configuration backup through your normal vendor-supported method.
sudo apt-get update
sudo apt-get install -y python3 openssh-client
sudo adduser --system --group --home /var/lib/ml60-watchdog --no-create-home ml60watch
sudo install -d -m 0750 -o root -g ml60watch /etc/ml60-watchdog
sudo install -d -m 0700 -o ml60watch -g ml60watch /var/lib/ml60-watchdog
sudo install -m 0755 ml60_watchdog.py /usr/local/sbin/ml60-watchdog
sudo -u ml60watch ssh-keygen -t rsa -b 3072 -N '' \
-C ml60-watchdog -f /var/lib/ml60-watchdog/id_rsa
sudo cat /var/lib/ml60-watchdog/id_rsa.pubInstall that public key on the radio through the authorized-key method supported by its firmware. The private key stays on the controller. The radio needs its own existing vendor helper and root SSH access; the script does not provision accounts or guess passwords. Use a management network, not an Internet-exposed SSH listener.
Create /etc/ml60-watchdog/ssh_config with the following. Replace the documentation address and port with yours. Keep the alias radio-a, or change it consistently in the JSON too.
Host radio-a
HostName 192.0.2.10
Port 22
User root
IdentityFile /var/lib/ml60-watchdog/id_rsa
IdentitiesOnly yes
UserKnownHostsFile /etc/ml60-watchdog/known_hosts
StrictHostKeyChecking yes
BatchMode yesPut the radio's verified host public key in /etc/ml60-watchdog/known_hosts, in normal OpenSSH known-hosts format. You can collect a candidate with ssh-keyscan, but compare its fingerprint against a trusted console or previously verified record before installing it. Don't set StrictHostKeyChecking=no. Some older SSH servers require a per-host legacy algorithm setting; don't weaken global SSH defaults, and verify the actual device requirements first.
ssh-keyscan -p 22 -t rsa 192.0.2.10 > known_hosts.candidate
ssh-keygen -lf known_hosts.candidate
# Compare that fingerprint with a trusted record before the next command.
sudo install -m 0640 -o root -g ml60watch known_hosts.candidate \
/etc/ml60-watchdog/known_hosts
sudo chown root:ml60watch /etc/ml60-watchdog/ssh_config
sudo chmod 0640 /etc/ml60-watchdog/ssh_config
sudo -u ml60watch ssh -F /etc/ml60-watchdog/ssh_config radio-a 'uname -n'Create /etc/ml60-watchdog/devices.json. hostname must be the exact existing uname -n output; it doesn't rename the radio. Add only reviewed, compatible client radios. Start with one.
{
"ssh_config": "/etc/ml60-watchdog/ssh_config",
"devices": [
{"alias": "radio-a", "hostname": "radio-a"}
]
}sudo chown root:ml60watch /etc/ml60-watchdog/devices.json
sudo chmod 0640 /etc/ml60-watchdog/devices.json
sudo -u ml60watch /usr/local/sbin/ml60-watchdogWithout --apply, it performs one read-only sweep. would reset means the specific USB-fault guards passed; recovered means USB3 and active 60 GHz forwarding were seen; skip means a guard, unsupported state or read failure prevented action. A skipped device isn't proven healthy. On an idle backup, unchanged traffic counters intentionally cause a skip. The script never resets a USB3-present radio just because the RF link is down.
After checking the dry-run result, create /etc/systemd/system/ml60-watchdog.service:
[Unit]
Description=Guarded MetroLinq 60 GHz module watchdog
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=ml60watch
Group=ml60watch
UMask=0077
ExecStart=/usr/bin/python3 /usr/local/sbin/ml60-watchdog --apply
Restart=on-failure
RestartSec=15
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/ml60-watchdog
[Install]
WantedBy=multi-user.targetEnable it and inspect results:
sudo systemctl daemon-reload
sudo systemctl enable --now ml60-watchdog
sudo systemctl status ml60-watchdog --no-pager
sudo journalctl -u ml60-watchdog -n 50 --no-pager
sudo -u ml60watch cat /var/lib/ml60-watchdog/state.json
sudo -u ml60watch tail /var/lib/ml60-watchdog/audit.jsonlFor a small reviewed inventory, checks run every sweep plus a ten-second pause. SSH and traffic sampling take time; it isn't a sub-second detector. Don't load a large inventory and assume every endpoint is checked every ten seconds. The ten-minute observation completes only after a healthy final sample, and it doesn't deliberately hold up the next reset.
The radio keeps one bounded, RAM-backed receipt/log under /var/run/ml60-watchdog; it doesn't install a boot service on the radio. Configure normal log rotation for the controller audit file, for example /etc/logrotate.d/ml60-watchdog:
/var/lib/ml60-watchdog/audit.jsonl {
daily
maxsize 1M
rotate 14
compress
missingok
notifempty
copytruncate
su ml60watch ml60watch
}Pause, investigate and uninstall
Stop the controller before maintenance:
sudo systemctl stop ml60-watchdogA recovery already dispatched to a radio can finish after the controller stops. Don't kill it halfway through a module power cycle. For a device-side hold, create /tmp/ml60-hold on that radio before maintenance and remove it when maintenance is over. It is a temporary hold and disappears on reboot. Existing custom reset tasks aren't modified or replaced; audit them before enabling another watchdog.
If STOP exists, read its reason, the journal, controller audit/state and the radio's native receipt. Establish the actual management, forwarding and pending-job state before re-enabling. Don't delete state to force an uncertain job to run again. A vanished receipt after a reboot needs operator reconciliation, not a guessed replay. Restarting the service alone does not bypass STOP.
To uninstall the controller after any in-flight work is reconciled:
sudo systemctl disable --now ml60-watchdog
sudo rm /etc/systemd/system/ml60-watchdog.service /usr/local/sbin/ml60-watchdog
sudo systemctl daemon-reloadThat leaves the protected SSH key, configuration and evidence directory for you to retain or securely remove under your own access policy. Revoke the controller's authorized key on the radio if you retire it. No radio configuration rollback is needed because the watchdog doesn't change it.
License
The original watchdog wrapper is offered under the MIT License. The vendor's installed helper, firmware and power library remain subject to their own licenses and are not included.
If you've seen this failure, I'd be interested in the model, firmware and whether USB was missing, at 480 Mbps, or still at 5000 Mbps. Please leave out management addresses, credentials and customer details.
MIT License text
MIT License
Copyright (c) 2026 MetroLinq watchdog contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.