#!/usr/bin/env python3

"""
Virtual USB-to-RS-485 adapter compatible with the EVBox ChargeStation Tool
Written 2026 by Maarten Tromp <maarten@geekabit.nl>
Website: https://www.geekabit.nl/projects/reversing-the-evbox-chargestation-tool/
License: CC0 1.0 Universal (Public Domain Dedication)
For more info see README
"""


import os
import sys
import time
import signal
import serial.rs485


# USB device config
VendorID = 0x04D8
ProductID = 0x003F
VendorString = "Microchip Technology, Inc."
ProductString = "EV-BOX USB-to-ChargeStation-LM"
SerialNumberString = "SPS-130212"
REPORT_LEN = 64
# USB gadget config
GADGET_DIR = "/sys/kernel/config/usb_gadget/evbox"
LINK = f"{GADGET_DIR}/configs/c.1/hid.usb0"
HID_PATH = "/dev/hidg0"
# Serial config
SERIAL_DEV = "/dev/ttyUSB1"


def write(path, value):
	"""
	Helper function to write to a file.
	"""
	#print(f"file: {path}, value: '{value}'")
	if not os.access(path, os.W_OK):
		print("Insufficient permissions, try running as root")
		sys.exit(1)
	with open(path, mode="w", encoding="ascii") as f:
		f.write(f"{value}")


def teardown_gadget():
	"""
	Stop the USB gadget.
	"""
	if os.path.islink(LINK):
		os.unlink(LINK)
	udc = f"{GADGET_DIR}/UDC"
	if os.path.isfile(udc):
		write(udc, "")


def setup_gadget():
	"""
	Configure and start the USB gadget.
	"""
	if not os.path.exists("/sys/kernel/config/usb_gadget"):
		print("configfs not mounted")
		sys.exit(1)

	os.makedirs(GADGET_DIR, exist_ok=True)

	# Device descriptor
	write(f"{GADGET_DIR}/idVendor", f"{VendorID}")
	write(f"{GADGET_DIR}/idProduct", f"{ProductID}")
	write(f"{GADGET_DIR}/bcdDevice", "0100")
	write(f"{GADGET_DIR}/bcdUSB", "0200")

	# Strings
	os.makedirs(f"{GADGET_DIR}/strings/0x409", exist_ok=True)
	write(f"{GADGET_DIR}/strings/0x409/manufacturer", VendorString)
	write(f"{GADGET_DIR}/strings/0x409/product", ProductString)
	write(f"{GADGET_DIR}/strings/0x409/serialnumber", SerialNumberString)

	# function
	os.makedirs(f"{GADGET_DIR}/functions/hid.usb0", exist_ok=True)
	write(f"{GADGET_DIR}/functions/hid.usb0/protocol", "1")
	write(f"{GADGET_DIR}/functions/hid.usb0/subclass", "0")
	write(f"{GADGET_DIR}/functions/hid.usb0/report_length", f"{REPORT_LEN}")

	# descriptor
	report_desc = bytes([
		0x06, 0x00, 0xff,	# USAGE_PAGE (Vendor Defined)
		0x09, 0x01,			# USAGE
		0xa1, 0x01,			# COLLECTION (Application)
		0x15, 0x00,			# LOGICAL_MINIMUM (0)
		0x26, 0xff, 0x00,	# LOGICAL_MAXIMUM (255)
		0x75, 0x08,			# REPORT_SIZE
		0x95, REPORT_LEN,	# REPORT_COUNT
		0x09, 0x01,			# USAGE
		0x81, 0x02,			# INPUT (Data,Var,Abs)
		0x95, REPORT_LEN,	# REPORT_COUNT
		0x09, 0x01,			# USAGE
		0x91, 0x02,			# OUTPUT (Data,Var,Abs)
		0xc0				# END_COLLECTION
	])
	#print("writing descriptor\n")
	with open(f"{GADGET_DIR}/functions/hid.usb0/report_desc", mode="wb") as f:
		f.write(report_desc)

	# Configuration
	os.makedirs(f"{GADGET_DIR}/configs/c.1", exist_ok=True)
	write(f"{GADGET_DIR}/configs/c.1/MaxPower", "250")
	os.symlink(f"{GADGET_DIR}/functions/hid.usb0", LINK)

	# Bind to virtual UDC (vhci_hcd)
	udc_path = "/sys/class/udc"
	udcs = os.listdir(udc_path)
	if not udcs:
		print("No UDC found! Make sure vhci_hcd is loaded")
		sys.exit(1)
	write(f"{GADGET_DIR}/UDC", udcs[0])


def forwarding_loop():
	"""
	Cooperative loop which read from USB and serial, and then writes to serial and USB
	"""
	hid = os.open(HID_PATH, os.O_RDWR | os.O_NONBLOCK)

	if not os.path.exists(SERIAL_DEV):
		print(f"{SERIAL_DEV} not found")
		sys.exit(1)
	ser = serial.rs485.RS485(port = SERIAL_DEV, baudrate = 38400, timeout = 0)

	hid_buf = bytearray()
	ser_buf = bytearray()
	frame = None
	last_serial_act = time.monotonic()

	print("press Ctrl+C to exit")
	while True:
		# read USB
		try:
			hid_data = os.read(hid, REPORT_LEN)
			# strip padding
			hid_data = hid_data.rstrip(b"\x00")
			# queue for sending
			hid_buf.extend(hid_data)
			print("USB → RS485:", hid_data.hex(" "), flush=True)
			#print(hid_data.hex(" "), flush=True)
		except BlockingIOError:
			# no data available
			pass

		# read serial
		ser_data = ser.read(REPORT_LEN)
		if ser_data:
			last_serial_act = time.monotonic()
			# buffer 1 frame
			ser_buf.extend(ser_data)
			sof = ser_buf.rfind(b"\x02")
			if sof < 0:
				# start of frame not found
				ser_buf.clear()
			else:
				if sof > 0:
					# garbage before start of frame
					ser_buf = ser_buf[sof:]
				eof = ser_buf.find(b"\x03\xff")
				if eof > 0:
					# queue frame for sending
					frame = ser_buf[:eof + 2]
					ser_buf = ser_buf[eof + 2:]
					print("RS485 → USB:", frame.hex(" "), flush=True)
					#print(frame.hex(" "), flush=True)
					if not valid_frame(frame):
						frame.clear()

		# write serial
		if len(hid_buf) > 0:
			if hid_buf[0] == 0x02:
				# start of new frame
				if time.monotonic() > last_serial_act + 0.01: # 10ms bus idle time on start of frame
					ser.write(hid_buf)
					ser.flush()
					hid_buf.clear()
			else:
				# frame continuation
				ser.write(hid_buf)
				ser.flush()
				hid_buf.clear()
				print("# does this ever happen?", flush=True)

		# write USB
		while frame:
			# cut up, pad and send off
			chunk = frame[:REPORT_LEN]
			chunk = chunk.ljust(REPORT_LEN, b"\x00")
			try:
				os.write(hid, chunk)
				frame = frame[REPORT_LEN:]
			#except BlockingIOError:
			except:
				pass

		# prevent 100% cpu load
		time.sleep(0.001)


def valid_frame(frame):
	"""
	Validate protocol Max frame.
	"""
	for b in frame[1:-2]:
		#if not (b == 0x00 or 0x30 <= b <= 0x39 or 0x41 <= b <= 0x5A): # null, 0-9, A-Z
		#if not (0x30 <= b <= 0x39 or 0x41 <= b <= 0x5A): # null, 0-9, A-Z
		if not (b in (0x00, 0x0a, 0x0d, 0x3a) or 0x30 <= b <= 0x39 or 0x41 <= b <= 0x5A): # null, cr, lf, :, 0-9, A-Z
			# invalid data in frame
			print("#invalid data", flush=True)
			return False

	payload = frame[1:-6]
	frame_checksum = frame[-6:-4]
	calculated_checksum = sum(payload) % 256
	calculated_checksum = f"{calculated_checksum:02X}".encode("ascii")
	if not frame_checksum == calculated_checksum:
		print("#invalid checksum", flush=True)
		return False

	frame_parity = frame[-4:-2]
	calculated_parity = 0
	for payload_byte in payload:
		calculated_parity = calculated_parity ^ payload_byte
	calculated_parity = f"{calculated_parity:02X}".encode("ascii")
	if not frame_parity == calculated_parity:
		print("#invalid parity", flush=True)
		return False

	return True


def sigint_handler(_sig, _frame):
	"""
	Handler for when Ctrl-C is pressed.
	"""
	teardown_gadget()
	sys.exit(0)


if __name__ == "__main__":
	signal.signal(signal.SIGINT, sigint_handler)
	teardown_gadget()
	setup_gadget()
	forwarding_loop()
