@ing.jorgeu/wargaming-overlay 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -625,3 +625,11 @@ npm run facts:coverage -- --require-four-hours
625
625
  ```
626
626
 
627
627
  Al ampliar `data/faction-facts.json`, usa el nombre exacto del registro, conserva los IDs existentes para respetar el historial y asigna un ID estable a cada nueva curiosidad. Incluye un enlace específico, título de fuente y fecha de revisión. No añadas reformulaciones del mismo dato para aumentar artificialmente la duración.
628
+
629
+ ### Continuidad entre USB y Wi-Fi
630
+
631
+ El lector recuerda el teléfono por su identificador físico en `phone-connection.json`, dentro de la carpeta de datos. Si ADB presenta USB y Wi-Fi para ese mismo teléfono, el overlay prefiere USB. Al retirar el cable, utiliza la conexión inalámbrica autorizada; al reconectarlo, vuelve a USB. Nunca elige otro teléfono por compartir el modelo o una dirección IP anterior.
632
+
633
+ Activa la depuración inalámbrica y empareja el computador con el teléfono en Android. Tener activada la opción no equivale a estar emparejado. ADB debe poder descubrir el teléfono en la red. Si el descubrimiento no funciona, conecta previamente con `adb connect IP:PUERTO` usando la dirección que Android muestra en Depuración inalámbrica. El overlay puede recuperar direcciones conocidas y servicios inalámbricos que anuncien el identificador del teléfono guardado; no activa puertos de depuración ni empareja dispositivos desconocidos automáticamente.
634
+
635
+ Para elegir otro teléfono, inicia con `--serial SERIAL` usando su identificador de `adb devices -l`. Si hay varios teléfonos diferentes y ninguno seleccionado, el lector pide seleccionar uno. El panel distingue conexión USB, Wi-Fi y espera de una ronda legible. Al enviar Tabletop Battles al fondo se conserva el último marcador; al volver a la ronda, la lectura se reanuda. Durante una reconexión puede haber una pausa de unos segundos.
@@ -0,0 +1,113 @@
1
+ """Track one physical Android device across authorized ADB transports."""
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+ import time
7
+ from pathlib import Path
8
+
9
+
10
+ def run_adb(*args):
11
+ return subprocess.run([os.environ.get('OVERLAY_ADB', 'adb'), *args],
12
+ capture_output=True, text=True, encoding='utf-8',
13
+ errors='replace', check=True, timeout=5).stdout
14
+
15
+
16
+ def devices(output):
17
+ rows = []
18
+ for line in output.splitlines():
19
+ fields = line.split()
20
+ if len(fields) >= 2 and not line.startswith(('List of', '*')):
21
+ serial = fields[0]
22
+ wireless = ':' in serial or '._adb-tls-connect._tcp' in serial
23
+ rows.append({'serial': serial, 'state': fields[1],
24
+ 'transport': 'wifi' if wireless else 'usb'})
25
+ return rows
26
+
27
+
28
+ class DeviceConnection:
29
+ def __init__(self, serial='', path=None, run=run_adb, now=time.monotonic):
30
+ self.run, self.now, self.explicit = run, now, serial
31
+ self.path = Path(path) if path else None
32
+ self.identity, self.aliases = None, set()
33
+ self.retry_at = 0
34
+ if self.path:
35
+ try:
36
+ saved = json.loads(self.path.read_text())
37
+ if not serial or serial in saved.get('aliases', []):
38
+ self.identity = saved.get('identity')
39
+ self.aliases = set(saved.get('aliases', []))
40
+ except (OSError, ValueError, TypeError):
41
+ pass
42
+
43
+ def identify(self, serial):
44
+ for prop in ('ro.serialno', 'ro.boot.serialno'):
45
+ try:
46
+ value = self.run('-s', serial, 'shell', 'getprop', prop).strip()
47
+ if value and value.lower() not in ('unknown', 'null'):
48
+ return value
49
+ except (OSError, subprocess.SubprocessError):
50
+ pass
51
+ return None
52
+
53
+ def remember(self, matching):
54
+ self.aliases.update(d['serial'] for d in matching)
55
+ if self.path:
56
+ self.path.parent.mkdir(parents=True, exist_ok=True)
57
+ temp = self.path.with_suffix('.tmp')
58
+ temp.write_text(json.dumps({'identity': self.identity, 'aliases': sorted(self.aliases)}))
59
+ temp.replace(self.path)
60
+
61
+ def reconnect(self):
62
+ if not self.identity or self.now() < self.retry_at:
63
+ return
64
+ self.retry_at = self.now() + 15
65
+ targets = {a for a in self.aliases if ':' in a and not a.startswith('adb-')}
66
+ # Only discover services advertising the remembered device, never arbitrary LAN phones.
67
+ try:
68
+ for line in self.run('mdns', 'services').splitlines():
69
+ fields = line.split()
70
+ if (len(fields) >= 3 and fields[0].startswith('adb-' + self.identity + '-')
71
+ and '_adb-tls-connect._tcp' in fields[1]):
72
+ targets.add(fields[2])
73
+ except (OSError, subprocess.SubprocessError):
74
+ pass
75
+ for target in sorted(targets)[:4]:
76
+ if re.fullmatch(r'[A-Za-z0-9.\-\[\]:]+:\d+', target):
77
+ try:
78
+ self.run('connect', target)
79
+ except (OSError, subprocess.SubprocessError):
80
+ pass
81
+
82
+ def select(self):
83
+ rows = devices(self.run('devices', '-l'))
84
+ ready = [d for d in rows if d['state'] == 'device']
85
+ # Verify the physical identity even for remembered IP addresses: DHCP can reuse them.
86
+ for d in ready:
87
+ d['identity'] = self.identify(d['serial'])
88
+ if self.identity:
89
+ matching = [d for d in ready if d['identity'] == self.identity]
90
+ elif self.explicit:
91
+ matching = [d for d in ready if d['serial'] == self.explicit]
92
+ if matching:
93
+ self.identity = matching[0]['identity']
94
+ if self.identity:
95
+ matching = [d for d in ready if d['identity'] == self.identity]
96
+ else:
97
+ identities = {d['identity'] or d['serial'] for d in ready}
98
+ if len(identities) > 1:
99
+ raise ValueError('Hay varios teléfonos diferentes. Selecciona uno con --serial SERIAL.')
100
+ matching = ready
101
+ if matching:
102
+ self.identity = matching[0]['identity']
103
+ if not matching:
104
+ self.reconnect()
105
+ if any(d['state'] == 'unauthorized' for d in rows):
106
+ raise ValueError('Autoriza este computador en el teléfono; para Wi-Fi completa el emparejamiento de depuración inalámbrica.')
107
+ raise ValueError('Reconectando al teléfono seleccionado por USB o Wi-Fi. Comprueba la conexión y la depuración inalámbrica.')
108
+ # If identity is unavailable, stick to the chosen transport, never guess another phone.
109
+ chosen = sorted(matching, key=lambda d: d['transport'] != 'usb')[0]
110
+ if not self.identity:
111
+ self.explicit = chosen['serial']
112
+ self.remember(matching)
113
+ return chosen
package/diagnostics.js CHANGED
@@ -33,6 +33,12 @@ function check(options={}){
33
33
  if(py.status!==0)return {ok:false,code:'PYTHON_VERSION',message:'Se necesita Python 3.8 o posterior. Usa --python /ruta/a/python3.',adb,python};
34
34
  const result=spawnSync(adb,['devices','-l'],{encoding:'utf8',timeout:15000});
35
35
  if(result.error||result.status!==0)return {ok:false,code:'ADB_ERROR',message:'ADB no pudo consultar el teléfono: '+(result.error?.message||(result.stderr||'').trim()).slice(0,600),adb,python};
36
- return {...deviceStatus(result.stdout,options.serial),adb,python};
36
+ let status=deviceStatus(result.stdout,options.serial);
37
+ // USB and wireless transports can be the same physical phone.
38
+ if(status.code==='MULTIPLE_DEVICES'){
39
+ const probe=spawnSync(python,['-c', 'import json; from device_connection import DeviceConnection; print(json.dumps(DeviceConnection().select()))'],{cwd:__dirname,env:{...process.env,OVERLAY_ADB:adb},encoding:'utf8',timeout:20000,windowsHide:true});
40
+ if(probe.status===0){try{const d=JSON.parse(probe.stdout);status={ok:true,code:'READY',message:'Teléfono conectado por '+(d.transport==='wifi'?'Wi-Fi.':'USB.'),serial:d.serial};}catch{}}
41
+ }
42
+ return {...status,adb,python};
37
43
  }
38
44
  module.exports={locate,deviceStatus,check,findPython};
package/game-store.js CHANGED
@@ -2,7 +2,7 @@ const {inferTurn}=require('./turn-tracker');
2
2
  const emptyGame=()=>({players:[],connected:false,updatedAt:null,error:null});
3
3
  function mergeGame(previous,incoming){
4
4
  if(!incoming.connected || !Array.isArray(incoming.players) || incoming.players.length!==2)
5
- return {...previous,connected:false,error:incoming.error||'Sin lectura válida'};
5
+ return {...previous,connected:false,phoneConnected:incoming.phoneConnected===true,transport:incoming.transport||null,error:incoming.error||'Sin lectura válida'};
6
6
  const same=previous.round===incoming.round && previous.players?.length===2 && incoming.players.every(p=>previous.players.some(old=>old.name===p.name));
7
7
  const players=incoming.players.map(p=>{
8
8
  const old=same?previous.players.find(old=>old.name===p.name):null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ing.jorgeu/wargaming-overlay",
3
- "version": "2.3.0",
3
+ "version": "2.4.0",
4
4
  "description": "Overlay local para OBS con lectura Android por ADB",
5
5
  "bin": {
6
6
  "wargaming-overlay": "cli.js"
@@ -32,7 +32,8 @@
32
32
  "announcements.js",
33
33
  "data/faction-facts.json",
34
34
  "factions.js",
35
- "scripts/fact-coverage.js"
35
+ "scripts/fact-coverage.js",
36
+ "device_connection.py"
36
37
  ],
37
38
  "license": "0BSD",
38
39
  "publishConfig": {
package/public/control.js CHANGED
@@ -47,7 +47,7 @@ document.querySelector('#reset-game').addEventListener('click',async()=>{
47
47
  catch {document.querySelector('#reset-status').textContent='No se pudo restablecer. Comprueba que el servidor esté actualizado y funcionando.';}
48
48
  finally{button.disabled=false}
49
49
  });
50
- async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();document.querySelector('#turn-status').textContent=(g.half==='top'?'TOP · Primer turno. ':g.half==='bottom'?'BOTTOM · Segundo turno. ':'Turno automático: ')+(g.turn?.reason||'Esperando actividad de puntuación.');document.querySelector('#phone-status').textContent=g.connected?'Teléfono conectado.':(g.error||'Sin conexión.')+(g.players?.length?' Se conserva la última lectura.':'');}catch{document.querySelector('#phone-status').textContent='Servidor no disponible.'}finally{setTimeout(phoneStatus,3000)}}phoneStatus();
50
+ async function phoneStatus(){try{const r=await fetch('/game-state.json',{cache:'no-store'});const g=await r.json();document.querySelector('#turn-status').textContent=(g.half==='top'?'TOP · Primer turno. ':g.half==='bottom'?'BOTTOM · Segundo turno. ':'Turno automático: ')+(g.turn?.reason||'Esperando actividad de puntuación.');document.querySelector('#phone-status').textContent=g.connected?`Teléfono conectado por ${g.transport==='wifi'?'Wi-Fi':'USB'}.`:(g.phoneConnected?`Teléfono conectado por ${g.transport==='wifi'?'Wi-Fi':'USB'}. `:'')+(g.error||'Reconectando…')+(g.players?.length?' Se conserva la última lectura.':'');}catch{document.querySelector('#phone-status').textContent='Servidor no disponible.'}finally{setTimeout(phoneStatus,3000)}}phoneStatus();
51
51
 
52
52
  document.querySelector('#obs-url').textContent=location.origin+'/overlay-art.html';
53
53
 
package/reader.py CHANGED
@@ -5,32 +5,23 @@ from battle_reader import parse, capture
5
5
  ADB = os.environ.get('OVERLAY_ADB', 'adb')
6
6
  SERIAL = os.environ.get('OVERLAY_SERIAL', '')
7
7
 
8
- def select_device(output):
9
- devices = [line.split()[:2] for line in output.splitlines() if line.strip() and not line.startswith(('List of', '*'))]
10
- if not devices:
11
- raise ValueError('ADB no detecta un teléfono. Conecta un cable de datos, desbloquea Android y activa Opciones de desarrollador → Depuración USB. No se puede distinguir cable desconectado de depuración desactivada.')
12
- if not SERIAL and len(devices) > 1:
13
- raise ValueError('Hay varios dispositivos. Reinicia con --serial SERIAL: ' + ', '.join(d[0] for d in devices))
14
- device = next((d for d in devices if d[0] == SERIAL), None) if SERIAL else devices[0]
15
- if not device: raise ValueError('El dispositivo seleccionado no está conectado: ' + SERIAL)
16
- serial, status = device
17
- if status == 'unauthorized': raise ValueError('Teléfono sin autorizar. Desbloquéalo y acepta «Permitir depuración USB» para este equipo. Si no aparece, reconecta el cable.')
18
- if status == 'no': raise ValueError('Sin permisos USB. Revisa reglas udev y grupos del usuario; en Ubuntu, android-sdk-platform-tools-common y plugdev. Vuelve a iniciar sesión después de cambiar grupos.')
19
- if status == 'offline': raise ValueError('Teléfono offline. Desbloquéalo y reconecta el cable USB.')
20
- if status != 'device': raise ValueError('Dispositivo no disponible: ' + status)
21
- return serial
8
+ from device_connection import DeviceConnection
9
+ from pathlib import Path
22
10
 
23
11
  if __name__ == '__main__':
12
+ data_dir = os.environ.get('OVERLAY_DATA_DIR')
13
+ connection = DeviceConnection(SERIAL, Path(data_dir) / 'phone-connection.json' if data_dir else None)
24
14
  while True:
15
+ device = None
25
16
  try:
26
- result = subprocess.run([ADB, 'devices', '-l'], capture_output=True, text=True, encoding='utf-8', errors='replace', check=True, timeout=10)
27
- serial = select_device(result.stdout)
17
+ device = connection.select()
18
+ serial = device['serial']
28
19
  state = parse(capture(serial))
29
- print(json.dumps(dict(state, connected=True, updatedAt=time.time(), error=None)), flush=True)
20
+ print(json.dumps(dict(state, connected=True, phoneConnected=True, transport=device["transport"], deviceSerial=serial, updatedAt=time.time(), error=None)), flush=True)
30
21
  except Exception as e:
31
22
  message = str(e)
32
23
  if isinstance(e, subprocess.TimeoutExpired): message = 'El teléfono no respondió a tiempo. Desbloquéalo y abre la ronda; volveré a intentar la lectura.'
33
24
  elif isinstance(e, subprocess.CalledProcessError): message = 'No se pudo leer Android. Abre la ronda y cierra otros lectores UIAutomator. Se reintentará. ' + (e.stderr or '').strip()[:300]
34
25
  elif isinstance(e, FileNotFoundError): message = 'ADB no está disponible. Ejecuta wargaming-overlay setup --install-adb.'
35
- print(json.dumps({'connected':False, 'error':message}), flush=True)
26
+ print(json.dumps({'connected':False, 'phoneConnected':device is not None and isinstance(e, ValueError), 'transport':device['transport'] if device is not None else None, 'error':message}), flush=True)
36
27
  time.sleep(float(os.environ.get('OVERLAY_INTERVAL', '3')))