hiapp 0.0.1__py3-none-any.whl

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.
hiapp/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ Hiapp: H.ogar con I.nteligencia A.rtificial P.rivada P.ersonal.
3
+
4
+ Asistente de voz autogestionado para el hogar, con servidor de juegos
5
+ para PPSSPP y otras aplicaciones.
6
+ """
7
+
8
+ from importlib.metadata import version, PackageNotFoundError
9
+
10
+ try:
11
+ __version__ = version("hiapp")
12
+ except PackageNotFoundError:
13
+ # Para estado en desarrollo, sin pip install
14
+ __version__ = "0.0.0+unknown"
15
+
16
+ __author__ = "Dixan Pupo Morales (Triplex3x-cyver)"
17
+ __license__ = "Apache-2.0"
File without changes
hiapp/addons/base.py ADDED
@@ -0,0 +1,60 @@
1
+ """
2
+ Clase base para todos los add-ons de Hiapp.
3
+
4
+ Un add-on es una funcionalidad opcional que se registra en el núcleo.
5
+ Puede ser gratuito (viene con Hiapp) o PRO (instalado desde el servidor de licencias).
6
+
7
+ Cada add-on debe:
8
+ - Definir `name` (identificador único).
9
+ - Definir `prefix` (prefijo de URL si expone rutas Flask).
10
+ - Definir `free` (True si es gratuito, False si es PRO).
11
+ - Implementar `register(app)` si expone rutas HTTP.
12
+ - Opcionalmente sobrescribir `start()` y `stop()` si gestiona recursos.
13
+ """
14
+
15
+
16
+ class Addon:
17
+ """Clase base para todos los add-ons de Hiapp."""
18
+
19
+ # --- Atributos que cada subclase debe sobrescribir ---
20
+ name: str = "unnamed"
21
+ prefix: str = "" # Prefijo de URL (ej. "/game")
22
+ free: bool = True
23
+
24
+ # --- Ciclo de vida ---
25
+
26
+ def register(self, app):
27
+ """
28
+ Registra los blueprints y rutas del add-on en la app Flask.
29
+
30
+ Args:
31
+ app: Instancia de Flask donde registrar el add-on.
32
+ """
33
+ pass
34
+
35
+ def start(self):
36
+ """Inicia recursos propios (hilos, procesos). Opcional."""
37
+ pass
38
+
39
+ def stop(self):
40
+ """Detiene recursos propios. Opcional."""
41
+ pass
42
+
43
+ # --- Estado ---
44
+
45
+ def status(self) -> dict:
46
+ """
47
+ Devuelve un diccionario con el estado actual del add-on.
48
+ Las subclases pueden sobrescribirlo para dar más información.
49
+ """
50
+ return {
51
+ "name": self.name,
52
+ "prefix": self.prefix,
53
+ "free": self.free,
54
+ }
55
+
56
+ # --- Utilidades ---
57
+
58
+ def __repr__(self) -> str:
59
+ kind = "free" if self.free else "PRO"
60
+ return f"<Addon {self.name} ({kind}) prefix={self.prefix!r}>"
File without changes
@@ -0,0 +1,20 @@
1
+ """
2
+ Add-on gratuito: servidor de juegos para PPSSPP.
3
+ """
4
+
5
+ # ..
6
+ from ..base import Addon
7
+ # .
8
+ from .routes import bp
9
+
10
+
11
+ class PpssppAddon(Addon):
12
+ """Servidor HTTP de streaming para PPSSPP (soporta rangos)."""
13
+
14
+ name = "ppsspp"
15
+ prefix = "/game"
16
+ free = True
17
+
18
+ def register(self, app):
19
+ """Registra el blueprint /game en la app Flask."""
20
+ app.register_blueprint(bp)
@@ -0,0 +1,21 @@
1
+ from pathlib import Path
2
+
3
+ # (...core)
4
+ from ...core.config import load_config, save_config
5
+
6
+
7
+ def get_games_directory() -> str:
8
+ """Devuelve el directorio de juegos configurado, creándolo si no existe."""
9
+ config = load_config()
10
+ games_dir = config.get("games_dir", str(Path.home() / "PSP"))
11
+
12
+ # Crear si no existe
13
+ Path(games_dir).mkdir(parents=True, exist_ok=True)
14
+ return games_dir
15
+
16
+
17
+ def save_games_directory(path: str) -> None:
18
+ """Guarda el directorio de juegos en la configuración."""
19
+ config = load_config()
20
+ config["games_dir"] = str(path)
21
+ save_config(config)
@@ -0,0 +1,69 @@
1
+ """
2
+ Rutas HTTP del add-on PPSSPP.
3
+
4
+ Se registran bajo el prefijo /game:
5
+ /game/ → index.txt con la lista de juegos
6
+ /game/<filename> → streaming del archivo (soporta rangos)
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from flask import Blueprint, Response, abort, send_file
12
+
13
+ # (.)
14
+ from .paths import get_games_directory
15
+
16
+
17
+ bp = Blueprint(
18
+ "ppsspp",
19
+ __name__,
20
+ url_prefix="/game",
21
+ template_folder="templates",
22
+ static_folder="static",
23
+ )
24
+
25
+
26
+ @bp.route("/", methods=["GET"])
27
+ def index():
28
+ """
29
+ Devuelve la lista de juegos disponibles en formato index.txt.
30
+
31
+ Cada línea debe empezar con '/' (PPSSPP lo requiere).
32
+ Formato de ejemplo:
33
+ /dissidia.iso
34
+ /god-of-war.cso
35
+ """
36
+ games_dir = Path(get_games_directory())
37
+ lines = []
38
+
39
+ if games_dir.is_dir():
40
+ for entry in sorted(games_dir.iterdir()):
41
+ if entry.is_file() and entry.suffix.lower() in {".iso", ".cso"}:
42
+ lines.append(f"/{entry.name}")
43
+
44
+ content = "\n".join(lines) + "\n" if lines else ""
45
+ return Response(content, mimetype="text/plain")
46
+
47
+
48
+ @bp.route("/<path:filename>", methods=["GET"])
49
+ def stream_game(filename):
50
+ """
51
+ Sirve un archivo de juego con soporte de peticiones de rango.
52
+ PPSSPP requiere respuestas 206 Partial Content.
53
+ """
54
+ games_dir = Path(get_games_directory()).resolve()
55
+ file_path = (games_dir / filename).resolve()
56
+
57
+ # Seguridad: evitar path traversal (../../etc/passwd)
58
+ if not str(file_path).startswith(str(games_dir)):
59
+ abort(403)
60
+
61
+ if not file_path.is_file():
62
+ abort(404)
63
+
64
+ # conditional=True habilita el manejo de cabeceras Range
65
+ return send_file(
66
+ file_path,
67
+ conditional=True,
68
+ mimetype="application/octet-stream",
69
+ )
hiapp/config_cli.py ADDED
@@ -0,0 +1,210 @@
1
+
2
+ """
3
+ Comando `hiapp-config`: gestiona la configuración de Hiapp.
4
+
5
+ Reglas:
6
+ - USER_DEFAULTS se pueden leer, escribir y resetear.
7
+ - APP_DEFAULTS solo se leen. Para cambiar lang/lang_model_name,
8
+ se usa el flujo de reconfiguración (reset lang_model_name + reinicio).
9
+ - `reset --all` solo afecta a USER_DEFAULTS.
10
+
11
+ Modo interactivo:
12
+ hiapp-config list
13
+ hiapp-config get port
14
+ hiapp-config set port 8080
15
+ hiapp-config get port
16
+ hiapp-config reset port
17
+ hiapp-config get port
18
+
19
+ hiapp-config get models_dir
20
+ hiapp-config set models_dir /storage/XXXX-XXXX/models
21
+ hiapp-config get models_dir
22
+ hiapp-config reset models_dir
23
+ hiapp-config get models_dir
24
+ hiapp-config list
25
+ """
26
+
27
+ # 🔧 Configuración de Hiapp
28
+ # ------------------------------
29
+ import sys
30
+ import argparse
31
+
32
+ from .core.config import (
33
+ ALL_DEFAULTS,
34
+ APP_DEFAULTS,
35
+ USER_DEFAULTS,
36
+ load_config,
37
+ save_config,
38
+ )
39
+ from .core.i18n import translate
40
+
41
+ # Traducir guiones (CLI) a guiones bajos (interno)
42
+ CLI_TO_INTERNAL = {k.replace("_", "-"): k for k in ALL_DEFAULTS}
43
+ config = load_config()
44
+
45
+
46
+ def _resolve_key(key: str) -> str:
47
+ """Convierte 'models-dir' → 'models_dir'. Deja pasar claves ya válidas.
48
+ Si entra models_dir, pasa sin cambiar.
49
+ """
50
+ return CLI_TO_INTERNAL.get(key, key)
51
+
52
+
53
+ def _is_app_key(key: str) -> bool:
54
+ """¿Es una clave gestionada por la app (no libremente editable)?"""
55
+ return key in APP_DEFAULTS
56
+
57
+
58
+ # --- Comandos -----------
59
+ def cmd_list(args):
60
+ print(translate("config_title", config.get("lang")))
61
+ print("=" * 40)
62
+ for key, value in config.items():
63
+ marca = " [app]" if _is_app_key(key) else ""
64
+ print(f" {key:18} = {value}{marca}")
65
+
66
+
67
+ def cmd_get(args):
68
+ key = _resolve_key(args.key)
69
+ if key not in ALL_DEFAULTS:
70
+ print(translate("config_unknown_key", config.get("lang"), text=key))
71
+ print(translate(
72
+ "config_valid_keys", config.get("lang"), valid=", ".join(ALL_DEFAULTS.keys())))
73
+ sys.exit(1)
74
+
75
+ print(config.get(key))
76
+
77
+
78
+ def cmd_set(args):
79
+ # global config
80
+ key = _resolve_key(args.key)
81
+
82
+ if key not in USER_DEFAULTS:
83
+ if _is_app_key(key):
84
+ print(translate("config_applang_warn", config.get("lang"), text=key))
85
+ else:
86
+ print(translate("config_unknown_key", config.get("lang"), text=key))
87
+ print(translate(
88
+ "config_valid_keys", config.get("lang"), valid=", ".join(ALL_DEFAULTS.keys())))
89
+ sys.exit(1)
90
+
91
+ # Unir todos los argumentos en un solo string
92
+ raw_value = " ".join(args.value)
93
+
94
+ # Convertir tipo si el valor por defecto es int
95
+ if isinstance(USER_DEFAULTS[key], int):
96
+ try:
97
+ value = int(raw_value)
98
+ except ValueError:
99
+ print(translate(
100
+ "config_nan_err", config.get("lang"), value=raw_value, text=key))
101
+ sys.exit(1)
102
+
103
+ elif isinstance(USER_DEFAULTS[key], list):
104
+ # Para listas (aliases), separar por comas
105
+ value = [v.strip() for v in raw_value.split(",") if v.strip()]
106
+ else:
107
+ value = raw_value
108
+
109
+ config[key] = value
110
+ save_config(config)
111
+ print(f"✅ {key} = {value}")
112
+
113
+
114
+ def cmd_reset(args):
115
+ # global config
116
+
117
+ # Caso especial: reset --all
118
+ if args.all:
119
+ #
120
+ # Solo resetear las configurables
121
+ for key in USER_DEFAULTS:
122
+ config[key] = USER_DEFAULTS[key]
123
+ save_config(config)
124
+ print(translate("config_reset_ok", config.get("lang")))
125
+ return
126
+
127
+ if not args.key:
128
+ print(translate("config_nokey_err", config.get("lang")))
129
+ print(translate(
130
+ "config_valid_keys", config.get("lang"),
131
+ valid=", ".join(ALL_DEFAULTS.keys()) + ", --all"))
132
+ sys.exit(1)
133
+
134
+ key = _resolve_key(args.key)
135
+
136
+ # RESET LANG
137
+ if key == "lang":
138
+ print(translate("config_applang_warn", config.get("lang"), text=key))
139
+ sys.exit(1)
140
+ #
141
+ # Claves de APP (excepto lang, ya bloqueada arriba)
142
+ if _is_app_key(key):
143
+ print(translate("config_appstat_warn", config.get("lang"), text=key))
144
+ confirm = input("¿Continuar? [s/N]: ").strip().lower()
145
+ if confirm != "s":
146
+ print(translate("canceled", config.get("lang")))
147
+ return
148
+
149
+ config[key] = APP_DEFAULTS[key]
150
+ save_config(config)
151
+ print(f"✅ {key} = {config[key]}")
152
+ return
153
+
154
+ if key not in ALL_DEFAULTS:
155
+ print(translate("config_unknown_key", config.get("lang"), text=key))
156
+ print(translate(
157
+ "config_valid_keys", config.get("lang"),
158
+ valid=", ".join(ALL_DEFAULTS.keys()) + ", --all"))
159
+ sys.exit(1)
160
+
161
+ # Ejecutar reset en clave de usuario
162
+ config[key] = USER_DEFAULTS[key]
163
+ save_config(config)
164
+ print(f"✅ {key} = {USER_DEFAULTS[key]}")
165
+
166
+
167
+ # --- Punto de entrada -----------
168
+ def main():
169
+ parser = argparse.ArgumentParser(
170
+ prog="hiapp-config",
171
+ description=translate("cmd_parser_desc", config.get("lang"))
172
+ )
173
+ subparsers = parser.add_subparsers(dest="command")
174
+
175
+ subparsers.add_parser("list", help=translate("cmd_list_help", config.get("lang")))
176
+
177
+ # GET
178
+ p_get = subparsers.add_parser("get", help=translate("cmd_get_help", config.get("lang")))
179
+ p_get.add_argument("key")
180
+
181
+ # SET
182
+ p_set = subparsers.add_parser("set", help=translate("cmd_set_help", config.get("lang")))
183
+ p_set.add_argument("key")
184
+ # p_set.add_argument("value", help="Valor (para listas, separar por comas a,b,c)")
185
+ p_set.add_argument(
186
+ "value", nargs="+", help=translate("cmd_setvalue_help", config.get("lang")))
187
+
188
+ # RESET
189
+ p_reset = subparsers.add_parser("reset", help=translate("cmd_reset_help", config.get("lang")))
190
+ p_reset.add_argument("key", nargs="?", help=translate("cmd_resetkey_help", config.get("lang")))
191
+ p_reset.add_argument(
192
+ "--all", action="store_true", help=translate("cmd_resetall_help", config.get("lang")))
193
+
194
+ args = parser.parse_args()
195
+
196
+ # Dispatch
197
+ if args.command == "list":
198
+ cmd_list(args)
199
+ elif args.command == "get":
200
+ cmd_get(args)
201
+ elif args.command == "set":
202
+ cmd_set(args)
203
+ elif args.command == "reset":
204
+ cmd_reset(args)
205
+ else:
206
+ parser.print_help()
207
+
208
+
209
+ if __name__ == "__main__":
210
+ main()
hiapp/core/__init__.py ADDED
File without changes
hiapp/core/commands.py ADDED
@@ -0,0 +1,138 @@
1
+ """
2
+ Registro de comandos de voz de Hiapp.
3
+
4
+ Los comandos se recogen de dos fuentes:
5
+ - El núcleo: estado, ayuda.
6
+ - Los add-ons: cada uno declara los suyos en su addon.json.
7
+
8
+ Con todos los comandos + la wake word, se construye una gramática JSON
9
+ que se pasa a Vosk para restringir el reconocimiento.
10
+
11
+ Beneficios de la gramática:
12
+ - Menos falsos positivos (de ~50% a ~100% de precisión).
13
+ - Menos uso de CPU (espacio de búsqueda reducido).
14
+ - Misma velocidad que un modelo "small".
15
+ """
16
+
17
+ import json
18
+
19
+
20
+ # Comandos del núcleo por idioma
21
+ CORE_COMMANDS = {
22
+ "es": {
23
+ "status": ("estado", "estatus"),
24
+ "help": ("ayuda", "aiuda", "ayúa"),
25
+ },
26
+ "en": {
27
+ "status": ("status", "state"),
28
+ "help": ("help",),
29
+ },
30
+ }
31
+
32
+
33
+ class CommandRegistry:
34
+ """
35
+ Recoge comandos del núcleo y de los add-ons, y construye la gramática
36
+ de Vosk para el idioma activo.
37
+
38
+ Mapea frases exactas a intenciones para que el controlador sepa
39
+ qué hacer cuando Vosk devuelve una transcripción.
40
+ """
41
+
42
+ def __init__(self):
43
+ # {lang: {intent: [frases]}}
44
+ self._by_intent = {}
45
+ # {lang: {frase: intent}}
46
+ self._phrase_to_intent = {}
47
+
48
+ # --- Registro -----------
49
+
50
+ def register_core(self):
51
+ """Registra los comandos del núcleo (estado, ayuda)."""
52
+ for lang, intents in CORE_COMMANDS.items():
53
+ for intent, phrases in intents.items():
54
+ self._register(lang, intent, phrases)
55
+
56
+ def register_addon(self, manifest: dict):
57
+ """
58
+ Registra los comandos de un add-on a partir de su manifest
59
+ (el diccionario cargado desde addon.json).
60
+
61
+ El manifest debe exponer los comandos con esta estructura:
62
+ "commands": {
63
+ "es": {
64
+ "start": ["activar servidor de juegos"],
65
+ "stop": ["desactivar servidor de juegos"]
66
+ }
67
+ }
68
+
69
+ Las intenciones se registran con el prefijo del add-on:
70
+ "ppsspp:start", "ppsspp:stop"
71
+ """
72
+ name = manifest.get("name", "addon")
73
+ commands = manifest.get("commands", {})
74
+
75
+ for lang, intents in commands.items():
76
+ for intent, phrases in intents.items():
77
+ full_intent = f"{name}:{intent}"
78
+ self._register(lang, full_intent, phrases)
79
+
80
+ def _register(self, lang: str, intent: str, phrases):
81
+ """Añade frases a una intención en un idioma, evitando duplicados."""
82
+ self._by_intent.setdefault(lang, {})
83
+ self._by_intent[lang].setdefault(intent, [])
84
+
85
+ self._phrase_to_intent.setdefault(lang, {})
86
+
87
+ for phrase in phrases:
88
+ # Evitar duplicados
89
+ if phrase not in self._by_intent[lang][intent]:
90
+ self._by_intent[lang][intent].append(phrase)
91
+ # Mapear frase → intención (el último gana si hay colisión)
92
+ self._phrase_to_intent[lang][phrase] = intent
93
+
94
+ # --- Gramática -----------
95
+
96
+ def build_grammar(self, lang: str, wake_words: list) -> str:
97
+ """
98
+ Construye la gramática JSON para Vosk.
99
+ Incluye la wake word, los comandos del idioma, y el token [unk].
100
+ """
101
+ phrases = set(wake_words)
102
+
103
+ for phrases_list in self._by_intent.get(lang, {}).values():
104
+ phrases.update(phrases_list)
105
+
106
+ phrases.add("[unk]")
107
+ return json.dumps(sorted(phrases))
108
+
109
+ # --- Búsqueda de intención -----------
110
+
111
+ def find_intent(self, text: str, lang: str) -> str | None:
112
+ """
113
+ Devuelve la intención cuyo comando aparece en el texto, o None.
114
+
115
+ Con gramática activa, Vosk devuelve frases exactas. El fallback
116
+ por substring está por si el modelo devuelve algo inesperado.
117
+ """
118
+ mapping = self._phrase_to_intent.get(lang, {})
119
+
120
+ # 1. Coincidencia exacta
121
+ if text in mapping:
122
+ return mapping[text]
123
+
124
+ # 2. Fallback: substring
125
+ for phrase, intent in mapping.items():
126
+ if phrase in text:
127
+ return intent
128
+
129
+ return None
130
+
131
+ # --- Utilidades -----------
132
+
133
+ def list_commands(self, lang: str) -> list:
134
+ """Devuelve las frases registradas para un idioma."""
135
+ phrases = []
136
+ for phrases_list in self._by_intent.get(lang, {}).values():
137
+ phrases.extend(phrases_list)
138
+ return sorted(phrases)
hiapp/core/config.py ADDED
@@ -0,0 +1,71 @@
1
+ import json
2
+ import socket
3
+ from pathlib import Path
4
+
5
+ # Rutas base (fijas)
6
+ HIAPP_DIR = Path.home() / ".hiapp"
7
+ CONFIG_FILE = HIAPP_DIR / "config.json"
8
+
9
+ # Configuración de usuario (resetable con --all)
10
+ USER_DEFAULTS = {
11
+ "models_dir": str(HIAPP_DIR / "models"),
12
+ "games_dir": str(Path.home() / "PSP"),
13
+ "port": 5000,
14
+ "host": "0.0.0.0",
15
+ "aliases": [],
16
+ }
17
+
18
+ # Estado de la aplicación (NO resetable con --all)
19
+ APP_DEFAULTS = {
20
+ "lang": None,
21
+ "lang_model_name": None,
22
+ }
23
+
24
+ # Todas las claves válidas (para validación en CLI)
25
+ ALL_DEFAULTS = {**USER_DEFAULTS, **APP_DEFAULTS}
26
+
27
+
28
+ def load_config() -> dict:
29
+ """Carga la configuración aplicando esta prioridad:
30
+ 1. Valores por defecto (USER_DEFAULTS + APP_DEFAULTS).
31
+ 2. Valores del archivo ~/.hiapp/config.json (si existen).
32
+
33
+ Solo se cargan las claves que existen en ALL_DEFAULTS.
34
+ """
35
+ result = {**USER_DEFAULTS, **APP_DEFAULTS}
36
+
37
+ # 1. Cargar del archivo json
38
+ if CONFIG_FILE.exists():
39
+ try:
40
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
41
+ # config.update(json.load(f))
42
+ saved = json.load(f)
43
+
44
+ for key in ALL_DEFAULTS:
45
+ if key in saved: # Solo carga las que estén en ALL_KEYS
46
+ result[key] = saved[key]
47
+ except (json.JSONDecodeError, OSError):
48
+ pass # Usar valores por defecto
49
+
50
+ return result
51
+
52
+
53
+ def save_config(config: dict):
54
+ """Guarda la configuración en ~/.hiapp/config.json."""
55
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
56
+
57
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
58
+ json.dump(config, f, indent=2, ensure_ascii=False)
59
+
60
+
61
+ def get_lan_ip() -> str:
62
+ """Devuelve la IP local de la interfaz que conecta a la LAN."""
63
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
64
+ try:
65
+ # No envía nada, solo consulta la ruta de salida hacia internet
66
+ s.connect(("8.8.8.8", 80))
67
+ return s.getsockname()[0]
68
+ except Exception:
69
+ return "127.0.0.1"
70
+ finally:
71
+ s.close()