Files
coin-counter-simulators/source/selex.py
2025-11-10 09:11:56 +01:00

510 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Selex Device Simulator
Simulates a Selex coin counting device (MIX-RED, MOON, MOON2000, CARRY & COUNT, ORION)
for development/testing purposes.
Implements the RS232 Communication Protocol MC-SELEX v2.2
"""
import random
import time
from typing import Optional
import serial
from loguru import logger
from .common import format_comm_debug
# Protocol constants
STX = ord("s") # 0x73
ETX = ord("e") # 0x65
CRC = ord("v") # 0x76
CAN = ord("c") # 0x63
ACK = ord("a") # 0x61
EOT = ord("t") # 0x74
# Machine states
STATE_IDLE = "01" # Machine stops with finished counting
STATE_NO_MONEY = "02" # Machine stops for lack of money
STATE_COUNTING = "03" # Machine counting process
STATE_ERROR = "04" # Machine stops for error
STATE_BATCHING = "07" # Machine in batching mode
STATE_STOPPED_HOST = "08" # Machine stopped by host
STATE_STOPPED_KEYBOARD = "09" # Machine stopped by keyboard
# Error codes
ERROR_NONE = "10"
ERROR_DISK_LOCKED_OVERLAP = "11"
ERROR_DISK_LOCKED_MOTOR = "12"
ERROR_LED_SETTING = "13"
ERROR_REJECTED_EXCESS = "14"
ERROR_DEJAM_FAILED = "15"
ERROR_COINS_UNDER_CCD = "17"
ERROR_DOORS_OPEN = "18"
ERROR_CCD_COMM = "19"
class SelexSimulator:
def __init__(self, port: str, baudrate: int = 9600):
self.port = port
self.baudrate = baudrate
self.serial_conn: Optional[serial.Serial] = None
# Device state
self.machine_state = STATE_IDLE
self.error_code = ERROR_NONE
self.has_rejected_coins = False
self.is_counting = False
# 8 counting lines
self.num_lines = 8
# Coin counters for each line (8 lines)
self.actual_counts = [0] * self.num_lines # Current batch
self.partial_totals = [0] * self.num_lines # Accumulated totals
self.batch_values = [0] * self.num_lines # Batch thresholds
self.extra_coins = [0] * self.num_lines # Extra coins
# Rejected coins counter
self.rejected_coins = 0
# Line enable/disable status
self.lines_enabled = [True] * self.num_lines
# Batching control
self.batching_enabled = True
# Software version
self.sw_version = "2.2.0"
self.serial_number = "0001234567"
def create_message(self, data: bytes) -> bytes:
"""Create a message with STX, data, CRC placeholder, and ETX"""
return bytes([STX]) + data + bytes([CRC, ETX])
def parse_message(self, message: bytes) -> Optional[bytes]:
"""Parse and validate a received message"""
if len(message) < 4:
logger.error("Message too short")
return None
if message[0] != STX:
logger.error("Invalid STX")
return None
if message[-1] != ETX:
logger.error("Invalid ETX")
return None
# Extract data (skip STX, CRC, ETX)
data = message[1:-2]
return data
def handle_status_request(self, data: bytes) -> bytes:
"""Handle machine status request (s100ve)"""
# Format: s101<STATUS><ERROR>ve
status_str = self.machine_state
if self.has_rejected_coins:
# Add 10 to first digit to indicate rejected coins
status_str = str(int(status_str[0]) + 1) + status_str[1]
response = b"101" + status_str.encode() + self.error_code.encode()
logger.info(
f"Status request: state={self.machine_state}, error={self.error_code}, rejected={self.has_rejected_coins}"
)
return self.create_message(response)
def handle_start_counting(self, data: bytes) -> bytes:
"""Handle counting start command (s200ve)"""
logger.info("Starting new counting session - resetting actual counts")
self.actual_counts = [0] * self.num_lines
self.rejected_coins = 0
self.has_rejected_coins = False
self.machine_state = STATE_COUNTING
self.is_counting = True
# Simulate coin counting
self._simulate_counting()
# After counting simulation, machine stops
self.machine_state = STATE_IDLE
self.is_counting = False
return bytes([ACK])
def handle_stop_counting(self, data: bytes) -> bytes:
"""Handle counting stop command (s800ve)"""
logger.info("Stopping counting")
self.machine_state = STATE_STOPPED_HOST
self.is_counting = False
return bytes([ACK])
def handle_main_total_reset(self, data: bytes) -> bytes:
"""Handle main total reset (s300ve)"""
logger.info("Resetting main total (not used in this simulator)")
return bytes([ACK])
def write_to_serial(self, data: bytes) -> None:
logger.debug(format_comm_debug("TX", data, include_ascii=True))
self.serial_conn.write(data)
def handle_actual_counters_request(self, data: bytes) -> bytes:
"""Handle actual counting counters request (s400ve or sc00ve for checksum)"""
cmd_char = chr(data[0]) if len(data) > 0 else "4"
with_checksum = cmd_char == "c"
logger.info(f"Actual counters request (checksum={with_checksum})")
# For simulator: if no counts yet, simulate counting
if sum(self.actual_counts) == 0:
logger.info("No counts yet - simulating counting for testing")
self._simulate_counting()
# First ACK
ack_msg = bytes([ACK])
self.write_to_serial(ack_msg)
time.sleep(0.01)
# Send each line's count
for line_num in range(1, self.num_lines + 1):
count = self.actual_counts[line_num - 1]
cmd = b"c" if with_checksum else b"4"
response = cmd + b"0" + f"{line_num:02d}{count:06d}".encode()
response_msg = self.create_message(response)
self.write_to_serial(response_msg)
logger.info(f" Line {line_num}: {count} coins")
time.sleep(0.01)
# Wait for ACK
ack = self.serial_conn.read(1)
# Send checksum if requested
if with_checksum:
total = sum(self.actual_counts)
response = b"c099" + f"{total:06d}".encode()
response_msg = self.create_message(response)
self.write_to_serial(response_msg)
logger.info(f" Total checksum: {total} coins")
time.sleep(0.01)
# Wait for ACK
ack = self.serial_conn.read(1)
# Send EOT
return bytes([EOT])
def handle_batch_reset(self, data: bytes) -> bytes:
"""Handle uncompleted batch counters reset (s500ve)"""
logger.info("Resetting batch counters and adding to partial totals")
for i in range(self.num_lines):
self.partial_totals[i] += self.actual_counts[i]
self.actual_counts[i] = 0
return bytes([ACK])
def handle_partial_totals_request(self, data: bytes) -> bytes:
"""Handle partial total counters request (s600ve or sd00ve for checksum)"""
cmd_char = chr(data[0]) if len(data) > 0 else "6"
with_checksum = cmd_char == "d"
logger.info(f"Partial totals request (checksum={with_checksum})")
# First ACK
ack_msg = bytes([ACK])
self.write_to_serial(ack_msg)
time.sleep(0.01)
# Send each line's partial total
for line_num in range(1, self.num_lines + 1):
count = self.partial_totals[line_num - 1]
cmd = b"d" if with_checksum else b"6"
response = cmd + b"0" + f"{line_num:02d}{count:06d}".encode()
response_msg = self.create_message(response)
self.write_to_serial(response_msg)
logger.info(f" Line {line_num}: {count} coins (partial total)")
time.sleep(0.01)
# Wait for ACK
ack = self.serial_conn.read(1)
# Send checksum if requested
if with_checksum:
total = sum(self.partial_totals)
response = b"d099" + f"{total:06d}".encode()
response_msg = self.create_message(response)
self.write_to_serial(response_msg)
logger.info(f" Total checksum: {total} coins")
time.sleep(0.01)
# Wait for ACK
ack = self.serial_conn.read(1)
# Send EOT
return bytes([EOT])
def handle_partial_total_reset(self, data: bytes) -> bytes:
"""Handle partial total counters reset (s700ve)"""
logger.info("Resetting partial totals")
self.partial_totals = [0] * self.num_lines
return bytes([ACK])
def handle_batch_values_request(self, data: bytes) -> bytes:
"""Handle batching value request (s900ve or sf00ve for checksum)"""
cmd_char = chr(data[0]) if len(data) > 0 else "9"
with_checksum = cmd_char == "f"
logger.info(f"Batch values request (checksum={with_checksum})")
# First ACK
ack_msg = bytes([ACK])
self.write_to_serial(ack_msg)
time.sleep(0.01)
# Send each line's batch value
for line_num in range(1, self.num_lines + 1):
batch_val = self.batch_values[line_num - 1]
cmd = b"f" if with_checksum else b"9"
response = cmd + b"0" + f"{line_num:02d}{batch_val:06d}".encode()
response_msg = self.create_message(response)
self.write_to_serial(response_msg)
logger.info(f" Line {line_num}: {batch_val} coins (batch threshold)")
time.sleep(0.01)
# Wait for ACK
ack = self.serial_conn.read(1)
# Send checksum if requested
if with_checksum:
total = sum(self.batch_values)
response = b"f099" + f"{total:06d}".encode()
response_msg = self.create_message(response)
self.write_to_serial(response_msg)
logger.info(f" Total checksum: {total} coins")
time.sleep(0.01)
# Wait for ACK
ack = self.serial_conn.read(1)
# Send EOT
return bytes([EOT])
def handle_line_disable(self, data: bytes) -> bytes:
"""Handle single counting line disable (sC<line>ve)"""
if len(data) < 3:
return bytes([CAN])
try:
line_str = data[1:3].decode("ascii")
line_num = int(line_str)
if 1 <= line_num <= self.num_lines:
self.lines_enabled[line_num - 1] = False
logger.info(f"Disabled counting line {line_num}")
return bytes([ACK])
except:
pass
return bytes([CAN])
def handle_line_enable(self, data: bytes) -> bytes:
"""Handle single counting line enable (sD<line>ve)"""
if len(data) < 3:
return bytes([CAN])
try:
line_str = data[1:3].decode("ascii")
line_num = int(line_str)
if 1 <= line_num <= self.num_lines:
self.lines_enabled[line_num - 1] = True
logger.info(f"Enabled counting line {line_num}")
return bytes([ACK])
except:
pass
return bytes([CAN])
def handle_software_version(self, data: bytes) -> bytes:
"""Handle software version request (sA00ve)"""
logger.info(f"Software version request: {self.sw_version}")
response = self.sw_version.encode() + b"\r\n"
return response
def handle_serial_number(self, data: bytes) -> bytes:
"""Handle serial number request (su00ve)"""
logger.info(f"Serial number request: {self.serial_number}")
response = self.serial_number.encode() + b"\r\n"
return response
def _simulate_counting(self):
"""Simulate coin counting - generate random counts for all enabled lines"""
logger.info("Simulating coin counting...")
# Standard denominations ranges
count_ranges = [
(20, 150), # Line 1 - high count
(15, 120), # Line 2
(10, 100), # Line 3
(10, 80), # Line 4
(5, 60), # Line 5
(5, 50), # Line 6
(1, 30), # Line 7 - lower count
(1, 20), # Line 8 - lowest count
]
total_coins = 0
for i in range(self.num_lines):
if self.lines_enabled[i]:
min_count, max_count = count_ranges[i]
count = random.randint(min_count, max_count)
self.actual_counts[i] = count
total_coins += count
logger.info(f" Line {i+1}: {count} coins")
else:
self.actual_counts[i] = 0
logger.info(f" Line {i+1}: disabled")
# Simulate some rejected coins (5-10% of total)
if total_coins > 0:
self.rejected_coins = random.randint(1, max(1, total_coins // 10))
self.has_rejected_coins = True
logger.info(f" Rejected: {self.rejected_coins} coins")
logger.info(f"Total coins counted: {total_coins}")
def handle_command(self, data: bytes) -> Optional[bytes]:
"""Route command to appropriate handler"""
if len(data) < 3:
logger.error("Command too short")
return bytes([CAN])
# If machine is counting, only accept status requests
if self.is_counting and data[0] != ord("1"):
logger.warning("Machine is counting - only status requests accepted")
return bytes([CAN])
cmd = chr(data[0])
sub_cmd = data[1:3].decode("ascii", errors="ignore")
logger.info(f"Received command: {cmd}{sub_cmd}")
# Status requests
if cmd == "1" and sub_cmd == "00":
return self.handle_status_request(data)
# Start counting
elif cmd == "2" and sub_cmd == "00":
return self.handle_start_counting(data)
# Main total reset
elif cmd == "3" and sub_cmd == "00":
return self.handle_main_total_reset(data)
# Actual counters request
elif cmd == "4" and sub_cmd == "00":
return self.handle_actual_counters_request(data)
# Actual counters with checksum
elif cmd == "c" and sub_cmd == "00":
return self.handle_actual_counters_request(data)
# Uncompleted batch reset
elif cmd == "5" and sub_cmd == "00":
return self.handle_batch_reset(data)
# Partial totals request
elif cmd == "6" and sub_cmd == "00":
return self.handle_partial_totals_request(data)
# Partial totals with checksum
elif cmd == "d" and sub_cmd == "00":
return self.handle_partial_totals_request(data)
# Partial total reset
elif cmd == "7" and sub_cmd == "00":
return self.handle_partial_total_reset(data)
# Stop counting
elif cmd == "8" and sub_cmd == "00":
return self.handle_stop_counting(data)
# Batch values request
elif cmd == "9" and sub_cmd == "00":
return self.handle_batch_values_request(data)
# Batch values with checksum
elif cmd == "f" and sub_cmd == "00":
return self.handle_batch_values_request(data)
# Line disable
elif cmd == "C":
return self.handle_line_disable(data)
# Line enable
elif cmd == "D":
return self.handle_line_enable(data)
# Software version
elif cmd == "A" and sub_cmd == "00":
return self.handle_software_version(data)
# Serial number
elif cmd == "u" and sub_cmd == "00":
return self.handle_serial_number(data)
else:
logger.warning(f"Unknown command: {cmd}{sub_cmd}")
return bytes([CAN])
def run(self):
"""Main simulator loop"""
logger.info(f"Starting Selex simulator on {self.port} at {self.baudrate} baud")
logger.info("Protocol: MC-SELEX v2.2")
try:
self.serial_conn = serial.Serial(
port=self.port,
baudrate=self.baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=1,
)
logger.info("Serial port opened successfully")
buffer = bytearray()
while True:
if self.serial_conn.in_waiting > 0:
data = self.serial_conn.read(self.serial_conn.in_waiting)
buffer.extend(data)
# Look for complete message (STX...ETX)
if STX in buffer and ETX in buffer:
stx_idx = buffer.index(STX)
try:
etx_idx = buffer.index(ETX, stx_idx)
message = bytes(buffer[stx_idx : etx_idx + 1])
buffer = buffer[etx_idx + 1 :]
logger.debug(
format_comm_debug("RX", message, include_ascii=True)
)
# Parse and handle message
parsed_data = self.parse_message(message)
if parsed_data:
response = self.handle_command(parsed_data)
if response:
self.write_to_serial(response)
# Handle EOT - close communication
if response == bytes([EOT]):
logger.debug("Communication closed with EOT")
time.sleep(0.5) # Wait 500ms after EOT
except ValueError:
pass # ETX not found yet
time.sleep(0.01)
except KeyboardInterrupt:
logger.info("Simulator stopped by user")
except Exception as e:
logger.error(f"Error: {e}", exc_info=True)
finally:
if self.serial_conn and self.serial_conn.is_open:
self.serial_conn.close()
logger.info("Serial port closed")