operx-cli 0.10.0__tar.gz → 0.11.0__tar.gz

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.
Files changed (85) hide show
  1. {operx_cli-0.10.0 → operx_cli-0.11.0}/PKG-INFO +12 -1
  2. {operx_cli-0.10.0 → operx_cli-0.11.0}/README.md +11 -0
  3. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/AGENTES.md +5 -0
  4. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/__init__.py +1 -1
  5. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/app.py +2 -1
  6. operx_cli-0.11.0/operx_cli/commands/connect.py +152 -0
  7. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli.egg-info/PKG-INFO +12 -1
  8. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli.egg-info/SOURCES.txt +2 -0
  9. {operx_cli-0.10.0 → operx_cli-0.11.0}/pyproject.toml +1 -1
  10. operx_cli-0.11.0/tests/test_connect.py +111 -0
  11. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/agent_guide.py +0 -0
  12. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/api_fields.py +0 -0
  13. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/browser_login.py +0 -0
  14. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/client.py +0 -0
  15. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/__init__.py +0 -0
  16. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/account.py +0 -0
  17. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/ambiental.py +0 -0
  18. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/articulos.py +0 -0
  19. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/avisos.py +0 -0
  20. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/campos.py +0 -0
  21. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/chat.py +0 -0
  22. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/common.py +0 -0
  23. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/completar.py +0 -0
  24. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/empresas.py +0 -0
  25. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/inventario.py +0 -0
  26. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/lecturas.py +0 -0
  27. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/muestreo.py +0 -0
  28. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/ordenes.py +0 -0
  29. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/personas.py +0 -0
  30. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/planta.py +0 -0
  31. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/preventivos.py +0 -0
  32. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/raw.py +0 -0
  33. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/registros.py +0 -0
  34. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/reportes.py +0 -0
  35. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/rutinas.py +0 -0
  36. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/search.py +0 -0
  37. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/sst.py +0 -0
  38. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/commands/update.py +0 -0
  39. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/lookup.py +0 -0
  40. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/output.py +0 -0
  41. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/record_completion.py +0 -0
  42. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/self_update.py +0 -0
  43. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/settings.py +0 -0
  44. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/shell.py +0 -0
  45. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/spreadsheet.py +0 -0
  46. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/updates.py +0 -0
  47. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/values.py +0 -0
  48. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli/welcome.py +0 -0
  49. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli.egg-info/dependency_links.txt +0 -0
  50. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli.egg-info/entry_points.txt +0 -0
  51. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli.egg-info/requires.txt +0 -0
  52. {operx_cli-0.10.0 → operx_cli-0.11.0}/operx_cli.egg-info/top_level.txt +0 -0
  53. {operx_cli-0.10.0 → operx_cli-0.11.0}/setup.cfg +0 -0
  54. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_agentes.py +0 -0
  55. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_api_campos.py +0 -0
  56. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_app.py +0 -0
  57. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_articulos.py +0 -0
  58. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_campos.py +0 -0
  59. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_cards.py +0 -0
  60. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_chat.py +0 -0
  61. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_client.py +0 -0
  62. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_completar.py +0 -0
  63. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_daily_flows.py +0 -0
  64. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_inventario_lecturas.py +0 -0
  65. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_json_writes.py +0 -0
  66. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_login.py +0 -0
  67. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_logout_and_routes.py +0 -0
  68. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_lookup.py +0 -0
  69. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_more_of_the_api.py +0 -0
  70. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_muestreo.py +0 -0
  71. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_ordenes.py +0 -0
  72. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_output.py +0 -0
  73. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_personas.py +0 -0
  74. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_preventivos.py +0 -0
  75. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_record_completion.py +0 -0
  76. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_registros.py +0 -0
  77. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_rutinas.py +0 -0
  78. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_self_update.py +0 -0
  79. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_settings.py +0 -0
  80. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_shell.py +0 -0
  81. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_spreadsheet.py +0 -0
  82. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_sst_ambiental.py +0 -0
  83. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_updates.py +0 -0
  84. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_values.py +0 -0
  85. {operx_cli-0.10.0 → operx_cli-0.11.0}/tests/test_welcome.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -216,6 +216,17 @@ operx chat leido mantenimiento
216
216
  Chat va solo por canales: los mensajes directos, los grupos y los archivos
217
217
  son de la app.
218
218
 
219
+ ```
220
+ operx connect dispositivos # los que envían datos de las máquinas, y su estado
221
+ operx connect canales "Broker planta" # qué envía cada canal y a qué punto va
222
+ operx connect mensajes d-7f3k2m --cuantos 5 # lo que llegó, tal cual
223
+ operx connect mapear d-7f3k2m planta/comp2/temp --equipo "Compresor 2" --punto "Temperatura rodamiento"
224
+ operx connect silenciar d-7f3k2m planta/comp2/temp --hasta "05/11/2026 18:00" --motivo "Mantenimiento"
225
+ ```
226
+
227
+ Las claves de los dispositivos no pasan por el CLI: se crean y se ven en la
228
+ app, en la pestaña Clave del dispositivo.
229
+
219
230
  SG-SST y Ambiental van por empresa: sin `--empresa`, la de tu cuenta. Si
220
231
  llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
221
232
  `operx ambiental empresas` las listan.
@@ -202,6 +202,17 @@ operx chat leido mantenimiento
202
202
  Chat va solo por canales: los mensajes directos, los grupos y los archivos
203
203
  son de la app.
204
204
 
205
+ ```
206
+ operx connect dispositivos # los que envían datos de las máquinas, y su estado
207
+ operx connect canales "Broker planta" # qué envía cada canal y a qué punto va
208
+ operx connect mensajes d-7f3k2m --cuantos 5 # lo que llegó, tal cual
209
+ operx connect mapear d-7f3k2m planta/comp2/temp --equipo "Compresor 2" --punto "Temperatura rodamiento"
210
+ operx connect silenciar d-7f3k2m planta/comp2/temp --hasta "05/11/2026 18:00" --motivo "Mantenimiento"
211
+ ```
212
+
213
+ Las claves de los dispositivos no pasan por el CLI: se crean y se ven en la
214
+ app, en la pestaña Clave del dispositivo.
215
+
205
216
  SG-SST y Ambiental van por empresa: sin `--empresa`, la de tu cuenta. Si
206
217
  llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
207
218
  `operx ambiental empresas` las listan.
@@ -309,6 +309,11 @@ operx chat canales --json
309
309
  operx chat leer mantenimiento --json
310
310
  operx chat escribir mantenimiento "@Pedro Pérez la bomba 4 quedó lista" --si --json
311
311
 
312
+ # Lo que envía un dispositivo de Connect, y asignar un canal a un punto (después de su sí)
313
+ operx connect canales d-7f3k2m --json
314
+ operx connect mensajes d-7f3k2m --cuantos 5 --json
315
+ operx connect mapear d-7f3k2m planta/comp2/temp --equipo "Compresor 2" --punto "Temperatura rodamiento" --si --json
316
+
312
317
  # Una reacción y un canal nuevo en Chat (no tienen comando propio)
313
318
  operx api POST chat/messages/123/reactions/ -d '{"emoji": "✅"}' --si
314
319
  operx api POST chat/conversations/ -d '{"kind": "public", "name": "turno-noche", "topic": "Lo de la noche"}' --si --json
@@ -2,4 +2,4 @@
2
2
  API (backend/users/api_allowlist.py) from a terminal, with a personal API
3
3
  token."""
4
4
 
5
- __version__ = "0.10.0"
5
+ __version__ = "0.11.0"
@@ -14,7 +14,7 @@ from . import __version__, settings, shell
14
14
  from .agent_guide import guide_text
15
15
  from .client import ApiError
16
16
  from .commands import (
17
- account, ambiental, articulos, avisos, campos, chat, completar, inventario, lecturas, muestreo, ordenes, personas,
17
+ account, ambiental, articulos, avisos, campos, chat, completar, connect, inventario, lecturas, muestreo, ordenes, personas,
18
18
  planta, preventivos, raw, registros, reportes, rutinas, search, sst, update,
19
19
  )
20
20
  from .commands.common import CliError, Session
@@ -162,6 +162,7 @@ app.add_typer(sst.app, name="sst")
162
162
  app.add_typer(ambiental.app, name="ambiental")
163
163
  app.add_typer(muestreo.app, name="muestreo")
164
164
  app.add_typer(chat.app, name="chat")
165
+ app.add_typer(connect.app, name="connect")
165
166
 
166
167
 
167
168
  @app.command()
@@ -0,0 +1,152 @@
1
+ """`operx connect`: the devices that send machine data, what each sends, and mapping
2
+ their channels to the machines' points (CONNECT_SPEC.md › API, CLI and MCP). Keys
3
+ never pass through here: a device's key is made and shown in the app."""
4
+
5
+ from typing import Annotated, Optional
6
+
7
+ import typer
8
+
9
+ from ..client import ApiError
10
+ from ..lookup import NoSingleMatch, find_in, find_one
11
+ from ..output import print_rows
12
+ from .common import CliError, CsvOption, JsonOption, YesOption, answer_of, client_of, confirm_write, moment, number, output_format, print_result
13
+
14
+ app = typer.Typer(help="Connect: los dispositivos que envían datos de las máquinas y sus canales.", no_args_is_help=True)
15
+
16
+ # The app's words (web/src/products/connect/components/parts.tsx, ChannelsTab.tsx, MessagesTab.tsx).
17
+ DEVICE_STATES = {
18
+ "receiving": "Recibiendo", "silent": "Sin datos", "errors": "Con errores", "waiting": "Esperando el primer mensaje", "paused": "Pausado",
19
+ }
20
+ CHANNEL_STATES = {"unmapped": "Sin asignar", "counting": "Contando", "out_of_range": "Fuera del rango", "in_range": "Dentro del rango"}
21
+ RESULTS = {"read": "leído", "partly_read": "leído en parte", "refused": "rechazado", "paused": "en pausa"}
22
+ DEVICE_COLUMNS = [
23
+ ("uid", "Id"), ("name", "Dispositivo"), ("model_label", "Tipo"), ("place_name", "Lugar"), ("estado", "Estado"),
24
+ ("last_seen", "Último envío"), ("channels_total", "Canales"), ("channels_mapped", "Asignados"),
25
+ ]
26
+ CHANNEL_COLUMNS = [("key", "Canal"), ("punto", "Punto"), ("estado", "Estado"), ("last_value", "Último valor"), ("last_raw", "Llega")]
27
+ MESSAGE_COLUMNS = [("llego", "Llegó"), ("way", "Vía"), ("resultado", "Resultado"), ("values_in", "Valores"), ("reason", "Motivo"), ("body", "Mensaje")]
28
+ BODY_SHOWN = 120
29
+ FIRST_MAPPING = "Es un contador y lo que marca el equipo no cuadra con lo anotado: decídelo en la app, en el canal del dispositivo."
30
+
31
+
32
+ def _when(text):
33
+ return (text or "")[:16].replace("T", " ")
34
+
35
+
36
+ def device_named(client, dispositivo):
37
+ """A device by its id (d-…), its name or its number."""
38
+ devices = client.get("connect/devices").get("devices", [])
39
+ try:
40
+ return find_in(devices, dispositivo, ["uid", "name"], "dispositivo")
41
+ except NoSingleMatch:
42
+ by_number = [device for device in devices if str(device["id"]) == str(dispositivo).strip()]
43
+ if by_number:
44
+ return by_number[0]
45
+ raise
46
+
47
+
48
+ def channel_named(device, canal):
49
+ return find_in(device.get("channels", []), canal, ["key", "label"], "canal")
50
+
51
+
52
+ @app.command()
53
+ def dispositivos(ctx: typer.Context, as_json: JsonOption = False, as_csv: CsvOption = False):
54
+ """Los dispositivos: su estado, cuándo enviaron y cuántos canales ya van a un punto."""
55
+ devices = client_of(ctx).get("connect/devices").get("devices", [])
56
+ output = output_format(as_json, as_csv)
57
+ rows = devices if output == "json" else [
58
+ {**device, "estado": DEVICE_STATES.get(device["state"], device["state"]), "last_seen": _when(device.get("last_seen_at"))} for device in devices
59
+ ]
60
+ print_rows(DEVICE_COLUMNS, rows, output)
61
+
62
+
63
+ @app.command()
64
+ def canales(
65
+ ctx: typer.Context,
66
+ dispositivo: Annotated[str, typer.Argument(help="El id (d-…), el nombre o el número del dispositivo.")],
67
+ as_json: JsonOption = False,
68
+ as_csv: CsvOption = False,
69
+ ):
70
+ """Los canales de un dispositivo: lo último que llegó y a qué punto va cada uno."""
71
+ client = client_of(ctx)
72
+ device = client.get(f"connect/devices/{device_named(client, dispositivo)['id']}")
73
+ output = output_format(as_json, as_csv)
74
+ channels = device.get("channels", [])
75
+ rows = channels if output == "json" else [{
76
+ **channel,
77
+ "punto": f"{channel['point']['machine_name']} · {channel['point']['name']}" if channel.get("point") else "",
78
+ "estado": channel.get("state_note") or CHANNEL_STATES.get(channel["state"], channel["state"]),
79
+ } for channel in channels]
80
+ print_rows(CHANNEL_COLUMNS, rows, output)
81
+
82
+
83
+ @app.command()
84
+ def mensajes(
85
+ ctx: typer.Context,
86
+ dispositivo: Annotated[str, typer.Argument(help="El id (d-…), el nombre o el número del dispositivo.")],
87
+ cuantos: Annotated[int, typer.Option("--cuantos", help="Cuántos, del más nuevo: hasta 50.")] = 10,
88
+ as_json: JsonOption = False,
89
+ as_csv: CsvOption = False,
90
+ ):
91
+ """Lo que envió un dispositivo, tal como llegó, con lo que se leyó y lo que se rechazó."""
92
+ client = client_of(ctx)
93
+ device = device_named(client, dispositivo)
94
+ messages = client.get(f"connect/devices/{device['id']}/messages").get("messages", [])[:max(1, min(cuantos, 50))]
95
+ output = output_format(as_json, as_csv)
96
+ rows = messages if output == "json" else [{
97
+ **message, "llego": _when(message["received_at"]), "resultado": RESULTS.get(message["result"], message["result"]),
98
+ "body": message["body"] if len(message["body"]) <= BODY_SHOWN else message["body"][:BODY_SHOWN] + "…",
99
+ } for message in messages]
100
+ print_rows(MESSAGE_COLUMNS, rows, output)
101
+
102
+
103
+ @app.command()
104
+ def mapear(
105
+ ctx: typer.Context,
106
+ dispositivo: Annotated[str, typer.Argument(help="El id (d-…), el nombre o el número del dispositivo.")],
107
+ canal: Annotated[str, typer.Argument(help="El canal tal como lo envía: planta/comp2/temp.")],
108
+ equipo: Annotated[str, typer.Option("--equipo", help="La máquina, por su código o nombre.")],
109
+ punto: Annotated[str, typer.Option("--punto", help="El punto de medición de esa máquina, por su nombre.")],
110
+ escala: Annotated[Optional[str], typer.Option("--escala", help="Por cuánto se multiplica lo que llega: 0,1.")] = None,
111
+ si: YesOption = False,
112
+ as_json: JsonOption = False,
113
+ ):
114
+ """Asigna un canal a un punto de medición de una máquina."""
115
+ client = client_of(ctx)
116
+ device = client.get(f"connect/devices/{device_named(client, dispositivo)['id']}")
117
+ channel = channel_named(device, canal)
118
+ machine = find_one(client, "assets", equipo, ["code", "name"], "equipo")
119
+ points = [row["point"] for row in client.get("connect/mappable-points").get("points", []) if row["point"]["machine_id"] == machine["id"]]
120
+ point = find_in(points, punto, ["name"], "punto")
121
+ change = {"point": point["id"]}
122
+ if escala:
123
+ change["scale"] = number(escala)
124
+ confirm_write(f"{device['name']} · {channel['key']} irá a {machine['name']} · {point['name']}.", si)
125
+ try:
126
+ changed = answer_of(client.send("PATCH", f"connect/channels/{channel['id']}", json=change))
127
+ except ApiError as error:
128
+ # A counter's first mapping that doesn't fit what people wrote down: a person decides.
129
+ if error.status != 409:
130
+ raise
131
+ raise CliError(FIRST_MAPPING) from None
132
+ print_result(changed, as_json, f"{channel['key']} va ahora a {machine['name']} · {point['name']}.")
133
+
134
+
135
+ @app.command()
136
+ def silenciar(
137
+ ctx: typer.Context,
138
+ dispositivo: Annotated[str, typer.Argument(help="El id (d-…), el nombre o el número del dispositivo.")],
139
+ canal: Annotated[str, typer.Argument(help="El canal tal como lo envía.")],
140
+ hasta: Annotated[Optional[str], typer.Option("--hasta", help="Hasta cuándo no avisa: 05/11/2026 18:00. Sin --hasta termina el silencio.")] = None,
141
+ motivo: Annotated[Optional[str], typer.Option("--motivo", help="Por qué: Mantenimiento del compresor.")] = None,
142
+ si: YesOption = False,
143
+ as_json: JsonOption = False,
144
+ ):
145
+ """Silencia los avisos de un canal hasta una hora; las lecturas siguen."""
146
+ client = client_of(ctx)
147
+ device = client.get(f"connect/devices/{device_named(client, dispositivo)['id']}")
148
+ channel = channel_named(device, canal)
149
+ until = moment(hasta) if hasta else None
150
+ confirm_write(f"{'Silenciar' if until else 'Quitar el silencio de'} {device['name']} · {channel['key']}.", si)
151
+ changed = answer_of(client.send("POST", f"connect/channels/{channel['id']}/silence", json={"until": until, "reason": motivo or ""}))
152
+ print_result(changed, as_json, f"{channel['key']}: {changed.get('state_note') or 'sin silencio'}.")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: operx-cli
3
- Version: 0.10.0
3
+ Version: 0.11.0
4
4
  Summary: La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API.
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -216,6 +216,17 @@ operx chat leido mantenimiento
216
216
  Chat va solo por canales: los mensajes directos, los grupos y los archivos
217
217
  son de la app.
218
218
 
219
+ ```
220
+ operx connect dispositivos # los que envían datos de las máquinas, y su estado
221
+ operx connect canales "Broker planta" # qué envía cada canal y a qué punto va
222
+ operx connect mensajes d-7f3k2m --cuantos 5 # lo que llegó, tal cual
223
+ operx connect mapear d-7f3k2m planta/comp2/temp --equipo "Compresor 2" --punto "Temperatura rodamiento"
224
+ operx connect silenciar d-7f3k2m planta/comp2/temp --hasta "05/11/2026 18:00" --motivo "Mantenimiento"
225
+ ```
226
+
227
+ Las claves de los dispositivos no pasan por el CLI: se crean y se ven en la
228
+ app, en la pestaña Clave del dispositivo.
229
+
219
230
  SG-SST y Ambiental van por empresa: sin `--empresa`, la de tu cuenta. Si
220
231
  llevas el SG-SST o lo ambiental de tus clientes, `operx sst empresas` y
221
232
  `operx ambiental empresas` las listan.
@@ -32,6 +32,7 @@ operx_cli/commands/campos.py
32
32
  operx_cli/commands/chat.py
33
33
  operx_cli/commands/common.py
34
34
  operx_cli/commands/completar.py
35
+ operx_cli/commands/connect.py
35
36
  operx_cli/commands/empresas.py
36
37
  operx_cli/commands/inventario.py
37
38
  operx_cli/commands/lecturas.py
@@ -56,6 +57,7 @@ tests/test_cards.py
56
57
  tests/test_chat.py
57
58
  tests/test_client.py
58
59
  tests/test_completar.py
60
+ tests/test_connect.py
59
61
  tests/test_daily_flows.py
60
62
  tests/test_inventario_lecturas.py
61
63
  tests/test_json_writes.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "operx-cli"
7
- version = "0.10.0"
7
+ version = "0.11.0"
8
8
  description = "La línea de comandos de OperX: tu empresa desde la terminal, con tu token de API."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -0,0 +1,111 @@
1
+ """`operx connect`: the devices, a device's channels and messages, mapping a channel
2
+ and silencing it. Fixtures: answers copied from what a personal token gets
3
+ (backend/connect/payloads.py; backend/connect/tests/test_api_tokens.py), taken from a
4
+ local run on 2026-11-05, as connect/api_descriptions.py describes them."""
5
+
6
+ import json
7
+
8
+ import pytest
9
+
10
+ from .fake_api import FakeApi, page, run
11
+
12
+ POINT_HOURS = {"id": 2, "name": "Horómetro", "machine_id": 1, "machine_name": "Compresor 2", "kind": "counter", "unit": "h", "min_value": None, "max_value": None}
13
+ POINT_TEMP = {
14
+ "id": 1, "name": "Temperatura rodamiento", "machine_id": 1, "machine_name": "Compresor 2", "kind": "condition", "unit": "°C",
15
+ "min_value": "70.0000", "max_value": "95.0000",
16
+ }
17
+ # GET /v1/connect/devices/ (payloads.py › device_cards)
18
+ CARD = {
19
+ "id": 3, "uid": "d-fwd000001", "name": "Broker planta", "preset": "broker", "model_label": "Broker de la planta", "place_name": "Planta Yumbo",
20
+ "status": "active", "state": "receiving", "last_seen_at": "2026-11-05T10:39:35.188451-05:00", "silent_since": None, "last_error": "",
21
+ "every_minutes": 5, "channels_total": 2, "channels_mapped": 0, "main": None, "strip": ["gap", "ok"], "strip_from": "2026-11-04T11:15:36.712052-05:00",
22
+ }
23
+
24
+
25
+ def channel(channel_id, key, value, point=None, state="unmapped", note=""):
26
+ """A channel as payloads.py › channel_rows writes it."""
27
+ return {
28
+ "id": channel_id, "key": key, "label": "", "point": point, "is_active": True, "scale": "1", "offset": "0.0000", "every_minutes": 15,
29
+ "confirm_after": 3, "clear_after": 3, "margin": None, "wrap_at": None, "margin_used": None, "max_rise_per_hour": None,
30
+ "last_value": value, "last_raw": value.rstrip("0").rstrip("."), "last_value_at": "2026-11-05T10:38:15.957537-05:00", "recent": [],
31
+ "state": state, "state_note": note, "silenced_until": None, "alarm_id": None, "question": None,
32
+ }
33
+
34
+
35
+ # GET /v1/connect/devices/3/ (payloads.py › device_detail)
36
+ DEVICE = {
37
+ "id": 3, "uid": "d-fwd000001", "name": "Broker planta", "preset": "broker", "model_label": "Broker de la planta", "way": "mqtt",
38
+ "mqtt": {"host": "mqtt.connect.operx.pro", "port": 8883, "username": "d-fwd000001", "client_id": "d-fwd000001", "topic": "operx/d-fwd000001/up"},
39
+ "place_id": 1, "place_name": "Planta Yumbo", "time_zone": "America/Bogota", "use_arrival_time": False, "every_minutes": 5, "status": "active",
40
+ "state": "receiving", "last_seen_at": "2026-11-05T10:39:35.188451-05:00", "last_error": "", "silent_since": None,
41
+ "ingest_address": "https://api.operx.pro/api/v1/connect/ingest",
42
+ "channels": [channel(31, "planta/comp2/temp", "80.5000"), channel(32, "planta/comp2/horas", "1200.0000")],
43
+ }
44
+ MESSAGE = {
45
+ "id": 46, "received_at": "2026-11-05T10:39:35.188451-05:00", "way": "mqtt", "result": "read", "values_in": 1, "values_written": 0,
46
+ "values_skipped": 0, "reason": "", "alarm": None, "body": '{"readings": [{"channel": "planta/comp2/temp", "value": "80.5"}]}',
47
+ }
48
+ MAPPABLE = {"points": [
49
+ {"point": POINT_HOURS, "read_by": None, "read_by_channel": None, "in_round": None},
50
+ {"point": POINT_TEMP, "read_by": None, "read_by_channel": None, "in_round": None},
51
+ ]}
52
+ # GET /v1/assets/?search= (Registros' machines; only the fields the lookup reads matter here)
53
+ MACHINE = {"id": 1, "code": "CP-02", "name": "Compresor 2"}
54
+ PLANT = {
55
+ ("GET", "assets/"): page(MACHINE),
56
+ ("GET", "connect/devices/"): {"devices": [CARD]},
57
+ ("GET", "connect/devices/3/"): DEVICE,
58
+ ("GET", "connect/mappable-points/"): MAPPABLE,
59
+ }
60
+
61
+
62
+ @pytest.fixture(autouse=True)
63
+ def signed_in(monkeypatch):
64
+ monkeypatch.setenv("OPERX_TOKEN", "opx_test")
65
+
66
+
67
+ def test_dispositivos_says_each_state_as_the_app_does():
68
+ result = run(FakeApi(PLANT), "connect", "dispositivos")
69
+ assert result.exit_code == 0, result.output
70
+ for text in ("d-fwd000001", "Broker planta", "Recibiendo", "Planta Yumbo"):
71
+ assert text in result.output
72
+
73
+
74
+ def test_canales_shows_what_arrives_and_where_it_goes():
75
+ result = run(FakeApi(PLANT), "connect", "canales", "Broker planta", "--csv")
76
+ assert result.exit_code == 0, result.output
77
+ # Spanish Excel's CSV: «;» between fields, a decimal comma, no trailing zeros.
78
+ assert result.output.splitlines()[1].split(";")[:4] == ["planta/comp2/temp", "", "Sin asignar", "80,5"]
79
+
80
+
81
+ def test_mensajes_shows_them_as_they_arrived():
82
+ fake = FakeApi({**PLANT, ("GET", "connect/devices/3/messages/"): {"messages": [MESSAGE]}})
83
+ result = run(fake, "connect", "mensajes", "d-fwd000001", "--json")
84
+ assert result.exit_code == 0, result.output
85
+ assert json.loads(result.output)[0]["body"] == MESSAGE["body"]
86
+
87
+
88
+ def test_mapear_sends_the_point_and_the_scale():
89
+ fake = FakeApi({**PLANT, ("PATCH", "connect/channels/31/"): channel(31, "planta/comp2/temp", "8.0500", point=POINT_TEMP, state="in_range")})
90
+ result = run(fake, "connect", "mapear", "d-fwd000001", "planta/comp2/temp", "--equipo", "CP-02", "--punto", "Temperatura rodamiento", "--escala", "0,1", "--si")
91
+ assert result.exit_code == 0, result.output
92
+ assert fake.sent("PATCH", "connect/channels/31/") == {"point": 1, "scale": "0.1"}
93
+ assert "Compresor 2 · Temperatura rodamiento" in result.output
94
+
95
+
96
+ def test_mapear_leaves_a_counter_that_doesnt_fit_to_a_person():
97
+ refused = (409, {"first_mapping": {"kind": "lower", "device_value": "12.0000", "point_value": "1200.0000", "unit": "h"}})
98
+ fake = FakeApi({**PLANT, ("PATCH", "connect/channels/32/"): refused})
99
+ result = run(fake, "connect", "mapear", "d-fwd000001", "planta/comp2/horas", "--equipo", "Compresor 2", "--punto", "Horómetro", "--si")
100
+ assert result.exit_code != 0
101
+ assert "decídelo en la app" in result.output
102
+
103
+
104
+ def test_silenciar_sends_until_when_and_why():
105
+ silenced = channel(31, "planta/comp2/temp", "80.5000", state="silenced", note="Silenciado hasta 18:00")
106
+ fake = FakeApi({**PLANT, ("POST", "connect/channels/31/silence/"): silenced})
107
+ result = run(fake, "connect", "silenciar", "d-fwd000001", "planta/comp2/temp", "--hasta", "05/11/2026 18:00", "--motivo", "Mantenimiento", "--si")
108
+ assert result.exit_code == 0, result.output
109
+ sent = fake.sent("POST", "connect/channels/31/silence/")
110
+ assert sent["reason"] == "Mantenimiento" and sent["until"].startswith("2026-11-05T18:00")
111
+ assert "Silenciado hasta 18:00" in result.output
File without changes
File without changes