operx-cli 0.10.0__tar.gz → 0.11.1__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.
- {operx_cli-0.10.0 → operx_cli-0.11.1}/PKG-INFO +12 -1
- {operx_cli-0.10.0 → operx_cli-0.11.1}/README.md +11 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/AGENTES.md +5 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/__init__.py +1 -1
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/app.py +2 -1
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/chat.py +13 -2
- operx_cli-0.11.1/operx_cli/commands/connect.py +152 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli.egg-info/PKG-INFO +12 -1
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli.egg-info/SOURCES.txt +2 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/pyproject.toml +1 -1
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_chat.py +21 -0
- operx_cli-0.11.1/tests/test_connect.py +111 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/agent_guide.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/api_fields.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/browser_login.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/client.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/__init__.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/account.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/ambiental.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/articulos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/avisos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/campos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/common.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/completar.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/empresas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/inventario.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/lecturas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/muestreo.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/ordenes.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/personas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/planta.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/preventivos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/raw.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/registros.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/reportes.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/rutinas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/search.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/sst.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/commands/update.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/lookup.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/output.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/record_completion.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/self_update.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/settings.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/shell.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/spreadsheet.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/updates.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/values.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli/welcome.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli.egg-info/dependency_links.txt +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli.egg-info/entry_points.txt +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli.egg-info/requires.txt +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/operx_cli.egg-info/top_level.txt +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/setup.cfg +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_agentes.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_api_campos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_app.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_articulos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_campos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_cards.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_client.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_completar.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_daily_flows.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_inventario_lecturas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_json_writes.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_login.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_logout_and_routes.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_lookup.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_more_of_the_api.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_muestreo.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_ordenes.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_output.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_personas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_preventivos.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_record_completion.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_registros.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_rutinas.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_self_update.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_settings.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_shell.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_spreadsheet.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_sst_ambiental.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_updates.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_values.py +0 -0
- {operx_cli-0.10.0 → operx_cli-0.11.1}/tests/test_welcome.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: operx-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.1
|
|
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
|
|
@@ -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()
|
|
@@ -50,9 +50,20 @@ def mentioned(text, people):
|
|
|
50
50
|
]
|
|
51
51
|
|
|
52
52
|
|
|
53
|
+
# Where a message puts its record among its words; the record itself is its «link».
|
|
54
|
+
RECORD_MARKER = "[[•]]"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def words_of(message):
|
|
58
|
+
"""The message's text, with the record's place named by the record's title."""
|
|
59
|
+
link = message.get("link")
|
|
60
|
+
name = (link.get("title") or "Registro no disponible") if link else ""
|
|
61
|
+
return (message.get("body") or "").replace(RECORD_MARKER, name).strip()
|
|
62
|
+
|
|
63
|
+
|
|
53
64
|
def message_line(message):
|
|
54
65
|
"""One message for a person: when, who, what, and what hangs from it."""
|
|
55
|
-
text = "(eliminado)" if message.get("deleted") else message
|
|
66
|
+
text = "(eliminado)" if message.get("deleted") else words_of(message) or (message.get("link") or {}).get("title") or ""
|
|
56
67
|
extras = []
|
|
57
68
|
if message.get("edited_at"):
|
|
58
69
|
extras.append("editado")
|
|
@@ -69,7 +80,7 @@ def _found_row(found):
|
|
|
69
80
|
message = found["message"]
|
|
70
81
|
return {
|
|
71
82
|
"channel": f"#{found['conversation']['title']}", "when": shown_moment(message.get("created_at")),
|
|
72
|
-
"author_name": message.get("author_name"), "text": message
|
|
83
|
+
"author_name": message.get("author_name"), "text": words_of(message), "id": message["id"],
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
|
|
@@ -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.
|
|
3
|
+
Version: 0.11.1
|
|
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.
|
|
7
|
+
version = "0.11.1"
|
|
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"
|
|
@@ -75,6 +75,27 @@ def test_leer_finds_the_channel_by_name_with_or_without_hash():
|
|
|
75
75
|
assert "Hay más antes: --antes 12" in result.output
|
|
76
76
|
|
|
77
77
|
|
|
78
|
+
WORK_ORDER = {"kind": "work_order", "id": 1042, "title": "OT-1042 · Cambio de rodamiento", "detail": "En Progreso"}
|
|
79
|
+
NOT_AVAILABLE = {"kind": None, "id": None, "title": None, "detail": ""}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_leer_names_the_record_where_the_message_puts_it():
|
|
83
|
+
fake = FakeApi({
|
|
84
|
+
("GET", "chat/conversations/"): [CHANNEL],
|
|
85
|
+
("GET", "chat/conversations/4/messages/"): {"messages": [
|
|
86
|
+
message(20, "Revisa [[•]] hoy", link=WORK_ORDER),
|
|
87
|
+
message(21, "[[•]]", link=WORK_ORDER),
|
|
88
|
+
message(22, "Mira [[•]]", link=NOT_AVAILABLE),
|
|
89
|
+
], "has_more": False},
|
|
90
|
+
})
|
|
91
|
+
result = run(fake, "chat", "leer", "4")
|
|
92
|
+
|
|
93
|
+
assert "Revisa OT-1042 · Cambio de rodamiento hoy" in result.output
|
|
94
|
+
assert "Laura Gómez: OT-1042 · Cambio de rodamiento" in result.output
|
|
95
|
+
assert "Mira Registro no disponible" in result.output
|
|
96
|
+
assert "[[" not in result.output
|
|
97
|
+
|
|
98
|
+
|
|
78
99
|
def test_leer_a_thread_asks_for_its_replies():
|
|
79
100
|
fake = FakeApi({
|
|
80
101
|
("GET", "chat/conversations/"): [CHANNEL],
|
|
@@ -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, "connector": None, "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")], "connector": None,
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|