This commit is contained in:
Eden Kirin
2025-11-05 09:16:37 +01:00
parent 569866d404
commit 5f8b8abd6b
4 changed files with 505 additions and 3 deletions

475
source/jetsort.py Normal file
View File

@ -0,0 +1,475 @@
#!/usr/bin/env python3
"""
JetSort Device Simulator
Simulates a JetSort coin/bill counting device (Model 3500, 3600, 6000)
for development/testing purposes.
Implements the JetSort Communication Package protocol
"""
import random
import select
import sys
import time
from datetime import datetime
from typing import Optional
import serial
from loguru import logger
# Protocol constants
STX = 0x02
ETX = 0x03
ENQ = 0x05
ACK = 0x06
CR = 0x0D
LF = 0x0A
# Report types
REPORT_SUB_BATCH = "SUB-BATCH"
REPORT_BATCH_DAY = "BATCH"
REPORT_BAG_LIMIT = "Limit"
class JetSortSimulator:
def __init__(self, port: str, baudrate: int = 9600):
self.port = port
self.baudrate = baudrate
self.serial_conn: Optional[serial.Serial] = None
# Device state
self.is_counting = False
# 9 coin lines and 9 bill lines
self.num_coin_lines = 9
self.num_bill_lines = 9
# Current batch counters (coin values in cents)
self.coin_values = [0] * self.num_coin_lines
self.bill_values = [0] * self.num_bill_lines
# Day totals
self.day_coin_values = [0] * self.num_coin_lines
self.day_bill_values = [0] * self.num_bill_lines
# Grand totals
self.grand_coin_total = 0
self.grand_bill_total = 0
# Batch tracking
self.sub_batch_count = 0
self.batch_count = 0
self.total_batches = 0
# Audit number (12 digits: XXXXXYYYYZZZ)
self.audit_number = "000070004001"
# Labels
self.label_a = "A:"
self.label_b = "B:"
self.label_c = "C:"
self.label_d = "D:"
self.operator_id = "ID:"
# Communication mode
self.polled_mode = False
# Bag limit settings (in cents)
self.bag_limit = 50000 # $500.00
def _calculate_checksum(self, data: bytes) -> int:
"""Calculate checksum for packet"""
checksum = 0
for byte in data:
checksum += byte
checksum += 0x20
return checksum & 0xFF
def _create_packet(self, title: str, data: str) -> bytes:
"""Create a packet with STX, checksum, length, title, data, and ETX"""
# Build packet content (without STX and ETX)
content = f"{title}\r\n{data}".encode("ascii")
# Calculate checksum
checksum = self._calculate_checksum(content)
# Calculate length (high and low bytes)
length = len(content) + 2 # +2 for CR LF after title
len_high = (length >> 8) & 0xFF
len_low = length & 0xFF
# Build full packet
packet = bytes([STX, checksum, len_high, len_low]) + content + bytes([ETX])
return packet
def _simulate_counting(self):
"""Simulate coin and bill counting - generate random values"""
logger.info("Simulating coin/bill counting...")
# Coin denominations in cents and their count ranges
coin_denominations = [
(1, 50, 200), # C1 - Pennies
(5, 30, 150), # C2 - Nickels
(10, 30, 120), # C3 - Dimes
(25, 20, 100), # C4 - Quarters
(50, 5, 40), # C5 - Half dollars
(100, 5, 30), # C6 - Dollar coins
(0, 0, 0), # C7 - Unused
(0, 0, 0), # C8 - Unused
(0, 0, 0), # C9 - Unused
]
# Bill denominations in cents and their count ranges
bill_denominations = [
(100, 10, 50), # B1 - $1 bills
(500, 5, 30), # B2 - $5 bills
(1000, 5, 25), # B3 - $10 bills
(2000, 3, 20), # B4 - $20 bills
(5000, 1, 10), # B5 - $50 bills
(10000, 1, 5), # B6 - $100 bills
(0, 0, 0), # B7 - Unused
(0, 0, 0), # B8 - Unused
(0, 0, 0), # B9 - Unused
]
# Generate random coin values
total_coin_value = 0
for i, (denomination, min_count, max_count) in enumerate(coin_denominations):
if denomination > 0:
count = random.randint(min_count, max_count)
value = count * denomination
self.coin_values[i] = value
total_coin_value += value
logger.info(
f" C{i+1}: {count} coins × ${denomination/100:.2f} = ${value/100:.2f}"
)
else:
self.coin_values[i] = 0
# Generate random bill values
total_bill_value = 0
for i, (denomination, min_count, max_count) in enumerate(bill_denominations):
if denomination > 0:
count = random.randint(min_count, max_count)
value = count * denomination
self.bill_values[i] = value
total_bill_value += value
logger.info(
f" B{i+1}: {count} bills × ${denomination/100:.2f} = ${value/100:.2f}"
)
else:
self.bill_values[i] = 0
# Update day totals
for i in range(self.num_coin_lines):
self.day_coin_values[i] += self.coin_values[i]
for i in range(self.num_bill_lines):
self.day_bill_values[i] += self.bill_values[i]
logger.info(f"Total coin value: ${total_coin_value/100:.2f}")
logger.info(f"Total bill value: ${total_bill_value/100:.2f}")
logger.info(
f"Total batch value: ${(total_coin_value + total_bill_value)/100:.2f}"
)
self.sub_batch_count += 1
def _format_value(self, value_cents: int) -> str:
"""Format value in cents to string format (no decimal point, no leading zeros)"""
# Convert cents to string without decimal (e.g., 10050 for $100.50)
return str(value_cents)
def _generate_sub_batch_report(self) -> str:
"""Generate SUB-BATCH report"""
logger.info("Generating SUB-BATCH report")
# Simulate counting if no values yet
if sum(self.coin_values) == 0 and sum(self.bill_values) == 0:
self._simulate_counting()
lines = []
# Date (blank if not enabled)
date_str = datetime.now().strftime("%m-%d-%y")
lines.append(date_str)
# Audit Number
lines.append(self.audit_number)
# Labels
lines.append(self.label_a)
lines.append(self.label_b)
lines.append(self.label_c)
lines.append(self.label_d)
lines.append(self.operator_id)
# Coin values (C1-C9)
for i in range(self.num_coin_lines):
lines.append(self._format_value(self.coin_values[i]))
# Coin Total Identifier
lines.append("CT:")
# Total Coin Value
total_coin_value = sum(self.coin_values)
lines.append(self._format_value(total_coin_value))
# Sub-Batch Currency (0)
lines.append("0")
# Sub-Batch Checks (0)
lines.append("0")
# Sub-Batch Misc (0)
lines.append("0")
# Bill values (B1-B9)
for i in range(self.num_bill_lines):
lines.append(self._format_value(self.bill_values[i]))
# Sub-Batch Declared Balance (0)
lines.append("0")
# Receipts Identifier
lines.append("RT:")
# Receipts Total (0)
lines.append("0")
# Grand Total Identifier
lines.append("GT:")
# Grand Total
grand_total = total_coin_value + sum(self.bill_values)
lines.append(self._format_value(grand_total))
return "\r\n".join(lines)
def _generate_batch_day_report(self) -> str:
"""Generate BATCH/DAY report"""
logger.info("Generating BATCH/DAY report")
lines = []
# Date
date_str = datetime.now().strftime("%m-%d-%y")
lines.append(date_str)
# Audit Number
lines.append(self.audit_number)
# Labels
lines.append(self.label_a)
lines.append(self.label_b)
lines.append(self.label_c)
lines.append(self.label_d)
lines.append(self.operator_id)
# Batch/Day Coin values (C1-C9)
for i in range(self.num_coin_lines):
lines.append(self._format_value(self.day_coin_values[i]))
# Coin Total Identifier
lines.append("CT:")
# Total Coin Value
total_coin_value = sum(self.day_coin_values)
lines.append(self._format_value(total_coin_value))
# Batch Currency (0)
lines.append("0")
# Batch Checks (0)
lines.append("0")
# Batch Misc (0)
lines.append("0")
# Batch Bill values (B1-B9)
for i in range(self.num_bill_lines):
lines.append(self._format_value(self.day_bill_values[i]))
# Batch Declared Balance (0)
lines.append("0")
# Receipt Identifier
lines.append("RT:")
# Receipts Total (0)
lines.append("0")
# Grand Total Identifier
lines.append("GT:")
# Grand Total
grand_total = total_coin_value + sum(self.day_bill_values)
lines.append(self._format_value(grand_total))
return "\r\n".join(lines)
def _generate_day_report(self) -> str:
"""Generate DAY report (end of batch/day)"""
logger.info("Generating DAY report")
lines = []
# Grand Total
grand_total = sum(self.day_coin_values) + sum(self.day_bill_values)
lines.append(self._format_value(grand_total))
# Title
lines.append("DAY")
# Date
date_str = datetime.now().strftime("%m-%d-%y")
lines.append(date_str)
# Audit Number
lines.append(self.audit_number)
# Day Coin values (C1-C9)
for i in range(self.num_coin_lines):
lines.append(self._format_value(self.day_coin_values[i]))
# Coin Total Identifier
lines.append("CT:")
# Total Coin Value
total_coin_value = sum(self.day_coin_values)
lines.append(self._format_value(total_coin_value))
# Day Currency (0)
lines.append("0")
# Day Checks (0)
lines.append("0")
# Day Misc (0)
lines.append("0")
# Day Bill values (B1-B9)
for i in range(self.num_bill_lines):
lines.append(self._format_value(self.day_bill_values[i]))
# Receipt Identifier
lines.append("RT:")
# Receipts Total (0)
lines.append("0")
# Grand Total Identifier
lines.append("GT:")
# Grand Total
lines.append(self._format_value(grand_total))
return "\r\n".join(lines)
def handle_poll(self) -> Optional[bytes]:
"""Handle ENQ poll from computer"""
logger.info("Received poll (ENQ)")
# Send SUB-BATCH report
report_data = self._generate_sub_batch_report()
packet = self._create_packet(REPORT_SUB_BATCH, report_data)
logger.info(f"Sending SUB-BATCH report ({len(packet)} bytes)")
return packet
def send_automatic_report(self):
"""Send automatic SUB-BATCH report (immediate mode)"""
if not self.polled_mode and self.serial_conn:
logger.info("Sending automatic SUB-BATCH report")
# Simulate new batch
self._simulate_counting()
# Generate and send report
report_data = self._generate_sub_batch_report()
packet = self._create_packet(REPORT_SUB_BATCH, report_data)
self.serial_conn.write(packet)
logger.debug(f"TX: {' '.join(f'{b:02X}' for b in packet[:50])}...")
# Reset batch counters after sending
self.coin_values = [0] * self.num_coin_lines
self.bill_values = [0] * self.num_bill_lines
def run(self):
"""Main simulator loop"""
logger.info(
f"Starting JetSort simulator on {self.port} at {self.baudrate} baud"
)
logger.info("Protocol: JetSort Communication Package")
logger.info("Press ENTER to send a cash counting report")
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")
help_text_shown = False
while True:
if not help_text_shown:
print("\nPress ENTER to send a cash counting report...")
help_text_shown = True
# Check for keyboard input (non-blocking)
if sys.platform != "win32":
# Unix/Linux - use select
ready, _, _ = select.select([sys.stdin], [], [], 0)
if ready:
sys.stdin.readline() # Consume the input
logger.info("Key pressed - sending cash counting report")
self.send_automatic_report()
help_text_shown = False
else:
# Windows - use msvcrt
import msvcrt
if msvcrt.kbhit():
msvcrt.getch() # Consume the input
logger.info("Key pressed - sending cash counting report")
self.send_automatic_report()
help_text_shown = False
# Check for incoming data from serial port
if self.serial_conn.in_waiting > 0:
data = self.serial_conn.read(self.serial_conn.in_waiting)
logger.debug(f"RX: {' '.join(f'{b:02X}' for b in data)}")
# Check for ENQ (poll)
if ENQ in data:
response = self.handle_poll()
if response:
self.serial_conn.write(response)
logger.debug(
f"TX: {' '.join(f'{b:02X}' for b in response[:50])}..."
)
# Reset batch counters
self.coin_values = [0] * self.num_coin_lines
self.bill_values = [0] * self.num_bill_lines
# Check for ACK
elif ACK in data:
logger.info("Received ACK from computer")
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")