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.
@@ -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
+ pyserial>=3.5
@@ -0,0 +1 @@
1
+ serial_spy_api
@@ -0,0 +1,3 @@
1
+ from .serial_connection import SerialConnection as SerialMonitor
2
+ from .tcp_connection import TCPConnection
3
+ from .udp_connection import UDPConnection
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()