Files
julioandClaude Opus 5.5 f8d2ebf705 fix(placa): Wi-Fi só vale se alcança o roteador da casa
Associar numa rede que não é a da casa fazia o serviço dar o Wi-Fi como
conectado, mover o cabo para .34 e responder pelo wlan0: a placa sumia da
rede. Agora:
- a conexão só conta quando o gateway responde pelo wlan0;
- falha com mensagem "conectei em X, mas não chego ao roteador";
- rotas do wlan0 com métrica 600 (o cabo continua preferido);
- ignore_routes_with_linkdown: cabo desligado não segura rota.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 03:17:28 -03:00

402 lines
19 KiB
Python

#!/usr/bin/env python3
"""Wi-Fi da placa com modo de configuração (estilo "Wi-Fi de hotel").
- Rede salva e encontrada: conecta (cliente) e, se pedido, usa o endereço fixo 192.168.1.35.
- Sem rede salva, ou a rede sumiu por alguns minutos: vira o ponto de acesso "Carinha-Config".
Quem conecta nele cai numa página (abre sozinha no celular) para escolher a rede e a senha.
- No modo configuração, sem ninguém conectado, tenta voltar à rede salva a cada 5 minutos
(faltou luz e a placa ligou antes do roteador: não fica presa aqui).
O chip (unisoc_wifi) não faz cliente e ponto de acesso ao mesmo tempo: o serviço alterna entre os
dois. O cabo de rede fica com um endereço fixo de reserva (192.168.1.34) para nunca perder o acesso.
Só usa a biblioteca padrão; hostapd e dnsmasq rodam como processos filhos.
"""
import hashlib
import html
import json
import os
import re
import signal
import subprocess
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs
IFACE = 'wlan0'
CABO = 'end0'
DIR = Path('/etc/carinha-wifi')
REDE = DIR / 'rede.json' # rede salva (só root lê)
AJUSTES = DIR / 'ajustes.json' # nome/senha do ponto de acesso
RUN = Path('/run/carinha-wifi')
ESTADO = RUN / 'estado.json' # para o painel mostrar
NETWORK = Path('/run/systemd/network/30-carinha-wlan0.network')
NETPLAN_CABO = Path('/etc/netplan/10-dhcp-all-interfaces.yaml')
AP_IP = '192.168.4.1'
IP_FIXO, GATEWAY, DNS = '192.168.1.35', '192.168.1.1', '192.168.1.1'
IP_CABO_RESERVA = '192.168.1.34'
ESPERA_CONECTAR = 60 # s para conectar antes de desistir e virar ponto de acesso
QUEDA_TOLERADA = 180 # s sem a rede (já conectado) até virar ponto de acesso
RETENTAR_A_CADA = 300 # s no modo configuração, sem ninguém conectado, para tentar a rede salva
PADRAO_AJUSTES = dict(ap_nome='Carinha-Config', ap_senha='carinha123')
def log(*a):
print('carinha-wifi:', *a, flush=True)
def sh(*cmd, check=False, timeout=20):
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if check and r.returncode:
raise RuntimeError(f'{cmd[0]} falhou: {r.stderr.strip()[:200]}')
return r
def ler_json(caminho, padrao):
try:
return json.loads(Path(caminho).read_text(encoding='utf-8'))
except (OSError, ValueError):
return padrao
def gravar(caminho, dados, modo=0o600):
caminho = Path(caminho)
caminho.parent.mkdir(parents=True, exist_ok=True)
tmp = caminho.with_suffix('.tmp')
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, modo)
with os.fdopen(fd, 'w', encoding='utf-8') as f:
f.write(dados if isinstance(dados, str) else json.dumps(dados, ensure_ascii=False, indent=2))
tmp.replace(caminho)
class Wifi:
def __init__(self):
self.modo = None # 'cliente' | 'ap'
self.desde = time.time()
self.ok_em = 0.0
self.erro = ''
self.redes = [] # última busca: [{ssid, sinal, seguranca}]
self.filhos = {}
self.portal = None
self.lock = threading.RLock()
self.trocar_para = None # pedido vindo da página
self.anterior = False # rede salva antes do pedido (False = nenhum pedido em teste)
RUN.mkdir(parents=True, exist_ok=True)
# ------------------------------------------------------------ utilidades
def ajustes(self):
return dict(PADRAO_AJUSTES, **ler_json(AJUSTES, {}))
def rede(self):
r = ler_json(REDE, None)
return r if isinstance(r, dict) and r.get('ssid') else None
def conectado(self):
link = sh('iw', 'dev', IFACE, 'link').stdout
return 'Connected to' in link
def gateway_wlan(self):
m = re.search(r'default via (\S+)', sh('ip', '-4', 'route', 'show', 'dev', IFACE).stdout)
return m.group(1) if m else (GATEWAY if (self.rede() or {}).get('ip_fixo', True) else '')
def chega_na_rede(self):
"""Associar não basta: o roteador tem que responder PELO Wi-Fi. Uma rede que não é a da casa
(outro roteador, rede de visitante) associa normalmente e deixaria a placa inacessível."""
gw = self.gateway_wlan()
return bool(gw) and sh('ping', '-c', '1', '-W', '2', '-I', IFACE, gw, timeout=6).returncode == 0
def ip_wlan(self):
m = re.search(r'inet (\d+\.\d+\.\d+\.\d+)', sh('ip', '-4', 'addr', 'show', IFACE).stdout)
return m.group(1) if m else ''
def publicar(self):
r = self.rede()
gravar(ESTADO, dict(modo=self.modo, desde=self.desde, rede=r['ssid'] if r else None, ip=self.ip_wlan(),
erro=self.erro, ap=self.ajustes()['ap_nome']), 0o644)
def buscar_redes(self):
"""Busca redes (só funciona como cliente; no modo ponto de acesso usa a última busca)."""
sh('ip', 'link', 'set', IFACE, 'up')
saida = sh('iw', 'dev', IFACE, 'scan', timeout=30).stdout
if not saida:
saida = sh('iw', 'dev', IFACE, 'scan', 'ap-force', timeout=30).stdout
redes, atual = {}, {}
for linha in saida.splitlines():
linha = linha.strip()
if linha.startswith('BSS '):
atual = {}
elif linha.startswith('signal:'):
atual['sinal'] = float(linha.split()[1])
elif linha.startswith('SSID:'):
ssid = linha[5:].strip()
if ssid and '\\x00' not in ssid:
atual['ssid'] = ssid
if ssid not in redes or redes[ssid]['sinal'] < atual.get('sinal', -100):
redes[ssid] = dict(ssid=ssid, sinal=atual.get('sinal', -100), seguranca=True)
if redes:
self.redes = sorted(redes.values(), key=lambda r: -r['sinal'])[:20]
return self.redes
def parar_filhos(self):
for nome, p in list(self.filhos.items()):
if p.poll() is None:
p.terminate()
try:
p.wait(timeout=5)
except subprocess.TimeoutExpired:
p.kill()
self.filhos.clear()
sh('pkill', '-f', f'wpa_supplicant.*{RUN}')
# ------------------------------------------------------------ cliente
def modo_cliente(self, rede):
log('conectando em', rede['ssid'])
self.parar_portal()
self.parar_filhos()
sh('ip', 'addr', 'flush', 'dev', IFACE)
sh('ip', 'link', 'set', IFACE, 'down')
sh('iw', 'dev', IFACE, 'set', 'type', 'managed')
sh('ip', 'link', 'set', IFACE, 'up')
sh('iw', 'dev', IFACE, 'set', 'power_save', 'off') # economia de energia atrasa e derruba a conexão
ssid_hex = rede['ssid'].encode().hex()
if rede.get('senha'):
psk = hashlib.pbkdf2_hmac('sha1', rede['senha'].encode(), rede['ssid'].encode(), 4096, 32).hex()
seg = f'key_mgmt=WPA-PSK\n psk={psk}'
else:
seg = 'key_mgmt=NONE'
gravar(RUN / 'wpa.conf', f'ctrl_interface={RUN}/wpa\ncountry=BR\nnetwork={{\n ssid={ssid_hex}\n scan_ssid=1\n {seg}\n}}\n')
self.filhos['wpa'] = subprocess.Popen(['wpa_supplicant', '-i', IFACE, '-D', 'nl80211', '-c', str(RUN / 'wpa.conf')],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if rede.get('ip_fixo', True):
# rotas do Wi-Fi com métrica maior: com o cabo ligado, o cabo sempre ganha
conteudo = (f'[Match]\nName={IFACE}\n\n[Network]\nDNS={DNS}\n\n[Address]\nAddress={IP_FIXO}/24\nRouteMetric=600\n'
f'\n[Route]\nGateway={GATEWAY}\nMetric=600\n')
else:
conteudo = f'[Match]\nName={IFACE}\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nRouteMetric=600\n'
gravar(NETWORK, conteudo, 0o644) # o systemd-networkd põe o endereço quando conectar
sh('networkctl', 'reload')
sh('networkctl', 'reconfigure', IFACE)
self.modo, self.desde, self.ok_em = 'cliente', time.time(), 0.0
self.publicar()
def liberar_ip_fixo_do_cabo(self):
"""O cabo usava 192.168.1.35: passa para 192.168.1.34 (reserva) para o Wi-Fi assumir o .35."""
try:
texto = NETPLAN_CABO.read_text(encoding='utf-8')
except OSError:
return
if f'- {IP_FIXO}/24' not in texto:
return
log('cabo passa para', IP_CABO_RESERVA, '(o Wi-Fi fica com', IP_FIXO + ')')
NETPLAN_CABO.with_suffix('.yaml.antes-wifi').write_text(texto, encoding='utf-8')
gravar(NETPLAN_CABO, texto.replace(f'- {IP_FIXO}/24', f'- {IP_CABO_RESERVA}/24'), 0o600)
sh('netplan', 'generate')
sh('networkctl', 'reload')
sh('networkctl', 'reconfigure', CABO)
# ------------------------------------------------------------ ponto de acesso
def modo_ap(self, motivo):
log('modo configuração:', motivo)
self.parar_filhos()
try:
NETWORK.unlink()
except FileNotFoundError:
pass
sh('networkctl', 'reload')
if self.modo != 'ap':
try:
self.buscar_redes() # ainda como cliente: guarda a lista para a página
except Exception as e:
log('busca falhou:', e)
aj = self.ajustes()
sh('ip', 'addr', 'flush', 'dev', IFACE)
sh('ip', 'link', 'set', IFACE, 'down')
sh('iw', 'dev', IFACE, 'set', 'type', '__ap')
sh('ip', 'link', 'set', IFACE, 'up')
sh('ip', 'addr', 'add', f'{AP_IP}/24', 'dev', IFACE)
gravar(RUN / 'hostapd.conf', '\n'.join([
f'interface={IFACE}', 'driver=nl80211', f'ssid={aj["ap_nome"]}', 'country_code=BR', 'hw_mode=g', 'channel=6',
'wmm_enabled=1', 'auth_algs=1', 'wpa=2', 'wpa_key_mgmt=WPA-PSK', 'rsn_pairwise=CCMP',
f'wpa_passphrase={aj["ap_senha"]}', '']))
self.filhos['hostapd'] = subprocess.Popen(['hostapd', str(RUN / 'hostapd.conf')],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.filhos['dnsmasq'] = subprocess.Popen([
'dnsmasq', '--keep-in-foreground', '--conf-file=/dev/null', '--no-resolv', '--no-hosts',
f'--interface={IFACE}', '--bind-interfaces', f'--listen-address={AP_IP}', '--except-interface=lo',
'--dhcp-range=192.168.4.10,192.168.4.60,255.255.255.0,1h', f'--dhcp-option=3,{AP_IP}',
f'--dhcp-option=6,{AP_IP}', f'--dhcp-option=114,http://{AP_IP}/', f'--address=/#/{AP_IP}',
f'--pid-file={RUN}/dnsmasq.pid'], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
self.iniciar_portal()
self.modo, self.desde = 'ap', time.time()
self.publicar()
def alguem_no_ap(self):
return 'Station' in sh('iw', 'dev', IFACE, 'station', 'dump').stdout
# ------------------------------------------------------------ página
def iniciar_portal(self):
if self.portal:
return
wifi = self
class Portal(BaseHTTPRequestHandler):
def responder(self, codigo, corpo='', tipo='text/html; charset=utf-8', extra=None):
dados = corpo.encode('utf-8')
self.send_response(codigo)
for k, v in (extra or {}).items():
self.send_header(k, v)
self.send_header('Content-Type', tipo)
self.send_header('Content-Length', str(len(dados)))
self.send_header('Cache-Control', 'no-store')
self.end_headers()
self.wfile.write(dados)
def do_GET(self):
if self.path.split('?')[0] in ('/', '/index.html'):
return self.responder(200, wifi.pagina())
# Android/iPhone/Windows testam "tem internet?": a resposta errada faz a página abrir sozinha
return self.responder(302, extra={'Location': f'http://{AP_IP}/'})
def do_POST(self):
if self.path != '/conectar':
return self.responder(404)
n = min(int(self.headers.get('Content-Length') or 0), 4096)
campos = {k: v[0] for k, v in parse_qs(self.rfile.read(n).decode('utf-8', 'replace')).items()}
ssid = (campos.get('ssid_outra') or campos.get('ssid') or '').strip()
senha = campos.get('senha', '')
if not ssid or len(ssid) > 32 or (senha and not 8 <= len(senha) <= 63):
wifi.erro = 'Escolha a rede e use uma senha de 8 a 63 caracteres (ou deixe vazio se a rede for aberta).'
return self.responder(200, wifi.pagina())
rede = dict(ssid=ssid, senha=senha, ip_fixo=campos.get('ip_fixo') == '1')
with wifi.lock:
wifi.trocar_para = rede
self.responder(200, wifi.pagina(conectando=ssid))
def log_message(self, *a):
pass
try:
self.portal = ThreadingHTTPServer((AP_IP, 80), Portal)
except OSError as e:
log('página não subiu:', e)
return
threading.Thread(target=self.portal.serve_forever, daemon=True).start()
def parar_portal(self):
if self.portal:
self.portal.shutdown()
self.portal.server_close()
self.portal = None
def pagina(self, conectando=None):
r = self.rede()
opcoes = ''.join(
f'<label class="rede"><input type="radio" name="ssid" value="{html.escape(x["ssid"], quote=True)}"'
f'{" checked" if r and r["ssid"] == x["ssid"] else ""}><span>{html.escape(x["ssid"])}</span>'
f'<i>{"▂▄▆█"[:max(1, min(4, int((x["sinal"] + 95) / 12)))]}</i></label>' for x in self.redes)
aviso = f'<p class="erro">{html.escape(self.erro)}</p>' if self.erro else ''
if conectando:
corpo = (f'<h1>Conectando em “{html.escape(conectando)}”…</h1><p>Se der certo, a rede '
f'<b>{html.escape(self.ajustes()["ap_nome"])}</b> some e o Carinha volta ao normal em '
f'<b>http://{IP_FIXO}:8100</b>.</p><p>Se a senha estiver errada, ela volta em cerca de 1 minuto, '
'com o aviso nesta página.</p>')
else:
corpo = f"""<h1>🤖 Wi-Fi do Carinha</h1>
<p class="fraco">{'Rede salva: <b>' + html.escape(r['ssid']) + '</b> (não encontrada agora)' if r else 'Nenhuma rede salva ainda.'}</p>
{aviso}
<form method="post" action="/conectar">
<div class="redes">{opcoes or '<p class="fraco">Nenhuma rede encontrada. Digite o nome abaixo.</p>'}</div>
<label>Outra rede (nome)<input name="ssid_outra" maxlength="32" autocomplete="off"></label>
<label>Senha<input name="senha" type="password" maxlength="63" autocomplete="off"></label>
<label class="caixa"><input type="checkbox" name="ip_fixo" value="1" checked> Manter o endereço {IP_FIXO} (recomendado em casa)</label>
<button>Conectar</button>
</form>"""
return f"""<!doctype html><html lang="pt-BR"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>Wi-Fi do Carinha</title><style>
:root{{color-scheme:dark;--fundo:#0d1117;--cartao:#161b22;--borda:#30363d;--texto:#e6edf3;--fraco:#8b949e;--azul:#5cd6ff;--erro:#ff7b72}}
body{{margin:0;padding:24px 16px;background:var(--fundo);color:var(--texto);font:16px system-ui,sans-serif}}
main{{max-width:420px;margin:auto;background:var(--cartao);border:1px solid var(--borda);border-radius:16px;padding:20px}}
h1{{font-size:20px;margin:0 0 8px}} .fraco{{color:var(--fraco);font-size:14px}} .erro{{color:var(--erro)}}
form{{display:grid;gap:14px}} label{{display:grid;gap:6px;font-size:14px;color:var(--fraco)}}
input[type=password],input[name=ssid_outra]{{padding:12px;border-radius:10px;border:1px solid var(--borda);background:var(--fundo);color:var(--texto);font-size:16px}}
.redes{{display:grid;gap:6px}} .rede{{display:flex;align-items:center;gap:10px;padding:12px;border:1px solid var(--borda);border-radius:10px;color:var(--texto)}}
.rede span{{flex:1}} .rede i{{font-style:normal;color:var(--azul)}} .caixa{{display:flex;align-items:center;gap:8px}}
button{{padding:14px;border:0;border-radius:10px;background:var(--azul);color:#00222e;font-weight:700;font-size:16px}}
</style></head><body><main>{corpo}</main></body></html>"""
# ------------------------------------------------------------ laço
def rodar(self):
sh('iw', 'reg', 'set', 'BR')
rede = self.rede()
if rede:
self.modo_cliente(rede)
else:
self.modo_ap('nenhuma rede salva')
while True:
time.sleep(3)
try:
self.passo()
except Exception as e: # nunca derruba o serviço
log('erro:', e)
def passo(self):
with self.lock:
pedido, self.trocar_para = self.trocar_para, None
if pedido: # veio da página: tenta a rede nova
time.sleep(2) # deixa a resposta chegar ao celular
self.anterior = self.rede() # se a nova não funcionar, volta para esta
gravar(REDE, pedido)
self.erro = ''
self.modo_cliente(pedido)
return
agora = time.time()
if self.modo == 'cliente':
if self.conectado() and self.ip_wlan() and self.chega_na_rede():
if not self.ok_em:
log('conectado em', self.rede()['ssid'], 'com', self.ip_wlan())
if self.rede().get('ip_fixo', True):
self.liberar_ip_fixo_do_cabo()
self.erro = ''
self.anterior = False
self.ok_em = agora
self.publicar()
return
limite = QUEDA_TOLERADA if self.ok_em else ESPERA_CONECTAR
if agora - (self.ok_em or self.desde) > limite:
if not self.ok_em:
if self.conectado():
self.erro = (f'Conectei em “{self.rede()["ssid"]}”, mas por ela não chego ao roteador da casa '
f'({self.gateway_wlan() or GATEWAY}). Escolha a rede onde ficam o PC e o celular do Carinha.')
else:
self.erro = (f'Não consegui conectar em “{self.rede()["ssid"]}”. Confira a senha e se a rede está '
'ligada, e tente de novo.')
if self.anterior is not False: # a rede nova falhou: a antiga continua salva
if self.anterior:
gravar(REDE, self.anterior)
else:
REDE.unlink(missing_ok=True)
self.anterior = False
self.modo_ap('a rede não respondeu')
elif self.modo == 'ap':
r = self.rede()
if r and agora - self.desde > RETENTAR_A_CADA and not self.alguem_no_ap():
self.modo_cliente(r) # ninguém configurando: tenta a rede salva de novo
def main():
w = Wifi()
def sair(*_):
w.parar_portal()
w.parar_filhos()
raise SystemExit(0)
signal.signal(signal.SIGTERM, sair)
w.rodar()
if __name__ == '__main__':
main()