""" COM Port Test Writer - Simulates data being sent to a COM port """ import serial import time import random import logging import sys from typing import Optional from config import ( COM_PORT, BAUD_RATE, TIMEOUT, WRITE_INTERVAL, TEST_DATA_TYPE, EMPTY_MIN, EMPTY_MAX, FULL_MIN, FULL_MAX, IDLE_SECONDS, RAMP_SECONDS, STABLE_SECONDS, DROP_SECONDS, DIVISION_SMALL, DIVISION_LARGE, DIVISION_THRESHOLD, DEBUG ) # Setup logging logging.basicConfig( level=logging.DEBUG if DEBUG else logging.INFO, # format='%(asctime)s - %(levelname)s - %(message)s' format='%(message)s' ) logger = logging.getLogger(__name__) class ComPortTestWriter: def __init__(self, port: str, baudrate: int = 9600, timeout: int = 1): self.port = port self.baudrate = baudrate self.timeout = timeout self.serial_conn: Optional[serial.Serial] = None self.is_connected = False self.is_running = False self.data_counter = 0 # Weighing cycle state self.phase = 'idle' self.phase_started = time.monotonic() self.loaded = False # alternates: empty truck, then full truck self.target = self._pick_target() self.phase_start_value = 0.0 def connect(self) -> bool: """Connect to the serial port""" try: self.serial_conn = serial.Serial( port=self.port, baudrate=self.baudrate, timeout=self.timeout ) self.is_connected = True logger.info(f"✓ Connected to {self.port} at {self.baudrate} baud") return True except serial.SerialException as e: logger.error(f"✗ Failed to connect to {self.port}: {e}") logger.info("\nTip: Check that the COM port exists and is not in use.") logger.info("You can list available ports with: python -m serial.tools.list_ports") self.is_connected = False return False def disconnect(self): """Disconnect from the serial port""" if self.serial_conn and self.serial_conn.is_open: self.serial_conn.close() self.is_connected = False logger.info(f"✓ Disconnected from {self.port}") def write_data(self, data: str) -> bool: """Write data to the serial port""" try: if self.is_connected and self.serial_conn: # Add newline if not present if not data.endswith('\n'): data += '\n' self.serial_conn.write(data.encode('utf-8')) logger.info(f"→ Sent: {data.strip()}") return True except Exception as e: logger.error(f"Error writing to {self.port}: {e}") self.is_connected = False return False @staticmethod def _quantise(value: float) -> int: """Snap a weight to the scale's division. Truck scales are multi-interval: they read in DIVISION_SMALL steps up to DIVISION_THRESHOLD and DIVISION_LARGE steps above it. Nothing in between is displayable. """ if value <= 0: return 0 division = DIVISION_SMALL if value < DIVISION_THRESHOLD else DIVISION_LARGE # floor(x + 0.5), not round() - Python rounds halves to even, so a reading # exactly between two divisions would sometimes snap down. return int((value + division / 2) // division) * division def _pick_target(self) -> int: """Pick the weight for the next truck on the scale.""" if self.loaded: return self._quantise(random.randint(FULL_MIN, FULL_MAX)) return self._quantise(random.randint(EMPTY_MIN, EMPTY_MAX)) def _advance_phase(self): """Move to the next phase of the weighing cycle.""" if self.phase == 'idle': self.phase = 'ramp' elif self.phase == 'ramp': self.phase = 'stable' elif self.phase == 'stable': self.phase = 'drop' else: # Truck left the scale - next one is the other kind self.phase = 'idle' self.loaded = not self.loaded self.target = self._pick_target() self.phase_started = time.monotonic() def generate_scales_data(self) -> str: """Simulate a truck being weighed: idle at 0, drive on, settle, drive off. Emits " kg ST" while the reading is stable (balanced), "US" otherwise, mirroring how real indicators report stability. """ durations = { 'idle': IDLE_SECONDS, 'ramp': RAMP_SECONDS, 'stable': STABLE_SECONDS, 'drop': DROP_SECONDS, } # Roll over any fully elapsed phases first, so the value below is always # computed from the phase it actually belongs to. elapsed = time.monotonic() - self.phase_started while elapsed >= durations[self.phase]: self._advance_phase() elapsed = 0.0 duration = durations[self.phase] progress = min(1.0, elapsed / duration) if duration > 0 else 1.0 if self.phase == 'idle': value, balanced = 0.0, False elif self.phase == 'ramp': # Climbing under load - noisy and unstable value = self.target * min(1.0, progress + 0.05) + random.uniform(-40, 40) value = max(0.0, value) balanced = False elif self.phase == 'stable': # Settled on the target; drift is small enough that it usually rests # on one division and only occasionally flickers to the next. value = self.target + random.uniform(-12, 12) balanced = True else: # drop value = self.target * (1.0 - progress) + random.uniform(-40, 40) value = max(0.0, value) balanced = False return f"{self._quantise(value)} kg {'ST' if balanced else 'US'}" def generate_counter_data(self) -> str: """Generate incrementing counter data""" self.data_counter += 1 return f"Count: {self.data_counter}" def generate_random_data(self) -> str: """Generate random numeric data""" value = random.uniform(10000, 50000) return f"Value: {value:.2f}" def generate_mixed_data(self) -> str: """Generate mixed sensor data""" temperature = random.uniform(20, 30) humidity = random.uniform(30, 70) pressure = random.uniform(1010, 1020) return f"T:{temperature:.1f}C H:{humidity:.1f}% P:{pressure:.1f}hPa" def get_next_data(self) -> str: """Get the next data to send based on test type""" if TEST_DATA_TYPE == 'scales': return self.generate_scales_data() elif TEST_DATA_TYPE == 'counter': return self.generate_counter_data() elif TEST_DATA_TYPE == 'random': return self.generate_random_data() elif TEST_DATA_TYPE == 'mixed': return self.generate_mixed_data() else: return self.generate_scales_data() def run(self): """Run the test writer""" logger.info(f"\n{'='*50}") logger.info(f"COM Port Test Writer") logger.info(f"{'='*50}") logger.info(f"Port: {self.port}") logger.info(f"Baud Rate: {self.baudrate}") logger.info(f"Data Type: {TEST_DATA_TYPE}") logger.info(f"Write Interval: {WRITE_INTERVAL}s") if TEST_DATA_TYPE == 'scales': logger.info(f"Cycle: idle {IDLE_SECONDS}s -> ramp {RAMP_SECONDS}s -> stable {STABLE_SECONDS}s -> drop {DROP_SECONDS}s") logger.info(f"Empty truck: {EMPTY_MIN}-{EMPTY_MAX} kg | Full truck: {FULL_MIN}-{FULL_MAX} kg") logger.info(f"{'='*50}\n") if not self.connect(): logger.error("Failed to connect to COM port. Exiting.") return self.is_running = True try: logger.info("Press Ctrl+C to stop writing data\n") while self.is_running: try: data = self.get_next_data() self.write_data(data) time.sleep(WRITE_INTERVAL) except KeyboardInterrupt: break except Exception as e: logger.error(f"Error in write loop: {e}") break except KeyboardInterrupt: pass finally: self.is_running = False self.disconnect() logger.info("\n✓ Test writer stopped") def list_available_ports(): """List available COM ports""" try: from serial.tools import list_ports ports = list_ports.comports() if ports: logger.info("\nAvailable COM ports:") for port in ports: logger.info(f" {port.device} - {port.description}") else: logger.info("No COM ports found") except Exception as e: logger.error(f"Error listing ports: {e}") def main(): """Main entry point""" import argparse parser = argparse.ArgumentParser( description='Test COM port writer - continuously writes data to a COM port' ) parser.add_argument( '--port', default=COM_PORT, help=f'COM port to write to (default: {COM_PORT})' ) parser.add_argument( '--baud', type=int, default=BAUD_RATE, help=f'Baud rate (default: {BAUD_RATE})' ) parser.add_argument( '--type', choices=['scales', 'counter', 'random', 'mixed'], default=TEST_DATA_TYPE, help=f'Type of test data (default: {TEST_DATA_TYPE})' ) parser.add_argument( '--interval', type=float, default=WRITE_INTERVAL, help=f'Write interval in seconds (default: {WRITE_INTERVAL})' ) parser.add_argument( '--list', action='store_true', help='List available COM ports and exit' ) args = parser.parse_args() if args.list: list_available_ports() return writer = ComPortTestWriter(args.port, args.baud) writer.run() if __name__ == '__main__': main()