replx 1.8__tar.gz → 1.10__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.
- {replx-1.8/replx.egg-info → replx-1.10}/PKG-INFO +4 -2
- {replx-1.8 → replx-1.10}/README.md +3 -1
- {replx-1.8 → replx-1.10}/replx/__init__.py +1 -1
- replx-1.10/replx/__main__.py +14 -0
- replx-1.10/replx/cli/__init__.py +20 -0
- replx-1.10/replx/cli/agent/__init__.py +27 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/client/core.py +51 -16
- {replx-1.8 → replx-1.10}/replx/cli/agent/client/session.py +7 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/connection_manager.py +28 -87
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/core.py +22 -3
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/exec.py +38 -8
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/session_manager.py +3 -0
- {replx-1.8 → replx-1.10}/replx/cli/app.py +138 -62
- replx-1.10/replx/cli/commands/_common.py +122 -0
- {replx-1.8 → replx-1.10}/replx/cli/commands/adc.py +2 -21
- {replx-1.8 → replx-1.10}/replx/cli/commands/device.py +203 -936
- {replx-1.8 → replx-1.10}/replx/cli/commands/exec.py +70 -56
- {replx-1.8 → replx-1.10}/replx/cli/commands/file.py +102 -22
- {replx-1.8 → replx-1.10}/replx/cli/commands/firmware.py +33 -9
- {replx-1.8 → replx-1.10}/replx/cli/commands/gpio.py +1 -14
- {replx-1.8 → replx-1.10}/replx/cli/commands/i2c.py +1 -50
- {replx-1.8 → replx-1.10}/replx/cli/commands/package.py +112 -27
- {replx-1.8 → replx-1.10}/replx/cli/commands/pwm.py +4 -16
- {replx-1.8 → replx-1.10}/replx/cli/commands/spi.py +12 -109
- {replx-1.8 → replx-1.10}/replx/cli/commands/uart.py +4 -97
- {replx-1.8 → replx-1.10}/replx/cli/commands/utility.py +233 -152
- replx-1.10/replx/cli/commands/wifi.py +914 -0
- replx-1.10/replx/cli/config.py +858 -0
- {replx-1.8 → replx-1.10}/replx/cli/connection.py +37 -41
- {replx-1.8 → replx-1.10}/replx/cli/helpers/__init__.py +24 -6
- {replx-1.8 → replx-1.10}/replx/cli/helpers/compiler.py +54 -24
- replx-1.10/replx/cli/helpers/output.py +626 -0
- {replx-1.8 → replx-1.10}/replx/cli/helpers/registry.py +16 -1
- {replx-1.8 → replx-1.10}/replx/cli/helpers/scanner.py +33 -5
- {replx-1.8 → replx-1.10}/replx/utils/constants.py +2 -1
- {replx-1.8 → replx-1.10/replx.egg-info}/PKG-INFO +4 -2
- {replx-1.8 → replx-1.10}/replx.egg-info/SOURCES.txt +3 -0
- replx-1.8/replx/cli/__init__.py +0 -13
- replx-1.8/replx/cli/agent/__init__.py +0 -14
- replx-1.8/replx/cli/config.py +0 -461
- replx-1.8/replx/cli/helpers/output.py +0 -219
- {replx-1.8 → replx-1.10}/LICENSE +0 -0
- {replx-1.8 → replx-1.10}/pyproject.toml +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/client/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/protocol.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/__main__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/command_dispatcher.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/filesystem.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/i2c.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/repl.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/session.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/spi.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/transfer.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/agent/server/handlers/uart.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/commands/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/helpers/environment.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/helpers/store.py +0 -0
- {replx-1.8 → replx-1.10}/replx/cli/helpers/updater.py +0 -0
- {replx-1.8 → replx-1.10}/replx/commands.py +0 -0
- {replx-1.8 → replx-1.10}/replx/protocol/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/protocol/repl.py +0 -0
- {replx-1.8 → replx-1.10}/replx/protocol/storage.py +0 -0
- {replx-1.8 → replx-1.10}/replx/terminal.py +0 -0
- {replx-1.8 → replx-1.10}/replx/transport/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/transport/serial.py +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/_thread.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/aioble/__init__.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/array.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/asyncio/__init__.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/binascii.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/bluetooth.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/builtins.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/cmath.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/collections.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/cryptolib.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/deflate.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/errno.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/framebuf.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/gc.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/hashlib.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/heapq.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/io.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/json.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/lwip.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/machine.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/math.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/micropython.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/mip/__init__.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/network.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/ntptime.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/os.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/platform.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/random.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/re.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/requests/__init__.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/select.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/socket.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/ssl.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/struct.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/sys.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/time.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/tls.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/uasyncio.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/uctypes.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/urequests.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm/vfs.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/binascii.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/digi/__init__.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/digi/ble.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/errno.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/hashlib.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/io.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/json.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/machine.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/math.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/micropython.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/network.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/os.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/select.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/socket.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/ssl.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/struct.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/sys.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/time.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/ubinascii.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/ucryptolib.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/uerrno.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/uhashlib.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/uio.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/ujson.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/umachine.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/uos.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/uselect.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/usocket.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/ussl.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/ustruct.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/utime.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/xbee/__init__.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/xbee/modem_status.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/comm_separate/EFR32MG/xbee/relay.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/core/ESP32/aioespnow.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/core/ESP32/esp.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/core/ESP32/esp32.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/core/ESP32/espnow.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/core/MIMXRT1062DVJ6A/mimxrt.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/typehints/core/RP2350/rp2.pyi +0 -0
- {replx-1.8 → replx-1.10}/replx/utils/__init__.py +0 -0
- {replx-1.8 → replx-1.10}/replx/utils/device_info.py +0 -0
- {replx-1.8 → replx-1.10}/replx/utils/exceptions.py +0 -0
- {replx-1.8 → replx-1.10}/replx.egg-info/dependency_links.txt +0 -0
- {replx-1.8 → replx-1.10}/replx.egg-info/entry_points.txt +0 -0
- {replx-1.8 → replx-1.10}/replx.egg-info/requires.txt +0 -0
- {replx-1.8 → replx-1.10}/replx.egg-info/top_level.txt +0 -0
- {replx-1.8 → replx-1.10}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: replx
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.10
|
|
4
4
|
Summary: replx is a fast, modern MicroPython CLI: turbo REPL, robust file sync (put/get), project install, mpy-cross integration, and smart port discovery.
|
|
5
5
|
Author-email: "chanmin.park" <devcamp@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,11 +30,12 @@ Dynamic: license-file
|
|
|
30
30
|
[](https://www.python.org/downloads/)
|
|
31
31
|
[](https://opensource.org/licenses/MIT)
|
|
32
32
|
|
|
33
|
-
`replx` is a CLI tool for MicroPython development. It uses
|
|
33
|
+
`replx` is a CLI tool for MicroPython development. It uses a single local agent process to manage multiple CLI sessions and multiple boards in a consistent workflow.
|
|
34
34
|
|
|
35
35
|
## What replx provides
|
|
36
36
|
|
|
37
37
|
- Shared connection management across terminal sessions
|
|
38
|
+
- Single local agent process per PC with home-scoped port persistence
|
|
38
39
|
- Foreground and background board handling per session
|
|
39
40
|
- Workspace-level default device configuration
|
|
40
41
|
- File operations on device storage
|
|
@@ -104,6 +105,7 @@ pip install replx
|
|
|
104
105
|
|
|
105
106
|
- `scan`, `status`, `whoami`, and `shutdown` are special commands and do not accept `--port`.
|
|
106
107
|
- Most device commands can omit the port when a foreground or workspace default device is available.
|
|
108
|
+
- The agent listens on a UDP port from `49152-65535` and stores the selected port in `~/.replx/.config` as `AGENT_PORT=...`.
|
|
107
109
|
|
|
108
110
|
## License
|
|
109
111
|
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
[](https://www.python.org/downloads/)
|
|
5
5
|
[](https://opensource.org/licenses/MIT)
|
|
6
6
|
|
|
7
|
-
`replx` is a CLI tool for MicroPython development. It uses
|
|
7
|
+
`replx` is a CLI tool for MicroPython development. It uses a single local agent process to manage multiple CLI sessions and multiple boards in a consistent workflow.
|
|
8
8
|
|
|
9
9
|
## What replx provides
|
|
10
10
|
|
|
11
11
|
- Shared connection management across terminal sessions
|
|
12
|
+
- Single local agent process per PC with home-scoped port persistence
|
|
12
13
|
- Foreground and background board handling per session
|
|
13
14
|
- Workspace-level default device configuration
|
|
14
15
|
- File operations on device storage
|
|
@@ -78,6 +79,7 @@ pip install replx
|
|
|
78
79
|
|
|
79
80
|
- `scan`, `status`, `whoami`, and `shutdown` are special commands and do not accept `--port`.
|
|
80
81
|
- Most device commands can omit the port when a foreground or workspace default device is available.
|
|
82
|
+
- The agent listens on a UDP port from `49152-65535` and stores the selected port in `~/.replx/.config` as `AGENT_PORT=...`.
|
|
81
83
|
|
|
82
84
|
## License
|
|
83
85
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Allow ``python -m replx ...`` as a fast-startup alias for the ``replx`` script.
|
|
2
|
+
|
|
3
|
+
On Windows the ``replx.exe`` console-script launcher (installed by pip from
|
|
4
|
+
``[project.scripts]``) goes through a zipapp-style entry that adds ~450ms to
|
|
5
|
+
every invocation. ``python -m replx`` bypasses that launcher and is noticeably
|
|
6
|
+
snappier for users who care about CLI startup latency.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from replx.cli.app import main
|
|
12
|
+
|
|
13
|
+
if __name__ == "__main__":
|
|
14
|
+
sys.exit(main())
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from .config import (
|
|
2
|
+
RuntimeState, STATE, GLOBAL_OPTIONS,
|
|
3
|
+
ConfigManager, AgentPortManager, ConnectionResolver,
|
|
4
|
+
)
|
|
5
|
+
from replx.utils.constants import DEFAULT_AGENT_PORT, MIN_AGENT_PORT, MAX_AGENT_PORT
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
'RuntimeState', 'STATE', 'GLOBAL_OPTIONS',
|
|
9
|
+
'ConfigManager', 'AgentPortManager', 'ConnectionResolver',
|
|
10
|
+
'DEFAULT_AGENT_PORT', 'MIN_AGENT_PORT', 'MAX_AGENT_PORT',
|
|
11
|
+
'app', 'main'
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def __getattr__(name):
|
|
16
|
+
if name in {'app', 'main'}:
|
|
17
|
+
from .app import app, main
|
|
18
|
+
|
|
19
|
+
return {'app': app, 'main': main}[name]
|
|
20
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
|
|
2
|
+
from .protocol import AgentProtocol
|
|
3
|
+
from .client import AgentClient, get_session_id, get_cached_session_id, clear_session_cache
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
'AgentProtocol',
|
|
7
|
+
'AgentClient',
|
|
8
|
+
'get_session_id',
|
|
9
|
+
'get_cached_session_id',
|
|
10
|
+
'clear_session_cache',
|
|
11
|
+
'AgentServer',
|
|
12
|
+
'agent_main',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def __getattr__(name):
|
|
17
|
+
# The server stack pulls in ``replx.terminal`` (~150ms) plus all command
|
|
18
|
+
# handlers; CLI clients never need it. Defer until something actually asks.
|
|
19
|
+
if name == 'AgentServer':
|
|
20
|
+
from .server import AgentServer
|
|
21
|
+
|
|
22
|
+
return AgentServer
|
|
23
|
+
if name == 'agent_main':
|
|
24
|
+
from .server import main as agent_main
|
|
25
|
+
|
|
26
|
+
return agent_main
|
|
27
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -128,35 +128,64 @@ class AgentClient:
|
|
|
128
128
|
seq = request['seq']
|
|
129
129
|
request_data = AgentProtocol.encode_message(request)
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
self.sock.settimeout(
|
|
131
|
+
# UDP can drop ACK packets. Retry start request a few times while
|
|
132
|
+
# waiting for ACK/early stream for this seq.
|
|
133
|
+
self.sock.settimeout(0.2)
|
|
134
134
|
ack_received = False
|
|
135
135
|
error_response = None
|
|
136
|
+
completed_during_handshake = False
|
|
136
137
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
138
|
+
max_attempts = max(1, self.MAX_RETRIES)
|
|
139
|
+
for attempt in range(max_attempts):
|
|
140
|
+
self.sock.sendto(request_data, (AGENT_HOST, self.agent_port))
|
|
141
|
+
|
|
142
|
+
attempt_deadline = time.time() + 1.5
|
|
143
|
+
while time.time() < attempt_deadline:
|
|
144
|
+
try:
|
|
145
|
+
data, addr = self.sock.recvfrom(MAX_UDP_SIZE)
|
|
146
|
+
msg = AgentProtocol.decode_message(data)
|
|
147
|
+
if msg and msg.get('seq') == seq:
|
|
148
|
+
msg_type = msg.get('type')
|
|
149
|
+
if msg_type == 'ack':
|
|
150
|
+
ack_received = True
|
|
151
|
+
break
|
|
152
|
+
if msg_type == 'response' and msg.get('error'):
|
|
153
|
+
error_response = msg
|
|
154
|
+
break
|
|
155
|
+
# If stream arrives before ACK (ACK loss/reordering),
|
|
156
|
+
# treat it as a successful start.
|
|
157
|
+
if msg_type == 'stream':
|
|
158
|
+
ack_received = True
|
|
159
|
+
output = msg.get('output', '')
|
|
160
|
+
if output and output_callback:
|
|
161
|
+
output_callback(output.encode('utf-8'), 'stdout')
|
|
162
|
+
if msg.get('completed'):
|
|
163
|
+
error = msg.get('error')
|
|
164
|
+
if error and output_callback:
|
|
165
|
+
output_callback(error.encode('utf-8'), 'stderr')
|
|
166
|
+
completed_during_handshake = True
|
|
167
|
+
break
|
|
168
|
+
except socket.timeout:
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
if ack_received or error_response:
|
|
149
172
|
break
|
|
150
173
|
|
|
151
174
|
if error_response:
|
|
152
175
|
raise RuntimeError(error_response['error'])
|
|
153
176
|
|
|
154
177
|
if not ack_received:
|
|
155
|
-
raise RuntimeError("No ACK from agent - run_interactive failed to start")
|
|
178
|
+
raise RuntimeError(f"No ACK from agent - run_interactive failed to start (attempts={max_attempts})")
|
|
179
|
+
|
|
180
|
+
if completed_during_handshake:
|
|
181
|
+
self.sock.settimeout(self.TIMEOUT)
|
|
182
|
+
return {"run": True, "completed": True}
|
|
156
183
|
|
|
157
184
|
self.sock.settimeout(0.01)
|
|
158
185
|
input_interval = 0.001
|
|
159
186
|
last_input_time = 0
|
|
187
|
+
last_stream_time = time.time()
|
|
188
|
+
stream_timeout = 5.0 # 5 seconds without stream = connection lost
|
|
160
189
|
|
|
161
190
|
try:
|
|
162
191
|
while True:
|
|
@@ -181,6 +210,7 @@ class AgentClient:
|
|
|
181
210
|
msg = AgentProtocol.decode_message(data)
|
|
182
211
|
if msg and msg.get('seq') == seq:
|
|
183
212
|
if msg.get('type') == 'stream':
|
|
213
|
+
last_stream_time = time.time()
|
|
184
214
|
output = msg.get('output', '')
|
|
185
215
|
if output and output_callback:
|
|
186
216
|
output_callback(output.encode('utf-8'), 'stdout')
|
|
@@ -204,6 +234,10 @@ class AgentClient:
|
|
|
204
234
|
|
|
205
235
|
now = time.time()
|
|
206
236
|
|
|
237
|
+
# Check for stream reception timeout (connection loss detection)
|
|
238
|
+
if now - last_stream_time > stream_timeout:
|
|
239
|
+
raise RuntimeError("Connection lost - no data from board for {}s".format(stream_timeout))
|
|
240
|
+
|
|
207
241
|
if now - last_input_time >= input_interval:
|
|
208
242
|
last_input_time = now
|
|
209
243
|
if input_provider:
|
|
@@ -225,6 +259,7 @@ class AgentClient:
|
|
|
225
259
|
if msg.get('type') == 'response' and msg.get('error'):
|
|
226
260
|
_pending_error = msg['error']
|
|
227
261
|
elif msg.get('type') == 'stream':
|
|
262
|
+
last_stream_time = time.time() # Update on any stream (even empty)
|
|
228
263
|
output = msg.get('output', '')
|
|
229
264
|
if output and output_callback:
|
|
230
265
|
output_callback(output.encode('utf-8'), 'stdout')
|
|
@@ -4,14 +4,21 @@ import psutil
|
|
|
4
4
|
|
|
5
5
|
def _find_terminal_process() -> Optional[dict]:
|
|
6
6
|
shell_names = {
|
|
7
|
+
# Windows
|
|
7
8
|
'powershell.exe', 'pwsh.exe', 'cmd.exe', 'bash.exe', 'zsh.exe', 'sh.exe', 'fish.exe',
|
|
8
9
|
'windowsterminal.exe',
|
|
10
|
+
# Linux / macOS (no .exe)
|
|
11
|
+
'bash', 'zsh', 'sh', 'fish', 'tcsh', 'csh', 'ksh', 'dash', 'pwsh',
|
|
9
12
|
}
|
|
10
13
|
|
|
11
14
|
ide_names = {
|
|
15
|
+
# Windows
|
|
12
16
|
'code.exe',
|
|
13
17
|
'conemu64.exe', 'conemu.exe',
|
|
14
18
|
'pycharm.exe', 'pycharm64.exe', 'idea.exe', 'idea64.exe',
|
|
19
|
+
# Linux / macOS (no .exe)
|
|
20
|
+
'code', 'code-oss',
|
|
21
|
+
'pycharm', 'pycharm64', 'idea', 'idea64',
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
try:
|
|
@@ -217,9 +217,10 @@ class ConnectionManager:
|
|
|
217
217
|
def __init__(self):
|
|
218
218
|
self._connections: Dict[str, BoardConnection] = {}
|
|
219
219
|
self._connections_lock = threading.RLock()
|
|
220
|
+
# Per-port events used to serialize concurrent create_serial_connection
|
|
221
|
+
# calls for the same port (prevents double serial-open race).
|
|
222
|
+
self._port_creating: Dict[str, threading.Event] = {}
|
|
220
223
|
self._default_port: Optional[str] = None
|
|
221
|
-
self._board_id_cache: Dict[str, Dict[str, str]] = {}
|
|
222
|
-
self._cache_lock = threading.Lock()
|
|
223
224
|
|
|
224
225
|
@property
|
|
225
226
|
def default_port(self) -> Optional[str]:
|
|
@@ -299,12 +300,32 @@ class ConnectionManager:
|
|
|
299
300
|
original_port = str(port).strip() if port is not None else ""
|
|
300
301
|
port_key = self._canon_port(original_port)
|
|
301
302
|
|
|
303
|
+
# --- Phase 1: fast check + race serialisation ---
|
|
302
304
|
with self._connections_lock:
|
|
303
305
|
if port_key in self._connections:
|
|
304
306
|
conn = self._connections[port_key]
|
|
305
307
|
if conn.is_connected():
|
|
306
308
|
return conn, None
|
|
309
|
+
# If another thread is already opening this port, get its event so
|
|
310
|
+
# we can wait for it instead of opening the same serial port twice.
|
|
311
|
+
if port_key in self._port_creating:
|
|
312
|
+
wait_event = self._port_creating[port_key]
|
|
313
|
+
our_event = None
|
|
314
|
+
else:
|
|
315
|
+
our_event = threading.Event()
|
|
316
|
+
self._port_creating[port_key] = our_event
|
|
317
|
+
wait_event = None
|
|
318
|
+
|
|
319
|
+
if wait_event is not None:
|
|
320
|
+
# Another thread is opening this port; wait then return its result.
|
|
321
|
+
wait_event.wait(timeout=30)
|
|
322
|
+
with self._connections_lock:
|
|
323
|
+
conn = self._connections.get(port_key)
|
|
324
|
+
if conn and conn.is_connected():
|
|
325
|
+
return conn, None
|
|
326
|
+
return None, "Connection creation timed out (concurrent attempt)"
|
|
307
327
|
|
|
328
|
+
# --- Phase 2: we are the creator ---
|
|
308
329
|
try:
|
|
309
330
|
from replx.transport import create_transport
|
|
310
331
|
|
|
@@ -355,97 +376,17 @@ class ConnectionManager:
|
|
|
355
376
|
|
|
356
377
|
with self._connections_lock:
|
|
357
378
|
self._connections[port_key] = conn
|
|
379
|
+
self._port_creating.pop(port_key, None)
|
|
358
380
|
|
|
381
|
+
our_event.set() # wake any threads that raced with us
|
|
359
382
|
return conn, None
|
|
360
383
|
|
|
361
384
|
except Exception as e:
|
|
385
|
+
with self._connections_lock:
|
|
386
|
+
self._port_creating.pop(port_key, None)
|
|
387
|
+
our_event.set() # unblock waiting threads so they get the error
|
|
362
388
|
return None, str(e)
|
|
363
389
|
|
|
364
|
-
def ensure_board_id(self, port: str) -> Optional[str]:
|
|
365
|
-
conn = self.get_connection(port)
|
|
366
|
-
if not conn or not conn.repl_protocol:
|
|
367
|
-
return None
|
|
368
|
-
|
|
369
|
-
if conn.board_id is not None:
|
|
370
|
-
return conn.board_id
|
|
371
|
-
|
|
372
|
-
try:
|
|
373
|
-
result = conn.repl_protocol.exec("import machine; print(machine.unique_id().hex())")
|
|
374
|
-
if result:
|
|
375
|
-
if isinstance(result, bytes):
|
|
376
|
-
result = result.decode('utf-8', errors='ignore')
|
|
377
|
-
result = result.strip()
|
|
378
|
-
if result:
|
|
379
|
-
conn.board_id = result
|
|
380
|
-
self._update_board_id_cache(port, conn.board_id)
|
|
381
|
-
return conn.board_id
|
|
382
|
-
except Exception:
|
|
383
|
-
pass
|
|
384
|
-
|
|
385
|
-
return None
|
|
386
|
-
|
|
387
|
-
def _update_board_id_cache(self, port: str, board_id: str) -> None:
|
|
388
|
-
with self._cache_lock:
|
|
389
|
-
if board_id not in self._board_id_cache:
|
|
390
|
-
self._board_id_cache[board_id] = {}
|
|
391
|
-
|
|
392
|
-
self._board_id_cache[board_id]["serial_port"] = self._canon_port(port)
|
|
393
|
-
|
|
394
|
-
def find_conflicting_by_board_id(self, port: str) -> Optional[str]:
|
|
395
|
-
conn = self.get_connection(port)
|
|
396
|
-
if not conn or not conn.board_id:
|
|
397
|
-
return None
|
|
398
|
-
|
|
399
|
-
with self._connections_lock:
|
|
400
|
-
for key, other_conn in self._connections.items():
|
|
401
|
-
if key == port:
|
|
402
|
-
continue
|
|
403
|
-
|
|
404
|
-
if not other_conn.is_connected():
|
|
405
|
-
continue
|
|
406
|
-
|
|
407
|
-
if other_conn.board_id is None:
|
|
408
|
-
try:
|
|
409
|
-
result = other_conn.repl_protocol.exec("import machine; print(machine.unique_id().hex())")
|
|
410
|
-
if result:
|
|
411
|
-
if isinstance(result, bytes):
|
|
412
|
-
result = result.decode('utf-8', errors='ignore')
|
|
413
|
-
result = result.strip()
|
|
414
|
-
if result:
|
|
415
|
-
other_conn.board_id = result
|
|
416
|
-
except Exception:
|
|
417
|
-
continue
|
|
418
|
-
|
|
419
|
-
if other_conn.board_id and other_conn.board_id == conn.board_id:
|
|
420
|
-
return key
|
|
421
|
-
|
|
422
|
-
return None
|
|
423
|
-
|
|
424
|
-
def resolve_board_conflict(self, port: str) -> Optional[str]:
|
|
425
|
-
board_id = self.ensure_board_id(port)
|
|
426
|
-
if not board_id:
|
|
427
|
-
return None
|
|
428
|
-
|
|
429
|
-
conflicting_port = self.find_conflicting_by_board_id(port)
|
|
430
|
-
if conflicting_port:
|
|
431
|
-
self.disconnect(conflicting_port)
|
|
432
|
-
|
|
433
|
-
conn = self.get_connection(port)
|
|
434
|
-
if conn and conn.repl_protocol:
|
|
435
|
-
try:
|
|
436
|
-
transport = conn.repl_protocol.transport
|
|
437
|
-
transport.write(CTRL_C)
|
|
438
|
-
time.sleep(0.05)
|
|
439
|
-
transport.write(CTRL_B)
|
|
440
|
-
time.sleep(0.1)
|
|
441
|
-
transport.reset_input_buffer()
|
|
442
|
-
except Exception:
|
|
443
|
-
pass
|
|
444
|
-
|
|
445
|
-
return conflicting_port
|
|
446
|
-
|
|
447
|
-
return None
|
|
448
|
-
|
|
449
390
|
def disconnect(self, port: str) -> bool:
|
|
450
391
|
key = None
|
|
451
392
|
with self._connections_lock:
|
|
@@ -334,7 +334,7 @@ class AgentServer(
|
|
|
334
334
|
)
|
|
335
335
|
self._datagram_transport = transport
|
|
336
336
|
self.running = True
|
|
337
|
-
print(f'replx agent started
|
|
337
|
+
print(f'replx agent started - listening on {AGENT_HOST}:{self.agent_port} (UDP)')
|
|
338
338
|
|
|
339
339
|
heartbeat_task = asyncio.create_task(
|
|
340
340
|
self._heartbeat_coro(), name='replx-heartbeat'
|
|
@@ -452,8 +452,16 @@ class AgentServer(
|
|
|
452
452
|
|
|
453
453
|
def _check_and_record_seq(self, client_addr: tuple, seq: int) -> bool:
|
|
454
454
|
with self._last_seq_lock:
|
|
455
|
-
if client_addr in self.last_seq
|
|
456
|
-
|
|
455
|
+
if client_addr in self.last_seq:
|
|
456
|
+
last = self.last_seq[client_addr]
|
|
457
|
+
# Only reject exact duplicates from the same UDP source.
|
|
458
|
+
#
|
|
459
|
+
# Using strict monotonic ordering across client restarts can
|
|
460
|
+
# incorrectly drop valid requests (e.g. process restart, seq
|
|
461
|
+
# wrap, or source-port reuse), which then appears as
|
|
462
|
+
# "No ACK from agent" on the client.
|
|
463
|
+
if seq == last:
|
|
464
|
+
return False
|
|
457
465
|
self.last_seq[client_addr] = seq
|
|
458
466
|
if len(self.last_seq) > self._MAX_LAST_SEQ:
|
|
459
467
|
oldest = next(iter(self.last_seq))
|
|
@@ -488,6 +496,12 @@ class AgentServer(
|
|
|
488
496
|
command = msg.get('command')
|
|
489
497
|
|
|
490
498
|
if not self._check_and_record_seq(client_addr, seq):
|
|
499
|
+
# Duplicate request (same seq from same UDP source): re-ACK so
|
|
500
|
+
# client retransmits can recover from a previously lost ACK.
|
|
501
|
+
self._safe_send(
|
|
502
|
+
AgentProtocol.encode_message(AgentProtocol.create_ack(seq)),
|
|
503
|
+
client_addr,
|
|
504
|
+
)
|
|
491
505
|
return
|
|
492
506
|
|
|
493
507
|
self._safe_send(
|
|
@@ -532,6 +546,11 @@ class AgentServer(
|
|
|
532
546
|
|
|
533
547
|
if msg_type == 'request':
|
|
534
548
|
if not self._check_and_record_seq(client_addr, seq):
|
|
549
|
+
# Duplicate request (same seq from same UDP source):
|
|
550
|
+
# send ACK again so the client can proceed.
|
|
551
|
+
ack = AgentProtocol.create_ack(seq)
|
|
552
|
+
ack_data = AgentProtocol.encode_message(ack)
|
|
553
|
+
self._safe_send(ack_data, client_addr)
|
|
535
554
|
return
|
|
536
555
|
|
|
537
556
|
ack = AgentProtocol.create_ack(seq)
|
|
@@ -232,6 +232,7 @@ class ExecCommandsMixin:
|
|
|
232
232
|
conn.interactive.thread = None
|
|
233
233
|
else:
|
|
234
234
|
send_error("Interactive session already active on this connection")
|
|
235
|
+
conn.release()
|
|
235
236
|
return
|
|
236
237
|
|
|
237
238
|
repl = conn.repl_protocol
|
|
@@ -242,6 +243,7 @@ class ExecCommandsMixin:
|
|
|
242
243
|
if script_path:
|
|
243
244
|
if not os.path.exists(script_path):
|
|
244
245
|
send_error(f"Script not found: {script_path}")
|
|
246
|
+
conn.release()
|
|
245
247
|
return
|
|
246
248
|
with open(script_path, 'rb') as f:
|
|
247
249
|
script_data = f.read()
|
|
@@ -249,6 +251,7 @@ class ExecCommandsMixin:
|
|
|
249
251
|
script_data = script_content.encode('utf-8') if isinstance(script_content, str) else script_content
|
|
250
252
|
else:
|
|
251
253
|
send_error("Either script_path or script_content required")
|
|
254
|
+
conn.release()
|
|
252
255
|
return
|
|
253
256
|
|
|
254
257
|
conn.interactive.start(ctx.ppid, seq, client_addr, echo)
|
|
@@ -282,19 +285,29 @@ class ExecCommandsMixin:
|
|
|
282
285
|
output_buffer = bytearray()
|
|
283
286
|
buffer_lock = threading.Lock()
|
|
284
287
|
last_flush_time = [time.time()]
|
|
288
|
+
last_stream_send_time = [time.time()]
|
|
285
289
|
flush_timer_running = [True]
|
|
286
290
|
BUFFER_FLUSH_SIZE = 4096
|
|
287
291
|
FLUSH_INTERVAL = 0.05
|
|
292
|
+
KEEPALIVE_INTERVAL = 1.0
|
|
288
293
|
|
|
289
294
|
def send_stream(output: str = '', completed: bool = False, error: str = None):
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
295
|
+
# When script completes (especially with error), retry sending to ensure client receives it
|
|
296
|
+
retry_count = 5 if completed else 1
|
|
297
|
+
retry_interval = 0.05 # 50ms between retries for completed messages
|
|
298
|
+
|
|
299
|
+
for attempt in range(retry_count):
|
|
300
|
+
try:
|
|
301
|
+
msg = {'type': 'stream', 'seq': seq, 'output': output}
|
|
302
|
+
if completed:
|
|
303
|
+
msg['completed'] = True
|
|
304
|
+
msg['error'] = error
|
|
305
|
+
self._safe_send(AgentProtocol.encode_message(msg), client_addr)
|
|
306
|
+
last_stream_send_time[0] = time.time()
|
|
307
|
+
if completed and attempt < retry_count - 1:
|
|
308
|
+
time.sleep(retry_interval)
|
|
309
|
+
except Exception:
|
|
310
|
+
pass
|
|
298
311
|
|
|
299
312
|
def flush_buffer():
|
|
300
313
|
with buffer_lock:
|
|
@@ -332,6 +345,10 @@ class ExecCommandsMixin:
|
|
|
332
345
|
while flush_timer_running[0]:
|
|
333
346
|
time.sleep(FLUSH_INTERVAL)
|
|
334
347
|
flush_buffer()
|
|
348
|
+
# Keep interactive session alive for client-side timeout logic,
|
|
349
|
+
# even when the script is running silently with no output.
|
|
350
|
+
if time.time() - last_stream_send_time[0] >= KEEPALIVE_INTERVAL:
|
|
351
|
+
send_stream('')
|
|
335
352
|
|
|
336
353
|
flush_thread = threading.Thread(target=flush_timer, daemon=True)
|
|
337
354
|
flush_thread.start()
|
|
@@ -403,6 +420,19 @@ class ExecCommandsMixin:
|
|
|
403
420
|
|
|
404
421
|
def _cmd_run_stop(self, ctx: CommandContext) -> dict:
|
|
405
422
|
conn = ctx.connection
|
|
423
|
+
|
|
424
|
+
# RUN_STOP is in NON_REPL_COMMANDS so ctx.connection is always None.
|
|
425
|
+
# Resolve the connection directly via explicit_port or active interactive session.
|
|
426
|
+
if conn is None:
|
|
427
|
+
if ctx.explicit_port:
|
|
428
|
+
conn = _find_connection_by_port(self.connection_manager, ctx.explicit_port)
|
|
429
|
+
if conn is None:
|
|
430
|
+
# Find any connection that has an active interactive session owned by this ppid
|
|
431
|
+
for c in self.connection_manager.get_all_connections().values():
|
|
432
|
+
if c.interactive.active and c.interactive.is_owner(ctx.ppid):
|
|
433
|
+
conn = c
|
|
434
|
+
break
|
|
435
|
+
|
|
406
436
|
if not conn:
|
|
407
437
|
return {"stopped": False, "reason": "Not connected"}
|
|
408
438
|
|