Tweaks
This commit is contained in:
475
source/jetsort.py
Normal file
475
source/jetsort.py
Normal 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")
|
||||
Reference in New Issue
Block a user