#!/usr/bin/env python3
#
# Program the epever XTRA-N solar charger for 24V LiFePo batteries.

from pymodbus.client import ModbusSerialClient as ModbusClient
import serial.rs485
from datetime import datetime
import time
import sys
import os
import argparse
import glob
import logging
from logging.handlers import RotatingFileHandler

# supported serial devices
# devices will be located by checking "/dev/serial/by-id/<pattern>"
# e.g. /dev/serial/by-id/usb-1a86_USB_Single_Serial_589A041798-if00
SERIAL_DEVICES = [
  {
    'name': 'XR21B1411',
    'pattern': '*XR21B1411*',
    'invert_rts': True
  },
  {
    'name': 'CH343',
    'pattern': '*1a86_USB*',
    'invert_rts': False
  }
]

# default options
portdev = None

# command line
parser = argparse.ArgumentParser( epilog = '\
Note: When using the --reset option (recommended for first ever programming or \
to overcome a programming issue) - have the battery disconnected with the unit \
powered via the solar input.'
)
parser.add_argument("-i", "--invert-rts", help="Invert RTS logic level", action='store_true')
parser.add_argument("--reset", help="Reset the EPEVER settings before programming (BEWARE)", action='store_true')
parser.add_argument("--init-only", help="Do not evaluate safe_shutdown recovery state and set 0x903D to 0 (debug option)", action="store_true")
parser.add_argument("--port", help="Set modbus port (e.g. /dev/ttyUSB0)")
parser.add_argument("--disconnect", help=f"Set voltage (mV) at which load is disconnected from battery", default=23000, type=int)
parser.add_argument("--reconnect", help=f"Set voltage (mV) at which load is reconnected to battery", default=25800, type=int)
parser.add_argument("-D", "--debug", help="debug serial connection", action="store_true")
args = parser.parse_args()

# collect user arguments
if args.port: portdev = args.port
invert_rts = args.invert_rts

# Function that writes registers, shows results
def set_register_values(reg_address, reg_values, label):
  count = len(reg_values)
  ok = False
  orig = client.read_holding_registers(address=reg_address, count=count, slave=1)
  if orig.isError():
    logging.warning(f"Received read error from device ({orig})")
  elif orig.registers == reg_values:
    logging.info(f'{label}: Unchanged')
    ok = True
  else:
    time.sleep(0.1)
    result = client.write_registers(address=reg_address, values=reg_values, slave=1)
    if result.isError():
      logging.warning(f"Received write error from device ({result})")
    else:
      time.sleep(0.1)
      check = client.read_holding_registers(address=reg_address, count=count, slave=1)
      if check.registers == reg_values:
        logging.info(f'{label}: Updated')
        ok = True
      else:
        logging.warning(f'{label}: FAILED')
        logging.warning('  original:', orig.registers)
        logging.warning('  expected:', reg_values)
        logging.warning('  actual:', check.registers)
  return ok

def init_logging():
  formatter = logging.Formatter("%(asctime)s - %(module)s - %(levelname)s - %(message)s")
  if args.debug:
    log_level = logging.DEBUG
  else:
    log_level = logging.INFO

  # get/reset default logger
  logger = logging.getLogger()
  logger.handlers.clear()
  logger.setLevel(log_level)

  # log to stdout
  console_handler = logging.StreamHandler(sys.stdout)
  console_handler.setLevel(log_level)
  console_handler.setFormatter(formatter)
  logger.addHandler(console_handler)

  # always log to file
  log_path = '/var/signalytic/log/power-management'
  os.makedirs(os.path.dirname(log_path), exist_ok=True)
  file_handler = RotatingFileHandler(filename=log_path, maxBytes=1048576, backupCount=3)
  file_handler.setLevel(log_level)
  file_handler.setFormatter(formatter)
  logger.addHandler(file_handler)

# initialize logging module
init_logging()

# if not specified, try to find a suitable serial device from known devices
if portdev is None:
  for dev in SERIAL_DEVICES:
    name = dev['name']
    pattern = dev['pattern']
    portlink = glob.glob(f'/dev/serial/by-id/{pattern}')
    if portlink:
      if len(portlink) > 1:
        logging.warning(f'Multiple serial devices found matching "{pattern}"')
      portdev = os.path.realpath(portlink[0])
      invert_rts = dev.get('invert_rts', False)
      break

# check serial device present
if not portdev or not os.path.exists(portdev):
  logging.error(f'Serial device not found: {portdev}')
  sys.exit(1)

# Handle serial debug:
if args.debug:
    spyfile = 'serial_spy.txt'
    porturl = "spy://"+portdev+"?file="+spyfile
else:
    porturl = portdev

# initialize client
client = ModbusClient(port=porturl, baudrate=115200, timeout=5, retries=5, strict=False)
if not client.connect():
  logging.error('client failed to connect')
  sys.exit(1)
if invert_rts:
  client.socket.rs485_mode = serial.rs485.RS485Settings(
    rts_level_for_tx=False,
    rts_level_for_rx=True,
  )

# Reset also resets load control register, so only use in case of emergency
if args.reset:
  client.write_coil(0x13, True, 1)

# Sequence:
#  - First set all the variables with registers we want to write.
#  - For each set of values:
#     - read all current settings
#     - only update those that need changing, verify changes with read back

# Registers have to be written to simultaneously, can not write to certain
# registers individually (will cause errors)
# 906D and 906e Only should be set if charging mode is set to SOC # 0x906D #Battery discharge - between 20-80% percentage of battery remaining cpacity when stop charging # 0x906E Battery charge - depth of charge
## Battery control parameters
# The G3 models have the same programming interface, though with more options
# and with more restrictions on the order of programming and permitted values.
# To program the batter parameters we must first set the rated battery voltage
# and the battery type (USER).

#  0x9000 = 0,     # Battery Type (user defined)
batteryControl_0_values = [0]

#  0x9067 = 2,     # Battery rated voltage level (24 V)
batteryControl_1_values = [2]

# 0   0x9001 = 20,    # Battery Capacity (20 AH)
# 1   0x9002 = 0,     # Temperature compensation coefficient
# 2   0x9003 = 2980,  # Over Voltage Disconnect Voltage (29.8 V)
# 3   0x9004 = 2900,  # Charging Limit Voltage (29.0 V)
# 4   0x9005 = 2900,  # Over Voltage Reconnect Voltage (29.0 V)
# 5   0x9006 = 2840,  # Equalize Charging Voltage (28.4 V)
# 6   0x9007 = 2840,  # Boost Charging Voltage (28.4 V)
# 7   0x9008 = 2660,  # Float Charging Voltage (26.6 V)
# 8   0x9009 = 2600,  # Boost Reconnect Charging Voltage (26.0 V ~ 80% SoC)
# 9   0x900A = 2580,  # Low Voltage Reconnect Voltage (args.reconnect)
# 10  0x900B = 2440,  # Under Voltage Warning Reconnect Voltage (24.4 V)
# 11  0x900C = 2400,  # Under Voltage Warning Voltage (24.0 V)
# 12  0x900D = 2300,  # Low Voltage Disconnect Voltage (args.disconnect)
# 13  0x900E = 2220,  # Discharging Limit Voltage (22.20 V)
reg_reconnect = int(args.reconnect/10)
reg_disconnect = int(args.disconnect/10)
batteryControl_2_values = [20, 0, 2980, 2900, 2900, 2840, 2840, 2660, 2600, reg_reconnect, 2440, 2400, reg_disconnect, 2220]

# Rule check (B):
rules_ok = True
if not batteryControl_2_values[2]>batteryControl_2_values[4]:
  rules_ok = False
  logging.error('Rule check failure: Over Voltage Disconnect Voltage>Over Voltage Reconnect Voltage')
if not batteryControl_2_values[3]==batteryControl_2_values[4]:
  rules_ok = False
  logging.error('Rule check failure: Over Voltage Reconnect Voltage＝Charging Limit Voltage')
if not batteryControl_2_values[4]>=batteryControl_2_values[5]:
  rules_ok = False
  logging.error('Rule check failure: Charging Limit Voltage ≥ Equalize Charging Voltage')
if not batteryControl_2_values[5]==batteryControl_2_values[6]:
  rules_ok = False
  logging.error('Rule check failure: Equalize Charging Voltage＝Boost Charging Voltage')
if not batteryControl_2_values[6]>=batteryControl_2_values[7]:
  rules_ok = False
  logging.error('Rule check failure: Boost Charging Voltage ≥ Float Charging Voltage')
if not batteryControl_2_values[7]>batteryControl_2_values[8]:
  rules_ok = False
  logging.error('Rule check failure: Float Charging Voltage>Boost Reconnect Charging Voltage')
# Rule check (C):
if not batteryControl_2_values[9]>batteryControl_2_values[12]:
  rules_ok = False
  logging.error('Rule check failure: Low Voltage Reconnect Voltage>Low Voltage Disconnect Voltage')
if not batteryControl_2_values[12]>=batteryControl_2_values[13]:
  rules_ok = False
  logging.error('Rule check failure: Low Voltage Disconnect Voltage ≥ Discharging Limit Voltage')
# Rule check (D):
if not batteryControl_2_values[10]>batteryControl_2_values[11]:
  rules_ok = False
  logging.error('Rule check failure: Under Voltage Warning Reconnect Voltage>Under Voltage Warning Voltage')
if not batteryControl_2_values[11]>=batteryControl_2_values[13]:
  rules_ok = False
  logging.error('Rule check failure: Under Voltage Warning Voltage≥ Discharging Limit Voltage')
# Rule check (E):
if not batteryControl_2_values[8]>batteryControl_2_values[9]:
  rules_ok = False
  logging.error('Rule check failure: Boost Reconnect Charging voltage> Low Voltage Reconnect Voltage')

#  0x906B = 0,     # Equalize duration (0 minutes, OFF)
#  0x906C = 15,    # Boost Duration (15 minutes)
batteryControl_3_values = [0, 15]
#  0x9070 = 0,     # Charging mode (Voltage compensation)
batteryControl_4_values = [0]

#  0x9107 = 0x0b00 # Li Battery Protection & Over Temp. Drop Power
batteryControl_5_values = [0x0b00]

##Load Settings = 0x903D is managed by safe_shutdown
#  0x903D = 0,     # Load Control Mode (Manual Mode, Light + Time Control, Time Control)
#  0x906A = 1,     # Default Load Status - (for Manual Mode only)
loadSettings_2_values = [1]

## Device Parameter Settings (temp limits, backlight)
#  0x9017, Battery upper temperature limit
#  0x9018, Battery lower temperature limit
#  0x9019, Device over temperature limit
#  0x901A, Device recovery temperature limit
# y=mx+b where b = -16384/25 and m = 1/100 for battery lower temperature solving
deviceSettings_1_values = [5250, 0, 6500, 6000]
#  0x9063, Back light time
deviceSettings_2_values = [10]

## -- read current, update if needed ---
programming_ok = rules_ok
if rules_ok:
  if not set_register_values(0x9000, batteryControl_0_values, 'batteryControl_0'): programming_ok = False
  if not set_register_values(0x9067, batteryControl_1_values, 'batteryControl_1'): programming_ok = False
  if not set_register_values(0x9001, batteryControl_2_values, 'batteryControl_2'): programming_ok = False
  if not set_register_values(0x906B, batteryControl_3_values, 'batteryControl_3'): programming_ok = False
  if not set_register_values(0x9070, batteryControl_4_values, 'batteryControl_4'): programming_ok = False
  if not set_register_values(0x9107, batteryControl_5_values, 'batteryControl_5'): programming_ok = False
  if not set_register_values(0x906A, loadSettings_2_values, 'loadSettings_2'): programming_ok = False
  if not set_register_values(0x9017, deviceSettings_1_values, 'deviceSettings_1'): programming_ok = False
  if not set_register_values(0x9063, deviceSettings_2_values, 'deviceSettings_2'): programming_ok = False
  if args.init_only:
    if not set_register_values(0x903D, [0], 'load_control_mode'): programming_ok = False

if programming_ok:
  logging.info('Success in writing values')
elif not rules_ok:
  logging.error('Error: rules check failure')
else:
  logging.error('Error: writing values')

# Write date and time converted to integer number
pst_currentMS = int((format(datetime.utcnow().minute, '#010b') + format(datetime.utcnow().second, '#010b').replace('0b','')),2)
pst_currentDH = int((format(datetime.utcnow().day, '#010b') + format(datetime.utcnow().hour, '#010b').replace('0b','')),2)
pst_currentYM = int((format(datetime.utcnow().year-2000, '#010b') + format(datetime.utcnow().month, '#010b').replace('0b','')),2)
set_register_values(0x9013, [pst_currentMS, pst_currentDH, pst_currentYM], 'clock')

time.sleep(0.1)
client.close()

if not args.init_only:
  file_path = os.path.dirname(os.path.realpath(__file__))
  safe_shutdown_path = f"{file_path}/safe_shutdown"
  if not os.path.exists(safe_shutdown_path):
    logging.error(f"path does not exist: {safe_shutdown_path}")
  else:
    os.system(f"{safe_shutdown_path} --boot")

if programming_ok:
  sys.exit(0)
else:
  sys.exit(1)

