serial-spy 1.0.0b0__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.
- serial_spy-1.0.0b0/PKG-INFO +11 -0
- serial_spy-1.0.0b0/README.md +100 -0
- serial_spy-1.0.0b0/serial_spy.egg-info/PKG-INFO +11 -0
- serial_spy-1.0.0b0/serial_spy.egg-info/SOURCES.txt +14 -0
- serial_spy-1.0.0b0/serial_spy.egg-info/dependency_links.txt +1 -0
- serial_spy-1.0.0b0/serial_spy.egg-info/requires.txt +1 -0
- serial_spy-1.0.0b0/serial_spy.egg-info/top_level.txt +1 -0
- serial_spy-1.0.0b0/serial_spy_api/__init__.py +3 -0
- serial_spy-1.0.0b0/serial_spy_api/base_monitor.py +169 -0
- serial_spy-1.0.0b0/serial_spy_api/serial_connection.py +152 -0
- serial_spy-1.0.0b0/serial_spy_api/serial_spy_api.py +31 -0
- serial_spy-1.0.0b0/serial_spy_api/tcp_connection.py +129 -0
- serial_spy-1.0.0b0/serial_spy_api/udp_connection.py +130 -0
- serial_spy-1.0.0b0/setup.cfg +4 -0
- serial_spy-1.0.0b0/setup.py +29 -0
- serial_spy-1.0.0b0/tests/test_serial_monitor.py +222 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serial-spy
|
|
3
|
+
Version: 1.0.0b0
|
|
4
|
+
Summary: Universal Telemetry & NMEA Monitor API
|
|
5
|
+
Author: Lucas H. M. Costa
|
|
6
|
+
Author-email: dacosta.lhm@gmail.com
|
|
7
|
+
Requires-Dist: pyserial>=3.5
|
|
8
|
+
Dynamic: author
|
|
9
|
+
Dynamic: author-email
|
|
10
|
+
Dynamic: requires-dist
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SERIAL SPY: Universal Telemetry & NMEA Monitor
|
|
2
|
+
|
|
3
|
+
## Current Version: 1.0.0-beta
|
|
4
|
+
|
|
5
|
+
SERIAL SPY has evolved into an advanced, modular, and highly scalable universal monitoring tool for Windows. Originally conceived as a strict serial monitor, it now boasts comprehensive support for network protocols, providing four distinct operational modes: an automated Command Line Interface (CLI), a graphical user interface (GUI), a fully decoupled Application Programming Interface (API) for seamless third-party integration, and a dedicated hardware-free simulation environment.
|
|
6
|
+
|
|
7
|
+
## Comprehensive Overview
|
|
8
|
+
|
|
9
|
+
This project delivers a robust, multi-protocol data acquisition and diagnostic platform, allowing engineers and developers to:
|
|
10
|
+
|
|
11
|
+
* **Establish Multi-Protocol Connections:** Configure traditional hardware serial communications or connect via modern network protocols by acting as a TCP Client or a UDP Server.
|
|
12
|
+
* **Monitor Live Telemetry:** Visualize incoming data streams (RX) in real-time, with automatic decoding of ASCII NMEA sentences and fallback hexadecimal representations.
|
|
13
|
+
* **Execute Bidirectional Communication:** Transmit (TX) standard text commands or dispatch precise hexadecimal byte sequences directly to the connected equipment.
|
|
14
|
+
* **Perform Multi-Format Auditing:** Persist event logs directly to the disk in text, CSV, or JSONL formats.
|
|
15
|
+
* **Operate via Intuitive Interfaces:** Utilize a lightweight, native Tkinter-based GUI for diagnostics, or leverage the parameterized terminal executable for headless server environments.
|
|
16
|
+
* **Integrate via OOP API:** Consume the monitoring engine natively within larger Python architectures.
|
|
17
|
+
|
|
18
|
+
## Distribution & Installation
|
|
19
|
+
|
|
20
|
+
The 1.0.0 release introduces compiled binaries, eliminating the need for end-users to install Python or manage environment dependencies.
|
|
21
|
+
|
|
22
|
+
* **For Desktop Users (GUI):** Download and run `SerialSpy_Setup.exe`. This standard Windows installer handles all background dependencies, directory structures, and creates a desktop shortcut with the official application icon.
|
|
23
|
+
* **For Sysadmins (CLI):** Download the standalone `serialspy.exe` binary. It is completely portable and can be executed natively from PowerShell or integrated into automated scripts.
|
|
24
|
+
* **For Developers (API):** The core classes can be distributed as pre-compiled bytecode (`.pyc`) or Cython-compiled dynamic extensions (`.pyd`) to protect the underlying source architecture.
|
|
25
|
+
|
|
26
|
+
## Mode 1: Automated Command Line Interface (CLI)
|
|
27
|
+
|
|
28
|
+
The CLI has been completely refactored to support direct argument parsing (`argparse`), making it ideal for automation, background services, and pipeline integration.
|
|
29
|
+
|
|
30
|
+
To launch the CLI, execute the portable binary with your required parameters:
|
|
31
|
+
|
|
32
|
+
```powershell
|
|
33
|
+
.\serialspy.exe --mode serial --port COM11 --baud 4800 --format jsonl --out data.jsonl
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Available Arguments:**
|
|
37
|
+
|
|
38
|
+
* \--mode: Connection protocol (serial, tcp, udp).
|
|
39
|
+
|
|
40
|
+
* \--port: Serial port (e.g., COM11) or Network port (e.g., 8080).
|
|
41
|
+
|
|
42
|
+
* \--baud, --bits, --parity, --stopbits, --timeout: Complete architecture control for serial connections.
|
|
43
|
+
|
|
44
|
+
* \--host: Target IP address for TCP/UDP connections.
|
|
45
|
+
|
|
46
|
+
* \--format and --out: Logging configuration (Text, CSV, JSON).
|
|
47
|
+
|
|
48
|
+
* \--silent: Hides stdout rendering for headless, resource-efficient background execution.
|
|
49
|
+
|
|
50
|
+
Graceful shutdown is fully supported. Pressing Ctrl+C safely flushes the disk buffers and releases the system ports.
|
|
51
|
+
|
|
52
|
+
Mode 2: Graphical User Interface (GUI)
|
|
53
|
+
--------------------------------------
|
|
54
|
+
|
|
55
|
+
The GUI provides a consolidated, single-window dashboard featuring a modern, flat-design dark mode (VS Code inspired).
|
|
56
|
+
|
|
57
|
+
* **Independent Multi-Threading:** Utilize the "+ New Connection" feature to open multiple tabs side-by-side. Each panel operates its own background thread, meaning you can monitor a UDP port and a Serial COM port simultaneously without blocking the UI.
|
|
58
|
+
|
|
59
|
+
***Dynamic Styling:** A native custom stylesheet replaces standard Windows elements with professional data-science aesthetics, featuring pure black terminal outputs for maximum contrast.
|
|
60
|
+
|
|
61
|
+
* **Embedded Documentation:** The interface includes a comprehensive rich-text Help menu and an About section displaying versioning and developer credits.
|
|
62
|
+
|
|
63
|
+
Mode 3: Application Programming Interface (API) Integration
|
|
64
|
+
-----------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
By importing the connection classes, developers can instantiate background monitors that feed data seamlessly into external databases.
|
|
67
|
+
|
|
68
|
+
**Implementation Example:**
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from serial_spy_api import SerialMonitor
|
|
72
|
+
def process_telemetry(event):
|
|
73
|
+
if event.direction == "RX":
|
|
74
|
+
print(f"[{event.timestamp_iso}] Sensor Payload: {event.payload}")
|
|
75
|
+
# The API runs asynchronously, decoupling data acquisition from the main thread
|
|
76
|
+
monitor = SerialMonitor( port="COM11", baudrate=4800, bytesize=8, parity='N', stopbits=1, timeout=1, print_output=False, event_callback=process_telemetry ) with monitor: monitor.send_text("$PMTK605*31") # Example NMEA query command
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Persistent Auditing & Logs
|
|
80
|
+
--------------------------
|
|
81
|
+
|
|
82
|
+
The storage engine is heavily optimized for zero-packet-loss high-frequency writes:
|
|
83
|
+
|
|
84
|
+
* **text:** Generates sequential lines optimized for tailing.
|
|
85
|
+
|
|
86
|
+
* **csv:** Comma-separated values strictly ordered.
|
|
87
|
+
|
|
88
|
+
* **json:** Adheres to the JSON Lines (JSONL) standard, emitting exactly one complete, independent JSON object per line.
|
|
89
|
+
|
|
90
|
+
Automated Unit Testing
|
|
91
|
+
----------------------
|
|
92
|
+
|
|
93
|
+
All structural tests reside in tests/test\_serial\_monitor.py and implement the unittest framework utilizing a FakeSerial mock class, eliminating the need for system-level hardware hooks during CI pipelines.
|
|
94
|
+
|
|
95
|
+
`PowerShell python -m unittest discover -s tests -p "test_*.py" -v`
|
|
96
|
+
|
|
97
|
+
Issues & Support
|
|
98
|
+
----------------
|
|
99
|
+
|
|
100
|
+
This project is currently under active development. Suggestions, issues, and bug reports can be directed to the primary developer: <dacosta.lhm@gmail.com>.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serial-spy
|
|
3
|
+
Version: 1.0.0b0
|
|
4
|
+
Summary: Universal Telemetry & NMEA Monitor API
|
|
5
|
+
Author: Lucas H. M. Costa
|
|
6
|
+
Author-email: dacosta.lhm@gmail.com
|
|
7
|
+
Requires-Dist: pyserial>=3.5
|
|
8
|
+
Dynamic: author
|
|
9
|
+
Dynamic: author-email
|
|
10
|
+
Dynamic: requires-dist
|
|
11
|
+
Dynamic: summary
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
setup.py
|
|
3
|
+
serial_spy.egg-info/PKG-INFO
|
|
4
|
+
serial_spy.egg-info/SOURCES.txt
|
|
5
|
+
serial_spy.egg-info/dependency_links.txt
|
|
6
|
+
serial_spy.egg-info/requires.txt
|
|
7
|
+
serial_spy.egg-info/top_level.txt
|
|
8
|
+
serial_spy_api/__init__.py
|
|
9
|
+
serial_spy_api/base_monitor.py
|
|
10
|
+
serial_spy_api/serial_connection.py
|
|
11
|
+
serial_spy_api/serial_spy_api.py
|
|
12
|
+
serial_spy_api/tcp_connection.py
|
|
13
|
+
serial_spy_api/udp_connection.py
|
|
14
|
+
tests/test_serial_monitor.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pyserial>=3.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
serial_spy_api
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Abstract core engine for the SERIAL_SPY project.
|
|
3
|
+
Objective: Manages event queues, thread synchronization, millisecond-precision
|
|
4
|
+
timestamp generation, file I/O operations (Text, CSV, JSONL), and generic
|
|
5
|
+
payload processing. It serves as the base class for all connection protocols.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
import csv
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import queue
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
from abc import ABC, abstractmethod
|
|
16
|
+
from dataclasses import asdict, dataclass
|
|
17
|
+
from datetime import datetime
|
|
18
|
+
from typing import Callable, List, Optional
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ConnectionEvent:
|
|
22
|
+
timestamp_iso: str
|
|
23
|
+
timestamp_ms: int
|
|
24
|
+
direction: str
|
|
25
|
+
payload: str
|
|
26
|
+
|
|
27
|
+
class BaseMonitor(ABC):
|
|
28
|
+
def __init__(self, log_file=None, log_format="text", print_output=True, event_callback: Optional[Callable[[ConnectionEvent], None]] = None):
|
|
29
|
+
self.log_file = log_file
|
|
30
|
+
self.log_format = log_format
|
|
31
|
+
self.print_output = print_output
|
|
32
|
+
self.is_running = False
|
|
33
|
+
self.file_obj = None
|
|
34
|
+
self.csv_writer = None
|
|
35
|
+
self.stop_event = threading.Event()
|
|
36
|
+
self.text_buffer = ""
|
|
37
|
+
self.write_lock = threading.Lock()
|
|
38
|
+
self.event_queue = queue.Queue()
|
|
39
|
+
self.event_callbacks: List[Callable[[ConnectionEvent], None]] = []
|
|
40
|
+
|
|
41
|
+
if event_callback is not None:
|
|
42
|
+
self.event_callbacks.append(event_callback)
|
|
43
|
+
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
return self.start()
|
|
46
|
+
|
|
47
|
+
def __exit__(self, exc_type, exc, tb):
|
|
48
|
+
self.stop()
|
|
49
|
+
|
|
50
|
+
def start_simulation(self):
|
|
51
|
+
self.is_running = True
|
|
52
|
+
self.stop_event.clear()
|
|
53
|
+
if self.log_file:
|
|
54
|
+
self._open_log_output()
|
|
55
|
+
if self.print_output:
|
|
56
|
+
print("Monitor started in simulation mode (no hardware required).")
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def emit_simulated_rx(self, payload):
|
|
60
|
+
self._emit_log("RX", payload)
|
|
61
|
+
|
|
62
|
+
def emit_simulated_tx(self, payload):
|
|
63
|
+
self._emit_log("TX", payload)
|
|
64
|
+
|
|
65
|
+
def _open_log_output(self):
|
|
66
|
+
if self.log_format == "csv":
|
|
67
|
+
file_exists = os.path.exists(self.log_file)
|
|
68
|
+
file_is_empty = (not file_exists) or os.path.getsize(self.log_file) == 0
|
|
69
|
+
self.file_obj = open(self.log_file, "a", encoding="utf-8", newline="")
|
|
70
|
+
self.csv_writer = csv.writer(self.file_obj)
|
|
71
|
+
if file_is_empty:
|
|
72
|
+
self.csv_writer.writerow(["timestamp_iso", "timestamp_ms", "direction", "payload"])
|
|
73
|
+
self.file_obj.flush()
|
|
74
|
+
return
|
|
75
|
+
self.file_obj = open(self.log_file, "a", encoding="utf-8")
|
|
76
|
+
|
|
77
|
+
def on_data_received(self, data: bytes):
|
|
78
|
+
try:
|
|
79
|
+
decoded_data = data.decode("ascii", errors="strict").replace("\r", "")
|
|
80
|
+
except UnicodeDecodeError:
|
|
81
|
+
if self.text_buffer:
|
|
82
|
+
self._emit_log("RX", self.text_buffer)
|
|
83
|
+
self.text_buffer = ""
|
|
84
|
+
self._emit_log("RX", data.hex(" ").upper())
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
self.text_buffer += decoded_data
|
|
88
|
+
lines = self.text_buffer.split("\n")
|
|
89
|
+
self.text_buffer = lines.pop()
|
|
90
|
+
|
|
91
|
+
for line in lines:
|
|
92
|
+
if line:
|
|
93
|
+
self._emit_log("RX", line)
|
|
94
|
+
|
|
95
|
+
def _current_timestamps(self):
|
|
96
|
+
now = datetime.now()
|
|
97
|
+
timestamp_iso = now.strftime("%Y-%m-%d %H:%M:%S.%f")[:-3]
|
|
98
|
+
timestamp_ms = int(now.timestamp() * 1000)
|
|
99
|
+
return timestamp_iso, timestamp_ms
|
|
100
|
+
|
|
101
|
+
def _emit_log(self, direction: str, payload: str):
|
|
102
|
+
timestamp_iso, timestamp_ms = self._current_timestamps()
|
|
103
|
+
event = ConnectionEvent(
|
|
104
|
+
timestamp_iso=timestamp_iso,
|
|
105
|
+
timestamp_ms=timestamp_ms,
|
|
106
|
+
direction=direction,
|
|
107
|
+
payload=payload,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
self.event_queue.put(event)
|
|
111
|
+
|
|
112
|
+
for callback in self.event_callbacks:
|
|
113
|
+
try:
|
|
114
|
+
callback(event)
|
|
115
|
+
except Exception as e:
|
|
116
|
+
if self.print_output:
|
|
117
|
+
print(f"Error in event callback: {e}")
|
|
118
|
+
|
|
119
|
+
if self.print_output:
|
|
120
|
+
print(f"[{timestamp_iso}] {direction}: {payload}")
|
|
121
|
+
|
|
122
|
+
if self.file_obj and not self.file_obj.closed:
|
|
123
|
+
try:
|
|
124
|
+
if self.log_format == "csv":
|
|
125
|
+
self.csv_writer.writerow(
|
|
126
|
+
[event.timestamp_iso, event.timestamp_ms, event.direction, event.payload]
|
|
127
|
+
)
|
|
128
|
+
elif self.log_format == "json":
|
|
129
|
+
self.file_obj.write(json.dumps(asdict(event), ensure_ascii=True) + "\n")
|
|
130
|
+
else:
|
|
131
|
+
self.file_obj.write(f"[{event.timestamp_iso}] {event.direction}: {event.payload}\n")
|
|
132
|
+
self.file_obj.flush()
|
|
133
|
+
except IOError as e:
|
|
134
|
+
if self.print_output:
|
|
135
|
+
print(f"File write error: {e}")
|
|
136
|
+
|
|
137
|
+
def add_event_callback(self, callback: Callable[[ConnectionEvent], None]):
|
|
138
|
+
self.event_callbacks.append(callback)
|
|
139
|
+
|
|
140
|
+
def get_event(self, timeout=None):
|
|
141
|
+
try:
|
|
142
|
+
return self.event_queue.get(timeout=timeout)
|
|
143
|
+
except queue.Empty:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def drain_events(self, max_items=None):
|
|
147
|
+
events = []
|
|
148
|
+
while max_items is None or len(events) < max_items:
|
|
149
|
+
event = self.get_event(timeout=0)
|
|
150
|
+
if event is None:
|
|
151
|
+
break
|
|
152
|
+
events.append(event)
|
|
153
|
+
return events
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
def start(self):
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
@abstractmethod
|
|
160
|
+
def stop(self):
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
@abstractmethod
|
|
164
|
+
def send_text(self, message: str):
|
|
165
|
+
pass
|
|
166
|
+
|
|
167
|
+
@abstractmethod
|
|
168
|
+
def send_hex(self, hex_string: str):
|
|
169
|
+
pass
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
|
|
2
|
+
"""
|
|
3
|
+
Purpose: Specialized hardware module for COM port communications.
|
|
4
|
+
Objective: Inherits from BaseMonitor to handle physical and virtual serial ports
|
|
5
|
+
using the 'pyserial' library. It manages hardware buffer states and continuous
|
|
6
|
+
background reading loops.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
import serial
|
|
12
|
+
from base_monitor import BaseMonitor, ConnectionEvent
|
|
13
|
+
|
|
14
|
+
class SerialConnection(BaseMonitor):
|
|
15
|
+
def __init__(self, port, baudrate, bytesize, parity, stopbits, timeout, tx_append_newline=True, serial_instance=None, **kwargs):
|
|
16
|
+
# Repassa log_file, log_format e event_callback para a classe BaseMonitor
|
|
17
|
+
super().__init__(**kwargs)
|
|
18
|
+
self.port = port
|
|
19
|
+
self.baudrate = baudrate
|
|
20
|
+
self.bytesize = bytesize
|
|
21
|
+
self.parity = parity
|
|
22
|
+
self.stopbits = stopbits
|
|
23
|
+
self.timeout = timeout
|
|
24
|
+
self.tx_append_newline = tx_append_newline
|
|
25
|
+
|
|
26
|
+
# Aceita a porta falsa injetada pelos testes unitarios
|
|
27
|
+
self.serial_instance = serial_instance
|
|
28
|
+
self.serial_port = None
|
|
29
|
+
self.read_thread = None
|
|
30
|
+
|
|
31
|
+
def start(self):
|
|
32
|
+
try:
|
|
33
|
+
# Se um FakeSerial foi passado (durante os testes), use-o.
|
|
34
|
+
# Caso contrario, inicie a biblioteca serial normalmente.
|
|
35
|
+
if self.serial_instance is not None:
|
|
36
|
+
self.serial_port = self.serial_instance
|
|
37
|
+
else:
|
|
38
|
+
self.serial_port = serial.Serial(
|
|
39
|
+
port=self.port,
|
|
40
|
+
baudrate=self.baudrate,
|
|
41
|
+
bytesize=self.bytesize,
|
|
42
|
+
parity=self.parity,
|
|
43
|
+
stopbits=self.stopbits,
|
|
44
|
+
timeout=self.timeout
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
self.is_running = True
|
|
48
|
+
self.stop_event.clear()
|
|
49
|
+
|
|
50
|
+
if self.log_file:
|
|
51
|
+
self._open_log_output()
|
|
52
|
+
|
|
53
|
+
# Inicia a thread exclusiva para leitura de hardware
|
|
54
|
+
self.read_thread = threading.Thread(target=self._read_serial, daemon=True)
|
|
55
|
+
self.read_thread.start()
|
|
56
|
+
|
|
57
|
+
if self.print_output:
|
|
58
|
+
print(f"Connected to {self.port} at {self.baudrate} baud. Config: {self.bytesize},{self.parity},{self.stopbits}")
|
|
59
|
+
except serial.SerialException as e:
|
|
60
|
+
raise RuntimeError(f"Error opening port {self.port}: {e}") from e
|
|
61
|
+
except IOError as e:
|
|
62
|
+
raise RuntimeError(f"Error creating log file: {e}") from e
|
|
63
|
+
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
def _read_serial(self):
|
|
67
|
+
if hasattr(self.serial_port, "reset_input_buffer"):
|
|
68
|
+
try:
|
|
69
|
+
self.serial_port.reset_input_buffer()
|
|
70
|
+
except Exception:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
while self.is_running and not self.stop_event.is_set():
|
|
74
|
+
try:
|
|
75
|
+
waiting = getattr(self.serial_port, "in_waiting", 0)
|
|
76
|
+
if callable(waiting):
|
|
77
|
+
waiting = waiting()
|
|
78
|
+
waiting = int(waiting)
|
|
79
|
+
|
|
80
|
+
if waiting > 0:
|
|
81
|
+
raw_data = self.serial_port.read(waiting)
|
|
82
|
+
if raw_data:
|
|
83
|
+
self.on_data_received(raw_data)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
if self.print_output:
|
|
86
|
+
print(f"\nCritical read error (hardware disconnected): {e}")
|
|
87
|
+
self.is_running = False
|
|
88
|
+
self.stop_event.set()
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
time.sleep(0.01)
|
|
92
|
+
|
|
93
|
+
def stop(self):
|
|
94
|
+
self.is_running = False
|
|
95
|
+
self.stop_event.set()
|
|
96
|
+
|
|
97
|
+
if self.text_buffer:
|
|
98
|
+
self._emit_log("RX", self.text_buffer)
|
|
99
|
+
self.text_buffer = ""
|
|
100
|
+
|
|
101
|
+
if self.read_thread and self.read_thread.is_alive():
|
|
102
|
+
self.read_thread.join(timeout=2)
|
|
103
|
+
if self.serial_port and getattr(self.serial_port, "is_open", False):
|
|
104
|
+
self.serial_port.close()
|
|
105
|
+
if self.file_obj and not self.file_obj.closed:
|
|
106
|
+
self.file_obj.close()
|
|
107
|
+
|
|
108
|
+
if self.print_output:
|
|
109
|
+
print("Operation stopped.")
|
|
110
|
+
|
|
111
|
+
def send_text(self, message: str):
|
|
112
|
+
if not self.serial_port or not getattr(self.serial_port, "is_open", True):
|
|
113
|
+
if self.print_output:
|
|
114
|
+
print("Serial port is not open for sending.")
|
|
115
|
+
return
|
|
116
|
+
|
|
117
|
+
payload = message
|
|
118
|
+
encoded = message.encode("utf-8", errors="replace")
|
|
119
|
+
if self.tx_append_newline:
|
|
120
|
+
encoded += b"\r\n"
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
with self.write_lock:
|
|
124
|
+
self.serial_port.write(encoded)
|
|
125
|
+
self._emit_log("TX", payload)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
if self.print_output:
|
|
128
|
+
print(f"Error sending data: {e}")
|
|
129
|
+
|
|
130
|
+
def send_hex(self, hex_string: str):
|
|
131
|
+
if not self.serial_port or not getattr(self.serial_port, "is_open", True):
|
|
132
|
+
if self.print_output:
|
|
133
|
+
print("Serial port is not open for sending.")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
cleaned = hex_string.replace(" ", "")
|
|
137
|
+
if len(cleaned) % 2 != 0:
|
|
138
|
+
if self.print_output:
|
|
139
|
+
print("Invalid HEX: character count must be even.")
|
|
140
|
+
return
|
|
141
|
+
|
|
142
|
+
try:
|
|
143
|
+
data = bytes.fromhex(cleaned)
|
|
144
|
+
with self.write_lock:
|
|
145
|
+
self.serial_port.write(data)
|
|
146
|
+
self._emit_log("TX", data.hex(" ").upper())
|
|
147
|
+
except ValueError:
|
|
148
|
+
if self.print_output:
|
|
149
|
+
print("Invalid HEX: use only 0-9 and A-F characters.")
|
|
150
|
+
except Exception as e:
|
|
151
|
+
if self.print_output:
|
|
152
|
+
print(f"Error sending HEX: {e}")
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: The API Facade of the SERIAL_SPY project.
|
|
3
|
+
Objective: Unifies the underlying connection modules and provides helper functions
|
|
4
|
+
(such as listing available COM ports) to ensure backward compatibility and
|
|
5
|
+
provide a clean entry point for third-party integrations.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import serial.tools.list_ports
|
|
9
|
+
|
|
10
|
+
# Importa a estrutura de eventos genérica
|
|
11
|
+
from base_monitor import ConnectionEvent
|
|
12
|
+
|
|
13
|
+
# Importa TODAS as conexões disponíveis no sistema
|
|
14
|
+
from serial_connection import SerialConnection as SerialMonitor
|
|
15
|
+
from tcp_connection import TCPConnection
|
|
16
|
+
from udp_connection import UDPConnection
|
|
17
|
+
|
|
18
|
+
# Mantém as funções utilitárias que listam as portas físicas ou virtuais
|
|
19
|
+
def get_serial_ports():
|
|
20
|
+
return sorted(serial.tools.list_ports.comports(), key=lambda p: p.device)
|
|
21
|
+
|
|
22
|
+
def list_serial_ports():
|
|
23
|
+
ports = get_serial_ports()
|
|
24
|
+
if not ports:
|
|
25
|
+
print("Nenhuma porta serial detectada automaticamente.")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
print("Portas seriais detectadas:")
|
|
29
|
+
for idx, port in enumerate(ports, start=1):
|
|
30
|
+
desc = port.description or "Sem descricao"
|
|
31
|
+
print(f" {idx}. {port.device} - {desc}")
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Specialized network module for TCP communications.
|
|
3
|
+
Objective: Acts as a TCP Client, establishing a connection-oriented socket stream
|
|
4
|
+
to a remote server. Inherits from BaseMonitor to process network bytes exactly
|
|
5
|
+
like hardware serial data.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from base_monitor import BaseMonitor, ConnectionEvent
|
|
11
|
+
|
|
12
|
+
class TCPConnection(BaseMonitor):
|
|
13
|
+
def __init__(self, host: str, port: int, tx_append_newline=True, **kwargs):
|
|
14
|
+
super().__init__(**kwargs)
|
|
15
|
+
self.host = host
|
|
16
|
+
self.port = port
|
|
17
|
+
self.tx_append_newline = tx_append_newline
|
|
18
|
+
|
|
19
|
+
self.tcp_socket = None
|
|
20
|
+
self.read_thread = None
|
|
21
|
+
|
|
22
|
+
def start(self):
|
|
23
|
+
try:
|
|
24
|
+
self.tcp_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
25
|
+
self.tcp_socket.settimeout(1.0)
|
|
26
|
+
self.tcp_socket.connect((self.host, self.port))
|
|
27
|
+
|
|
28
|
+
self.is_running = True
|
|
29
|
+
self.stop_event.clear()
|
|
30
|
+
|
|
31
|
+
if self.log_file:
|
|
32
|
+
self._open_log_output()
|
|
33
|
+
|
|
34
|
+
self.read_thread = threading.Thread(target=self._read_tcp, daemon=True)
|
|
35
|
+
self.read_thread.start()
|
|
36
|
+
|
|
37
|
+
if self.print_output:
|
|
38
|
+
print(f"Connected via TCP to host {self.host}:{self.port}")
|
|
39
|
+
except Exception as e:
|
|
40
|
+
raise RuntimeError(f"Error connecting via TCP to {self.host}:{self.port}: {e}") from e
|
|
41
|
+
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def _read_tcp(self):
|
|
45
|
+
while self.is_running and not self.stop_event.is_set():
|
|
46
|
+
try:
|
|
47
|
+
raw_data = self.tcp_socket.recv(4096)
|
|
48
|
+
if raw_data:
|
|
49
|
+
self.on_data_received(raw_data)
|
|
50
|
+
else:
|
|
51
|
+
if self.print_output:
|
|
52
|
+
print("\nTCP connection closed by remote server.")
|
|
53
|
+
self.is_running = False
|
|
54
|
+
self.stop_event.set()
|
|
55
|
+
break
|
|
56
|
+
except socket.timeout:
|
|
57
|
+
continue
|
|
58
|
+
except Exception as e:
|
|
59
|
+
if self.print_output:
|
|
60
|
+
print(f"\nCritical network read error: {e}")
|
|
61
|
+
self.is_running = False
|
|
62
|
+
self.stop_event.set()
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
def stop(self):
|
|
66
|
+
self.is_running = False
|
|
67
|
+
self.stop_event.set()
|
|
68
|
+
|
|
69
|
+
if self.text_buffer:
|
|
70
|
+
self._emit_log("RX", self.text_buffer)
|
|
71
|
+
self.text_buffer = ""
|
|
72
|
+
|
|
73
|
+
if self.read_thread and self.read_thread.is_alive():
|
|
74
|
+
self.read_thread.join(timeout=2)
|
|
75
|
+
|
|
76
|
+
if self.tcp_socket:
|
|
77
|
+
try:
|
|
78
|
+
self.tcp_socket.close()
|
|
79
|
+
except Exception:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
if self.file_obj and not self.file_obj.closed:
|
|
83
|
+
self.file_obj.close()
|
|
84
|
+
|
|
85
|
+
if self.print_output:
|
|
86
|
+
print("TCP operation stopped.")
|
|
87
|
+
|
|
88
|
+
def send_text(self, message: str):
|
|
89
|
+
if not self.tcp_socket:
|
|
90
|
+
if self.print_output:
|
|
91
|
+
print("TCP socket is not open for sending.")
|
|
92
|
+
return
|
|
93
|
+
|
|
94
|
+
payload = message
|
|
95
|
+
encoded = message.encode("utf-8", errors="replace")
|
|
96
|
+
if self.tx_append_newline:
|
|
97
|
+
encoded += b"\r\n"
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
with self.write_lock:
|
|
101
|
+
self.tcp_socket.sendall(encoded)
|
|
102
|
+
self._emit_log("TX", payload)
|
|
103
|
+
except Exception as e:
|
|
104
|
+
if self.print_output:
|
|
105
|
+
print(f"Error sending data via network: {e}")
|
|
106
|
+
|
|
107
|
+
def send_hex(self, hex_string: str):
|
|
108
|
+
if not self.tcp_socket:
|
|
109
|
+
if self.print_output:
|
|
110
|
+
print("TCP socket is not open for sending.")
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
cleaned = hex_string.replace(" ", "")
|
|
114
|
+
if len(cleaned) % 2 != 0:
|
|
115
|
+
if self.print_output:
|
|
116
|
+
print("Invalid HEX: character count must be even.")
|
|
117
|
+
return
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
data = bytes.fromhex(cleaned)
|
|
121
|
+
with self.write_lock:
|
|
122
|
+
self.tcp_socket.sendall(data)
|
|
123
|
+
self._emit_log("TX", data.hex(" ").upper())
|
|
124
|
+
except ValueError:
|
|
125
|
+
if self.print_output:
|
|
126
|
+
print("Invalid HEX: use only 0-9 and A-F characters.")
|
|
127
|
+
except Exception as e:
|
|
128
|
+
if self.print_output:
|
|
129
|
+
print(f"Error sending HEX via network: {e}")
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Specialized network module for UDP communications.
|
|
3
|
+
Objective: Acts as a passive UDP listener/server for datagram broadcasts.
|
|
4
|
+
It tracks the origin IP address of incoming packets to enable bidirectional
|
|
5
|
+
communication without a fixed connection state.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import socket
|
|
9
|
+
import threading
|
|
10
|
+
from base_monitor import BaseMonitor, ConnectionEvent
|
|
11
|
+
|
|
12
|
+
class UDPConnection(BaseMonitor):
|
|
13
|
+
def __init__(self, host: str, port: int, tx_append_newline=True, **kwargs):
|
|
14
|
+
super().__init__(**kwargs)
|
|
15
|
+
self.host = host
|
|
16
|
+
self.port = port
|
|
17
|
+
self.tx_append_newline = tx_append_newline
|
|
18
|
+
|
|
19
|
+
self.udp_socket = None
|
|
20
|
+
self.read_thread = None
|
|
21
|
+
# Armazena a origem do ultimo pacote para sabermos para onde enviar respostas
|
|
22
|
+
self.last_client_address = None
|
|
23
|
+
|
|
24
|
+
def start(self):
|
|
25
|
+
try:
|
|
26
|
+
self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
27
|
+
self.udp_socket.settimeout(1.0)
|
|
28
|
+
|
|
29
|
+
# Permite reutilizar a porta, evitando erros se o script for reiniciado rapidamente
|
|
30
|
+
self.udp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
31
|
+
self.udp_socket.bind((self.host, self.port))
|
|
32
|
+
|
|
33
|
+
self.is_running = True
|
|
34
|
+
self.stop_event.clear()
|
|
35
|
+
|
|
36
|
+
if self.log_file:
|
|
37
|
+
self._open_log_output()
|
|
38
|
+
|
|
39
|
+
self.read_thread = threading.Thread(target=self._read_udp, daemon=True)
|
|
40
|
+
self.read_thread.start()
|
|
41
|
+
|
|
42
|
+
if self.print_output:
|
|
43
|
+
print(f"Escutando pacotes UDP em {self.host}:{self.port}")
|
|
44
|
+
except Exception as e:
|
|
45
|
+
raise RuntimeError(f"Erro ao abrir porta UDP {self.host}:{self.port}: {e}") from e
|
|
46
|
+
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def _read_udp(self):
|
|
50
|
+
while self.is_running and not self.stop_event.is_set():
|
|
51
|
+
try:
|
|
52
|
+
# No UDP, recvfrom entrega os bytes e o endereco (IP, Porta) da origem
|
|
53
|
+
raw_data, address = self.udp_socket.recvfrom(4096)
|
|
54
|
+
if raw_data:
|
|
55
|
+
self.last_client_address = address
|
|
56
|
+
self.on_data_received(raw_data)
|
|
57
|
+
except socket.timeout:
|
|
58
|
+
continue
|
|
59
|
+
except Exception as e:
|
|
60
|
+
if self.print_output:
|
|
61
|
+
print(f"\nErro critico de leitura na rede UDP: {e}")
|
|
62
|
+
self.is_running = False
|
|
63
|
+
self.stop_event.set()
|
|
64
|
+
break
|
|
65
|
+
|
|
66
|
+
def stop(self):
|
|
67
|
+
self.is_running = False
|
|
68
|
+
self.stop_event.set()
|
|
69
|
+
|
|
70
|
+
if self.text_buffer:
|
|
71
|
+
self._emit_log("RX", self.text_buffer)
|
|
72
|
+
self.text_buffer = ""
|
|
73
|
+
|
|
74
|
+
if self.read_thread and self.read_thread.is_alive():
|
|
75
|
+
self.read_thread.join(timeout=2)
|
|
76
|
+
|
|
77
|
+
if self.udp_socket:
|
|
78
|
+
try:
|
|
79
|
+
self.udp_socket.close()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
if self.file_obj and not self.file_obj.closed:
|
|
84
|
+
self.file_obj.close()
|
|
85
|
+
|
|
86
|
+
if self.print_output:
|
|
87
|
+
print("Operacao UDP encerrada.")
|
|
88
|
+
|
|
89
|
+
def send_text(self, message: str):
|
|
90
|
+
if not self.udp_socket or not self.last_client_address:
|
|
91
|
+
if self.print_output:
|
|
92
|
+
print("UDP indisponivel ou nenhum destino mapeado (aguarde receber dados primeiro).")
|
|
93
|
+
return
|
|
94
|
+
|
|
95
|
+
payload = message
|
|
96
|
+
encoded = message.encode("utf-8", errors="replace")
|
|
97
|
+
if self.tx_append_newline:
|
|
98
|
+
encoded += b"\r\n"
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
with self.write_lock:
|
|
102
|
+
self.udp_socket.sendto(encoded, self.last_client_address)
|
|
103
|
+
self._emit_log("TX", payload)
|
|
104
|
+
except Exception as e:
|
|
105
|
+
if self.print_output:
|
|
106
|
+
print(f"Erro ao enviar dados via UDP: {e}")
|
|
107
|
+
|
|
108
|
+
def send_hex(self, hex_string: str):
|
|
109
|
+
if not self.udp_socket or not self.last_client_address:
|
|
110
|
+
if self.print_output:
|
|
111
|
+
print("UDP indisponivel ou destino nao mapeado.")
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
cleaned = hex_string.replace(" ", "")
|
|
115
|
+
if len(cleaned) % 2 != 0:
|
|
116
|
+
if self.print_output:
|
|
117
|
+
print("HEX invalido: a quantidade de caracteres deve ser par.")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
data = bytes.fromhex(cleaned)
|
|
122
|
+
with self.write_lock:
|
|
123
|
+
self.udp_socket.sendto(data, self.last_client_address)
|
|
124
|
+
self._emit_log("TX", data.hex(" ").upper())
|
|
125
|
+
except ValueError:
|
|
126
|
+
if self.print_output:
|
|
127
|
+
print("HEX invalido: use apenas caracteres 0-9 e A-F.")
|
|
128
|
+
except Exception as e:
|
|
129
|
+
if self.print_output:
|
|
130
|
+
print(f"Erro ao enviar HEX via UDP: {e}")
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from setuptools import setup, Distribution
|
|
2
|
+
|
|
3
|
+
# Força o setuptools a tratar o pacote como binário pré-compilado (Wheel não-puro)
|
|
4
|
+
class BinaryDistribution(Distribution):
|
|
5
|
+
def has_ext_modules(self):
|
|
6
|
+
return True
|
|
7
|
+
|
|
8
|
+
setup(
|
|
9
|
+
name="serial-spy",
|
|
10
|
+
version="1.0.0-beta",
|
|
11
|
+
description="Universal Telemetry & NMEA Monitor API",
|
|
12
|
+
author="Lucas H. M. Costa",
|
|
13
|
+
author_email="dacosta.lhm@gmail.com",
|
|
14
|
+
|
|
15
|
+
# Define o nome da pasta onde seus arquivos compilados vão ficar
|
|
16
|
+
packages=["serial_spy_api"],
|
|
17
|
+
|
|
18
|
+
# Instrui o pip a baixar dependências externas automaticamente
|
|
19
|
+
install_requires=[
|
|
20
|
+
"pyserial>=3.5"
|
|
21
|
+
],
|
|
22
|
+
|
|
23
|
+
# Garante que ele empacote os binários e ignore código fonte solto
|
|
24
|
+
package_data={
|
|
25
|
+
"serial_spy_api": ["*.pyc", "*.pyd"],
|
|
26
|
+
},
|
|
27
|
+
include_package_data=True,
|
|
28
|
+
distclass=BinaryDistribution
|
|
29
|
+
)
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Purpose: Automated unit testing suite.
|
|
3
|
+
Objective: Validates thread lifecycles, file formatting (JSONL/CSV), and data
|
|
4
|
+
fragmentation handling using a mocked 'FakeSerial' instance, ensuring the
|
|
5
|
+
architecture remains stable without requiring physical hardware.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import csv
|
|
11
|
+
import json
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import serial
|
|
16
|
+
|
|
17
|
+
# Força o Python a enxergar a pasta raiz para importar a API corretamente
|
|
18
|
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
|
19
|
+
|
|
20
|
+
from serial_spy_api import SerialMonitor
|
|
21
|
+
|
|
22
|
+
class FakeSerial:
|
|
23
|
+
def __init__(self):
|
|
24
|
+
self.is_open = True
|
|
25
|
+
self.written = []
|
|
26
|
+
self._in_waiting = 0
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def in_waiting(self):
|
|
30
|
+
return self._in_waiting
|
|
31
|
+
|
|
32
|
+
def read(self, size):
|
|
33
|
+
return b""
|
|
34
|
+
|
|
35
|
+
def write(self, data):
|
|
36
|
+
self.written.append(data)
|
|
37
|
+
return len(data)
|
|
38
|
+
|
|
39
|
+
def reset_input_buffer(self):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
def close(self):
|
|
43
|
+
self.is_open = False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SerialMonitorTests(unittest.TestCase):
|
|
47
|
+
def _create_monitor(self, **overrides):
|
|
48
|
+
base = {
|
|
49
|
+
"port": "COM_TEST",
|
|
50
|
+
"baudrate": 115200,
|
|
51
|
+
"bytesize": serial.EIGHTBITS,
|
|
52
|
+
"parity": serial.PARITY_NONE,
|
|
53
|
+
"stopbits": serial.STOPBITS_ONE,
|
|
54
|
+
"timeout": 1,
|
|
55
|
+
"log_file": None,
|
|
56
|
+
"log_format": "text",
|
|
57
|
+
"tx_append_newline": True,
|
|
58
|
+
"print_output": False,
|
|
59
|
+
}
|
|
60
|
+
base.update(overrides)
|
|
61
|
+
return SerialMonitor(**base)
|
|
62
|
+
|
|
63
|
+
def test_start_and_stop_with_fake_serial_instance(self):
|
|
64
|
+
fake = FakeSerial()
|
|
65
|
+
monitor = self._create_monitor(serial_instance=fake)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
monitor.start()
|
|
69
|
+
self.assertTrue(monitor.is_running)
|
|
70
|
+
self.assertTrue(monitor.read_thread.is_alive())
|
|
71
|
+
finally:
|
|
72
|
+
monitor.stop()
|
|
73
|
+
|
|
74
|
+
self.assertFalse(monitor.is_running)
|
|
75
|
+
self.assertFalse(fake.is_open)
|
|
76
|
+
|
|
77
|
+
def test_send_text_appends_crlf_and_logs_tx_event(self):
|
|
78
|
+
fake = FakeSerial()
|
|
79
|
+
monitor = self._create_monitor(serial_instance=fake, tx_append_newline=True)
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
monitor.start()
|
|
83
|
+
monitor.send_text("HELLO")
|
|
84
|
+
finally:
|
|
85
|
+
monitor.stop()
|
|
86
|
+
|
|
87
|
+
self.assertEqual(fake.written[-1], b"HELLO\r\n")
|
|
88
|
+
events = monitor.drain_events()
|
|
89
|
+
tx_events = [e for e in events if e.direction == "TX"]
|
|
90
|
+
self.assertEqual(len(tx_events), 1)
|
|
91
|
+
self.assertEqual(tx_events[0].payload, "HELLO")
|
|
92
|
+
|
|
93
|
+
def test_on_data_received_buffers_partial_lines(self):
|
|
94
|
+
monitor = self._create_monitor()
|
|
95
|
+
try:
|
|
96
|
+
monitor.start_simulation()
|
|
97
|
+
monitor.on_data_received(b"abc")
|
|
98
|
+
self.assertEqual(monitor.drain_events(), [])
|
|
99
|
+
monitor.on_data_received(b"123\nxyz\n")
|
|
100
|
+
finally:
|
|
101
|
+
monitor.stop()
|
|
102
|
+
|
|
103
|
+
events = monitor.drain_events()
|
|
104
|
+
rx_payloads = [e.payload for e in events if e.direction == "RX"]
|
|
105
|
+
self.assertIn("abc123", rx_payloads)
|
|
106
|
+
self.assertIn("xyz", rx_payloads)
|
|
107
|
+
|
|
108
|
+
def test_non_ascii_payload_is_logged_as_hex(self):
|
|
109
|
+
monitor = self._create_monitor()
|
|
110
|
+
try:
|
|
111
|
+
monitor.start_simulation()
|
|
112
|
+
monitor.on_data_received(bytes([0xFF, 0x01, 0xAA]))
|
|
113
|
+
finally:
|
|
114
|
+
monitor.stop()
|
|
115
|
+
|
|
116
|
+
events = monitor.drain_events()
|
|
117
|
+
rx_events = [e for e in events if e.direction == "RX"]
|
|
118
|
+
self.assertEqual(len(rx_events), 1)
|
|
119
|
+
self.assertEqual(rx_events[0].payload, "FF 01 AA")
|
|
120
|
+
|
|
121
|
+
def test_json_log_format_writes_jsonl_events(self):
|
|
122
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
123
|
+
log_path = Path(tmp_dir) / "events.jsonl"
|
|
124
|
+
monitor = self._create_monitor(log_file=str(log_path), log_format="json")
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
monitor.start_simulation()
|
|
128
|
+
monitor.emit_simulated_rx("RX_SAMPLE")
|
|
129
|
+
monitor.emit_simulated_tx("TX_SAMPLE")
|
|
130
|
+
finally:
|
|
131
|
+
# O finally garante que o arquivo seja fechado ANTES do tmp_dir ser deletado
|
|
132
|
+
monitor.stop()
|
|
133
|
+
|
|
134
|
+
lines = log_path.read_text(encoding="utf-8").strip().splitlines()
|
|
135
|
+
self.assertEqual(len(lines), 2)
|
|
136
|
+
payloads = []
|
|
137
|
+
for line in lines:
|
|
138
|
+
entry = json.loads(line)
|
|
139
|
+
self.assertIn("timestamp_iso", entry)
|
|
140
|
+
self.assertIn("timestamp_ms", entry)
|
|
141
|
+
self.assertIn("direction", entry)
|
|
142
|
+
self.assertIn("payload", entry)
|
|
143
|
+
payloads.append(entry["payload"])
|
|
144
|
+
self.assertEqual(payloads, ["RX_SAMPLE", "TX_SAMPLE"])
|
|
145
|
+
|
|
146
|
+
def test_csv_log_format_writes_header_once_and_rows(self):
|
|
147
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
148
|
+
log_path = Path(tmp_dir) / "events.csv"
|
|
149
|
+
monitor = self._create_monitor(log_file=str(log_path), log_format="csv")
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
monitor.start_simulation()
|
|
153
|
+
monitor.emit_simulated_rx("ROW1")
|
|
154
|
+
monitor.emit_simulated_tx("ROW2")
|
|
155
|
+
finally:
|
|
156
|
+
monitor.stop()
|
|
157
|
+
|
|
158
|
+
with log_path.open("r", encoding="utf-8", newline="") as f:
|
|
159
|
+
rows = list(csv.reader(f))
|
|
160
|
+
|
|
161
|
+
self.assertEqual(rows[0], ["timestamp_iso", "timestamp_ms", "direction", "payload"])
|
|
162
|
+
self.assertEqual(rows[1][2:], ["RX", "ROW1"])
|
|
163
|
+
self.assertEqual(rows[2][2:], ["TX", "ROW2"])
|
|
164
|
+
def test_generate_persistent_execution_log(self):
|
|
165
|
+
"""Gera log da execucao do teste E salva os dados reais do simulate_serial.py"""
|
|
166
|
+
import subprocess
|
|
167
|
+
import sys
|
|
168
|
+
import os
|
|
169
|
+
from datetime import datetime
|
|
170
|
+
|
|
171
|
+
# Define caminhos absolutos na raiz do projeto
|
|
172
|
+
script_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'simulate_serial.py'))
|
|
173
|
+
data_log_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'test_data_output.jsonl'))
|
|
174
|
+
test_log_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'test_execution_steps.log'))
|
|
175
|
+
|
|
176
|
+
# Limpa os logs antigos para um teste limpo
|
|
177
|
+
for path in [data_log_path, test_log_path]:
|
|
178
|
+
if os.path.exists(path):
|
|
179
|
+
os.remove(path)
|
|
180
|
+
|
|
181
|
+
# Funcao auxiliar para gravar as acoes do teste
|
|
182
|
+
def log_step(msg):
|
|
183
|
+
with open(test_log_path, "a", encoding="utf-8") as f:
|
|
184
|
+
f.write(f"[{datetime.now().isoformat()}] {msg}\n")
|
|
185
|
+
|
|
186
|
+
log_step("=== INICIANDO TESTE DE CAPTURA REAL ===")
|
|
187
|
+
|
|
188
|
+
# Cria o monitor configurado para salvar os dados em JSONL
|
|
189
|
+
monitor = self._create_monitor(log_file=data_log_path, log_format="json")
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
log_step("Monitor instanciado no modo simulacao (offline).")
|
|
193
|
+
monitor.start_simulation()
|
|
194
|
+
|
|
195
|
+
log_step(f"Iniciando execucao em background do script: {script_path}")
|
|
196
|
+
# Roda o seu simulador e captura tudo que ele imprimir na tela
|
|
197
|
+
resultado = subprocess.run([sys.executable, script_path], capture_output=True, text=True)
|
|
198
|
+
|
|
199
|
+
self.assertEqual(resultado.returncode, 0, f"O script simulador falhou: {resultado.stderr}")
|
|
200
|
+
linhas = resultado.stdout.strip().splitlines()
|
|
201
|
+
|
|
202
|
+
log_step(f"Sucesso. {len(linhas)} linhas de dados foram geradas pelo simulador.")
|
|
203
|
+
log_step("Injetando dados capturados no SerialMonitor...")
|
|
204
|
+
|
|
205
|
+
for linha in linhas:
|
|
206
|
+
if linha.strip():
|
|
207
|
+
# Pega a linha real gerada pelo script e injeta na API
|
|
208
|
+
monitor.emit_simulated_rx(linha)
|
|
209
|
+
|
|
210
|
+
log_step("Injecao de telemetria concluida.")
|
|
211
|
+
except Exception as e:
|
|
212
|
+
log_step(f"FALHA CRITICA NO TESTE: {str(e)}")
|
|
213
|
+
raise
|
|
214
|
+
finally:
|
|
215
|
+
log_step("Encerrando monitor e salvando arquivos em disco.")
|
|
216
|
+
monitor.stop()
|
|
217
|
+
|
|
218
|
+
# Garante que os DOIS arquivos estao no seu computador
|
|
219
|
+
self.assertTrue(os.path.exists(data_log_path), "Arquivo JSONL nao gerado.")
|
|
220
|
+
self.assertTrue(os.path.exists(test_log_path), "Log de passos do teste nao gerado.")
|
|
221
|
+
if __name__ == "__main__":
|
|
222
|
+
unittest.main()
|