#!/usr/bin/env python3

#    pYSFReflector
#
#    Created by Antonio Matraia (IU5JAE) on 20/02/2021.
#    Copyright 2021 Antonio Matraia (IU5JAE). All rights reserved.

#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.
#    ----- Mods. Par VA2VDC 2025/11/04 -----UTC to Local Time -- Python 3.12 ----
#    ----- VA2VDC Daniel Lafond  Mods. UTC to LOCAL Zone TIME Avec PYTZ ---------

import socket
import threading
import queue
import sys
import os
import time
import re
import configparser
import signal
import datetime
import pytz
import bisect 
import struct

T_Local_zone = 'America/Toronto'

local_tz = pytz.timezone(T_Local_zone)

def ip2long(ip):
    packed = socket.inet_aton(ip)
    lng = struct.unpack("!L", packed)[0]
    return lng

def long2ip(lng):
    packed = struct.pack("!L", lng)
    ip = socket.inet_ntoa(packed)
    return ip

## LH list management ## ------------------------------------------------------------------------------------
def inserisci_lista(lista, elemento, n_max):
    if (len(lista) < n_max):
        lista.append(elemento)
    else:
        for i in range(n_max - 1):
            lista[i] = lista[i+1]
        lista[n_max-1] = elemento    

def inserisci_listaD(lista, elemento, n_max):    
    for i in lista:
      if (i[1] == elemento[1]):
        lista.remove(i)
        break
    
    if (len(lista) < n_max):
        lista.append(elemento)
    else:
        for i in range(n_max - 1):
            lista[i] = lista[i+1]
        lista[n_max-1] = elemento    



def stampa_lista(lista):
    for i in range(len(lista)):
        print(lista[len(lista)-i-1])

################################# ---------------------------------------------------------------------------

def inlist(a, x):
    i = bisect.bisect_left(a, x)
    if i != len(a) and a[i] == x:
        return True
    else:
        return False        

def check_string(l):
  s = ''
  for c in l:
     if ((not c.isprintable()) and (ord(c) != 10)):
       s += '<' +str(ord(c))  + '>' 
     if c.isprintable():
       s += c  
  return s


def RecvData(sock,recvPackets):
    while True:
        data,addr = sock.recvfrom(1024)    # block if I don't receive anything  -----------------------------
        recvPackets.put((data,addr))
        

def CalcID(ref):
    c = ref.strip().ljust(16)
    u = 0

    for a in c:
        u = (u + ord(a)) & 0xFFFFFFFF
        u = (u + ((u << 10) & 0xFFFFFFFF)) & 0xFFFFFFFF
        u = (u ^ (u >> 6)) & 0xFFFFFFFF

    u = (u + ((u << 3) & 0xFFFFFFFF)) & 0xFFFFFFFF
    u = (u ^ (u >> 11)) & 0xFFFFFFFF
    u = (u + ((u << 15) & 0xFFFFFFFF)) & 0xFFFFFFFF

    u = u % 100000
    return u


def getidgw(cl, adr):
    # Cherche dans la liste `cl` l'élément avec adresse IP et port correspondants à `adr`
    i = [0, '']
    for c in cl:
        if c[0] == adr[0] and c[1] == adr[1]:
            i = [c[4], c[2]]
            break
    return i


def ElencoNodi(cl):
    # En boucle, toutes les 2 minutes, affiche les répéteurs/gateways connectés et ceux qui sont muted
    while True:
        time.sleep(120)
        cl_lo = []
        if len(cl) == 0:
            printlog(1, 'No repeaters/gateways linked')
        else:
            printlog(1, 'Currently linked repeaters/gateways:')
            for c in cl:
                # Conversion aware du timestamp c[6] ----------
                dt_utcC = datetime.datetime.utcfromtimestamp(c[6]).replace(tzinfo=pytz.utc)
                dt_localC = dt_utcC.astimezone(local_tz)
                formatted_timeC = dt_localC.strftime("%Y-%m-%d %H:%M:%S")
 
                printlog(1, '     ' + c[2].ljust(10) + ': ' + str(c[0]) + ':' + str(c[1]) + ' ' + str(c[3]) + '/60 : ' + formatted_timeC)
                if c[5] == 1:
                    cl_lo.append(c)
            if len(cl_lo) == 0:
                printlog(1, 'No repeaters/gateways muted')
            else:
                printlog(1, 'Currently muted repeaters/gateways:')
                for c in cl_lo:
                    printlog(1, '     ' + c[2].ljust(10) + ': ' + str(c[0]) + ':' + str(c[1]) + ' ' + str(c[3]) + '/60')

def TimeoutNodi(cl):
    while True:
        time.sleep(1)
        to_remove = []
        for c in cl:
            c[3] += 1
            if c[3] > 60:
                printlog(1, 'Removing ' + c[2].ljust(10) + ' (' + c[0] + ':' + str(c[1]) + ') disappeared')
                to_remove.append(c)
        for c in to_remove:
            cl.remove(c)

def TimeoutTX(t, t_lock, r_lock, lista_lh, lista_lhd, t_out, t_react):
  global BLK_TMP
  global SCHED
  global lock_tx

  while True:
    if (t[1] < 5):
      lock_tx.acquire()
      t[1] += 0.1
      lock_tx.release()
    if ((t[1] > 1.5) and (t[0] != 0)):
      lock_tx.acquire()
      t[0] = 0  
      printlog(1, 'Network watchdog has expired')

      # Conversion aware du timestamp t[6] ----------
      dt_utcT = datetime.datetime.utcfromtimestamp(t[6]).replace(tzinfo=pytz.utc)
      dt_localT = dt_utcT.astimezone(local_tz)
      formatted_timeT = dt_localT.strftime("%d-%m-%Y %H-%M-%S")

      inserisci_lista(lista_lh, [check_string(t[2]), check_string(t[3]), check_string(t[4]), t[5], formatted_timeT, round(time.time() - t[6]) ], 20)
      inserisci_listaD(lista_lhd, [check_string(t[2]), check_string(t[3]), check_string(t[4]), t[5], formatted_timeT, round(time.time() - t[6]) ], 20)

      t[1] = 0
      t[2] = ''
      t[3] = ''
      t[4] = ''
      t[5] = 0
      t[6] = 0
      lock_tx.release()
    pop_list = []  
    for d in t_lock:
      if (t_lock[d] < 5):
        t_lock[d] += 0.1
      if ((t_lock[d] > 2.0) and (t_lock[d] != 0)):    
        pop_list.append(d)
        r_lock.remove(d)
        
    for x in pop_list:
        t_lock.pop(x)   
        printlog(1, 'Removed from blockeds queue ' + str(x))
        
    if (((time.time() - t[6]) > t_out) and (t[0] != 0)):       # Tx timeout
      if (not inlist(BLK_TMP, t[3])):
        bisect.insort(BLK_TMP,t[3])  
        printlog(1, 'Timeout ' + t[3])
        t[0] = 0                          # drop transmission/force reauthorisation
        t_sched =  time.time() + t_react
        SCHED.append([t[3], 'RC', t_sched])       # append scheduled remove from blocked list
        # (t_sched) ----------
        dt_sched_utc = datetime.datetime.utcfromtimestamp(t_sched).replace(tzinfo=pytz.utc)
        dt_sched_local = dt_sched_utc.astimezone(local_tz)
        formatted_sched = dt_sched_local.strftime('%Y-%m-%d %H:%M:%S')

        printlog(1, 'Appended scheduled job: ' + t[3] + '/RC at time '  + formatted_sched)

    time.sleep(0.1)  

def scheduler():
  global BLK_TMP
  global SCHED
  while True:
    time.sleep(1)
    t = time.time()
    for sc in SCHED:
      if (t > sc[2]):
        if (sc[1] == 'RC'):
          BLK_TMP.remove(sc[0])
          SCHED.remove(sc)
          printlog(1, 'Removed from temporary blockeds queue ' + sc[0]) 

def ckeck_wild_ptt(cs, tw, cnt, trea):
  global W_PTT
  global BLK_TMP
  n = 0
  tc = time.time()
  W_PTT.append([cs, tc])
  for r in W_PTT:
    if (r[1] < (tc - tw)):
      W_PTT.remove(r)
    else:
      if (r[0] == cs):
        n += 1  
  if (n >= cnt):
    printlog(1, 'Wild-PTT ' + r[0])
    if (not inlist(BLK_TMP, r[0])):
        bisect.insort(BLK_TMP,r[0])  
        SCHED.append([r[0], 'RC', tc + trea])

        # Conversion de tc + trea (timestamp UNIX) en datetime aware locale ----------
        dt_sched_utcTC = datetime.datetime.utcfromtimestamp(tc + trea).replace(tzinfo=pytz.utc)
        dt_sched_localTC = dt_sched_utcTC.astimezone(local_tz)
        formatted_schedTC = dt_sched_localTC.strftime('%Y-%m-%d %H:%M:%S')

        printlog(1, 'Appended scheduled job: ' + r[0] + '/RC at time ' + formatted_schedTC)

def canTrasmit(cs, re_en):
  global BLACK_LIST
  global WHITE_LIST
  global BLK_TMP
  call_sp = re.split(r'[-/\' \']', cs)
  call = call_sp[0]

## always block a stream from repeater ### ------------------------------------------------------------------
  if (len(call_sp) > 1):
    if (call_sp[1] == 'RPT'):
      return False      

  if inlist(BLK_TMP, call):
    return False

## case CheckRE == 1 ### ------------------------------------------------------------------------------------
  if (re_en == 1):  
    if inlist(WHITE_LIST, call):
      return True
    if inlist(BLACK_LIST, call):
      return False
    if (re.match(r'^\d?[A-Z]{1,2}\d{1,4}[A-Z]{1,3}$',call,re.IGNORECASE) and (len(call) <= 8)):
      return True
    else:
      return False  

## case CheckRE == 0 ### ------------------------------------------------------------------------------------
  if (re_en == 0):  
    if inlist(WHITE_LIST, call):
      return True
    if inlist(BLACK_LIST, call):
      return False
    return True

## case CheckRE == -1 ### ---------------------------- deny.MD ------------ BlackList -------- Deny.MD ------
  if (re_en == -1):  
    if inlist(BLACK_LIST, call):
      return False
    if inlist(WHITE_LIST, call):
      return True
    return False

def lista_gw(cl):
    info = ''
    for c in cl:
        dt_utc = datetime.datetime.utcfromtimestamp(c[6]).replace(tzinfo=pytz.utc)
        dt_local = dt_utc.astimezone(local_tz)
        formatted_time = dt_local.strftime("%d-%m-%Y %H-%M-%S")

        info += c[2] + ':' + c[0] + ':' + str(c[1]) + ':' + formatted_time + ';'
    return info

def lista_invio(lista):
  info = ''
  for i in range(len(lista)):
    info += lista[len(lista)-i-1][0] + ':' + lista[len(lista)-i-1][1] + ':' + lista[len(lista)-i-1][2] + ':' + str(lista[len(lista)-i-1][3]) + ':' + lista[len(lista)-i-1][4] + ':' + str(lista[len(lista)-i-1][5]) + ';'  
  return info

def update_clients(cl):
  global GW_BL
  global IP_BL
  global GW_LK
  global IP_LK
  for c in cl:
    if (inlist(GW_BL, c[2]) or inlist(IP_BL, ip2long(c[0])) or inlist(GW_LK, c[2]) or inlist(IP_LK, ip2long(c[0]))):
      c[5] = 1
    else:
      c[5] = 0
    if (inlist(GW_LK, c[2]) or inlist(IP_LK, ip2long(c[0]))):
      c[7] = 1
    else:
      c[7] = 0

def blacklist(f_bl, t_reload, cli):
  global BLACK_LIST
  global WHITE_LIST
  global GW_BL
  global IP_BL
  global GW_LK
  global IP_LK

  f_time_old = 0
  try:
    f_time = os.stat(f_bl).st_mtime
  except:
    pass

  while True:
    f_time = os.stat(f_bl).st_mtime
    if (f_time != f_time_old):
      try:
        file = open(f_bl)
        BL_TMP = []
        GW_TMP = []
        IP_TMP = []
        WL_TMP = []
        GW_LK_TMP = []
        IP_LK_TMP = []
        printlog(1, 'Reload the Blacklist from File')
        for row in file:
          content = row.strip()

          # valid line (not a comment) ----------------------------------------------------------------------
          if ((len(content) > 3) and (content[0] != '#')):
            c_split = content.split(':')
            for i in range(len(c_split)):
              c_split[i] = c_split[i].strip()

            # CALL
            if (len(c_split) == 1 or c_split[0] == 'CS'):
              if (len(c_split) == 1):
                cont = content
              if (c_split[0] == 'CS'):
                cont = c_split[1] 
              if ((len(cont) <= 8) and (len(cont) >= 3)):
                if (not inlist(BL_TMP, cont)):
                  bisect.insort(BL_TMP,cont)

            # WL      
            if (len(c_split) == 2 and c_split[0] == 'AL'):      
              if (not inlist(WL_TMP, c_split[1])):
                bisect.insort(WL_TMP,c_split[1])

            # GW      
            if (len(c_split) == 2 and c_split[0] == 'GW'):      
              if (not inlist(GW_TMP, c_split[1])):
                bisect.insort(GW_TMP,c_split[1])

            # GWB      
            if (len(c_split) == 2 and c_split[0] == 'GWB'):      
              if (not inlist(GW_LK_TMP, c_split[1])):
                bisect.insort(GW_LK_TMP,c_split[1])                

            # IP      
            if (len(c_split) == 2 and c_split[0] == 'IP'):      
              try:
                ipa = socket.gethostbyname(c_split[1])
                ipl = ip2long(ipa)
              except:
                ipl = 0
                printlog(2, 'Invalid hostname ' + c_split[1])
              if (ipl > 0):  
                if (not inlist(IP_TMP, ipl)):
                  bisect.insort(IP_TMP, ipl)

             # IPB      
            if (len(c_split) == 2 and c_split[0] == 'IPB'):      
              try:
                ipa = socket.gethostbyname(c_split[1])
                ipl = ip2long(ipa)
              except:
                ipl = 0
                printlog(2, 'Invalid hostname ' + c_split[1])
              if (ipl > 0):  
                if (not inlist(IP_LK_TMP, ipl)):
                  bisect.insort(IP_LK_TMP, ipl)

        file.close() 
      except Exception as ex:
        printlog(2, 'Failed to load Blacklist from File ' + str(ex) )
      BLACK_LIST = BL_TMP.copy()
      WHITE_LIST = WL_TMP.copy()
      GW_BL = GW_TMP.copy()
      IP_BL = IP_TMP.copy()
      GW_LK = GW_LK_TMP.copy()
      IP_LK = IP_LK_TMP.copy()
      printlog(1, 'Loaded ' + str(len(BLACK_LIST)) + '/CS ' + str(len(WHITE_LIST)) + '/AL ' + str(len(GW_BL)) + '/GW ' + str(len(IP_BL)) + '/IP ' + str(len(GW_LK)) + '/GWB ' + str(len(IP_LK)) + '/IPB')
      f_time_old = f_time
      update_clients(cli)
    else:
      pass
    time.sleep(t_reload)


## reading configuration file ## ----------------------------------------------------------------------------
def ReadConfig(f,p):
  config = configparser.ConfigParser()
  
  config_file = f.strip()
  config.read(config_file)
  name = config['Info']['Name'] 
  description = config['Info']['Description']
  try:
    id = int(config['Info']['id'])
  except:
    id = 0
  log_path = config['Log']['FilePath']
  log_name = config['Log']['FileRoot']
  try:
    file_rotate = config['Log']['FileRotate']
  except:
    file_rotate = "1" # pour conserver la rotation des fichiers par défaut avec horodatage  with timestamp --
  
  try:
    display_level = int(config['Log']['DisplayLevel'])
  except:
    display_level = 1
    
  try:
    file_level = int(config['Log']['FileLevel'])
  except:
    file_level = 1

  try:
    en_ext_cmd = int(config['Log']['EnableExtendedCommands'])
  except:
    en_ext_cmd = 0 # extended commands disabled by default --------------------------------------------------

  try:
    port = int(config['Network']['Port'])
  except:
    port = 42000
  try:  
    file_blacklist = config['Block List']['File']
  except:
    file_blacklist = ''

  try:  
    CheckRE = int(config['Block List']['CheckRE'])
    if (CheckRE > 1):
      CheckRE = 1
    if (CheckRE < -1):
      CheckRE = -1
  except:
    CheckRE = 1

  try:
    t_reload_blacklist = float(config['Block List']['Time'])
  except:
    t_reload_blacklist = 5.0
  if (t_reload_blacklist < 0.1):
    t_reload_blacklist = 0.1

  try:
    debug = int(config['Network']['Debug'])
    if (debug >= 1):
      debug = 1
    if (debug < 1):
      debug = 0
  except:
    debug = 0
    
  try:
    timeout = float(config['Protections']['Timeout'])
  except:
    timeout = 240.0

  try:
    treactivate = float(config['Protections']['Treactivate'])
  except:
    treactivate = 600.0  

  try:
    wildptttime = float(config['Protections']['WildPTTTime'])
  except:
    wildptttime = 5.0  

  try:
    wildpttcount = int(config['Protections']['WildPTTCount'])
  except:
    wildpttcount = 3  

  p.append(id)                 # 0
  p.append(name)               # 1
  p.append(description)        # 2
  p.append(log_path)           # 3
  p.append(log_name)           # 4
  p.append(port)               # 5
  p.append(file_blacklist)     # 6
  p.append(t_reload_blacklist) # 7
  p.append(file_rotate)        # 8 --------------------------------------------------------------------------
  p.append(CheckRE)            # 9
  p.append(en_ext_cmd)         #10
  p.append(display_level)      #11
  p.append(file_level)         #12
  p.append(debug)              #13
  p.append(timeout)            #14
  p.append(treactivate)        #15
  p.append(wildptttime)        #16
  p.append(wildpttcount)       #17


def sanitize_msg(data):
  bya_msg = bytearray(data)

  if ((data[0:4] == b"YSFP") and (len(data) == 14)):
    for i in range(10):
      if ((bya_msg[i+4] < 32) or (bya_msg[i+4] > 126)):
        bya_msg[i+4] = 32      

  if ((data[0:4] == b"YSFD") and (len(data) == 155)):
    for i in range(30):
      if ((bya_msg[i+4] < 32) or (bya_msg[i+4] > 126)):
        bya_msg[i+4] = 32

  return(bytes(bya_msg))

def RunServer(config):
    global filelog
    global version
    global BLACK_LIST
    global GW_BL
    global IP_BL
    global GW_LK
    global IP_LK
    global BLK_TMP
    global debug
    global lock_tx

    host = '0.0.0.0'   
    port = config[5]
    s = socket.socket(socket.AF_INET,socket.SOCK_DGRAM)
    s.setblocking(1)
    s.bind((host,port))
    clients = []   # addr, port, gw, t_corr, ID, lonly, t_conn, locked
    c = []
    rx_lock = []
    rx_lock_tout = {}

    # LH list -----------------------------------------------------------------------------------------------
    LH = []
    LHD = []

    # muted list --------------------------------------------------------------------------------------------
    BL = []
    BLD = []

    id = 1  # id gw
    id_str = 1 # id stream
    tx = [0, 0, '', '', '', 0, 0]  # id_gw, tout, gateway, src, dest, id_stream, start_time

    refl_name = config[1]
    refl_desc = config[2]

    if ((config[0] > 0) and (config[0] < 1000000)):
      refl_id = str(config[0]).zfill(5)
    else:  
      refl_id = CalcID(refl_name)

    f_blacklist = config[6]
    tr_blacklist = config[7] * 60.0

    CheckRE = config[9]

    en_ext_cmd = config[10]

    timeout = config[14]
    treactivate = config[15]

    wptttime = config[16]
    wpttcount = config[17]

    recvPackets = queue.Queue()

    print('Starting pYSFReflector-' + version)
    printlog(2, 'Starting pYSFReflector-' + version)
    if (en_ext_cmd == 1):
      printlog(2, 'Extended Commands Enabled')
    else:
      printlog(2, 'Extended Commands Disabled')  
    threading.Thread(target=RecvData,args=(s,recvPackets)).start()
    threading.Thread(target=ElencoNodi,args=(clients,)).start()
    threading.Thread(target=TimeoutNodi,args=(clients,)).start()
    threading.Thread(target=TimeoutTX,args=(tx,rx_lock_tout,rx_lock,LH,LHD,timeout,treactivate)).start()
    threading.Thread(target=scheduler,args=[]).start()
    if (len(f_blacklist) > 0):
      threading.Thread(target=blacklist,args=(f_blacklist,tr_blacklist,clients)).start()

    time_start = time.time()


    while True:
            data_ns, addr = recvPackets.get()    # blocked if queue is empty --------------------------------
            data = sanitize_msg(data_ns)
            cmd = data[0:4]

            if debug == 1:
              hex_dump(data)

            if (cmd == b'YSFP'):
              pres = False
              for c in clients:
                if ((c[0] == addr[0]) and (c[1] == addr[1])):
                  pres = True
                  c[3] = 0
                  break
              if not pres:  
                lonly = 0
                locked = 0
                if inlist(GW_BL, (data[4:14]).decode().strip()):
                  lonly = 1
                if inlist(IP_BL, ip2long(addr[0])):
                  lonly = 1 
                if inlist(GW_LK, (data[4:14]).decode().strip()):
                  locked = 1
                  lonly = 1
                if inlist(IP_LK, ip2long(addr[0])):
                  locked = 1 
                  lonly = 1

                c=[addr[0], addr[1], (data[4:14]).decode().strip(), 0, id, lonly, time.time(), locked]
                id += 1
                clients.append(c)
                printlog(1, 'Adding ' + c[2].ljust(10) + ' (' + c[0] + ':' + str(c[1]) + ')')
              s.sendto(b'YSFPREFLECTOR ',addr)

            if (cmd == b'YSFU'):
               for c in clients:
                if ((c[0] == addr[0]) and (c[1] == addr[1])):
                  printlog(1, 'Removing ' + c[2].ljust(10) + ' (' + c[0] + ':' + str(c[1]) + ') unlinked')
                  clients.remove(c)
                  break

            if ((cmd == b'YSFD') and (len(data) == 155)):
              [id_corr, gw_corr] = getidgw(clients, addr)
              if (tx[0] == 0):
                if (inlist(GW_BL, gw_corr) or inlist(GW_LK, gw_corr)):
                  tx_ok = False
                  block_r = 'GW'
                else:
                  if (inlist(IP_BL, ip2long(addr[0])) or inlist(IP_LK, ip2long(addr[0]))):
                    tx_ok = False
                    block_r = 'IP'      
                  else:
                    tx_ok = canTrasmit(data[14:24].decode().strip(), CheckRE)
                    block_r = 'CS'
              if tx_ok:
                if ((tx[0] == 0) and (id_corr != 0)): # new stream 
                  lock_tx.acquire()
                  tx[0] = id_corr
                  tx[1] = 0
                  # gateway
                  tx[2] = data[4:14].decode().strip()
                  # src
                  tx[3] = data[14:24].decode().strip()
                  # dest
                  tx[4] = data[24:34].decode().strip() 
                  # stream ID
                  tx[5] = id_str
                  # time start
                  tx[6] = time.time()
                  lock_tx.release()
                  id_str += 1   
                  printlog(1, 'Received data from ' + tx[3].ljust(10) +   ' to ' +  tx[4].ljust(10) + ' at ' +  tx[2].ljust(10))
                  ckeck_wild_ptt(tx[3], wptttime, wpttcount, treactivate)
              else:
                if (id_corr not in rx_lock):
                  rx_lock.append(id_corr)  

                  # (time.time()) -------------------------------  T_Local_zone  ------------------------------------
                  local_tz = pytz.timezone(T_Local_zone)
                  dt_utcBL = datetime.datetime.utcfromtimestamp(time.time()).replace(tzinfo=pytz.utc)
                  dt_localBL = dt_utcBL.astimezone(local_tz)
                  formatted_timeBL = dt_localBL.strftime("%d-%m-%Y %H-%M-%S")

                  inserisci_lista(BL, [check_string(data[4:14].decode().strip()) + '/' + block_r, check_string(data[14:24].decode().strip()), check_string(data[24:34].decode().strip()), -1, formatted_timeBL, -1 ], 20)
                  inserisci_listaD(BLD, [check_string(data[4:14].decode().strip()) + '/' + block_r, check_string(data[14:24].decode().strip()), check_string(data[24:34].decode().strip()), -1, formatted_timeBL, -1 ], 20)
                  printlog(1, 'Data from ' + data[14:24].decode().strip().ljust(10) + ' at ' + data[4:14].decode().strip().ljust(10) + ' blocked/' + block_r)
                rx_lock_tout[id_corr] = 0  

              if ((id_corr == tx[0]) and (id_corr != 0)):    
                lock_tx.acquire()
                tx[1] = 0
                lock_tx.release()

              for c in clients:
                if (((c[0] != addr[0]) or (c[1] != addr[1])) and (id_corr == tx[0]) and (id_corr != 0) and (id_corr not in rx_lock) and (c[7] == 0)):
                  s.sendto(data,(c[0], c[1]))

              if (((data[34] & 0x01) == 0x01) and (tx[0] == id_corr) and (tx[0] !=0)):
                printlog(1, 'Received end of transmission')
                # timeTx -----------------
                local_tz = pytz.timezone(T_Local_zone)
                dt_utcTX = datetime.datetime.utcfromtimestamp(tx[6]).replace(tzinfo=pytz.utc)
                dt_localTX = dt_utcTX.astimezone(local_tz)
                formatted_timeTX = dt_localTX.strftime("%d-%m-%Y %H-%M-%S")

                inserisci_lista(LH, [check_string(tx[2]), check_string(tx[3]), check_string(tx[4]), tx[5], formatted_timeTX, round(time.time() - tx[6]) ], 20)
                inserisci_listaD(LHD, [check_string(tx[2]), check_string(tx[3]), check_string(tx[4]), tx[5], formatted_timeTX, round(time.time() - tx[6]) ], 20)
                lock_tx.acquire()
                tx[0] = 0
                tx[1] = 0
                tx[2] = ''
                tx[3] = ''
                tx[4] = ''
                tx[5] = 0
                tx[6] = 0
                lock_tx.release()

            if (cmd == b'YSFS'):
              printlog(0, 'YSF server status enquiry from ' + addr[0] + ':' + str(addr[1]))
              if (len(clients) > 999):
                num_cli = 999
              else:
                num_cli = len(clients)  
              info = 'YSFS' + str(refl_id).zfill(5) + refl_name.ljust(16) + refl_desc.ljust(14) + str(num_cli).zfill(3)
              s.sendto(str.encode(info),addr)  


            if (cmd == b'YSFV'):
              printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
              info = 'YSFV' + 'pYSFReflector' + ' ' + version
              s.sendto(str.encode(info),addr)  

            if (cmd == b'YSFI'):
              # Peut-être que nous pourrons faire quelque chose d'utile avec ces informations plus tard?
              printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
              printlog(0, 'Received information from: ' + addr[0] + ': Callsign: ' + str(data[4:14].decode().strip()) + ' RX-QRG: ' + str(data[14:23].decode().strip()) + ' TX-QRG: ' + str(data[23:32].decode().strip()) + ' Loc: ' + str(data[32:38].decode().strip()) + ' QTH: ' + str(data[38:58].decode().strip()) + ' Type: ' + str(data[58:70].decode().strip()) + ' GW-ID: ' + str(data[70:87].decode().strip()))

            ## Extended Commands ##
            if (en_ext_cmd == 1):
              if (cmd == b'QSRU'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'ASRU;' + str(round(time.time()-time_start)) + ';'
                s.sendto(str.encode(info),addr) 

              if (cmd == b'QSRI'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'ASRI;' + str(refl_id) + ':' + refl_name + ':' + refl_desc + ':' + 'pYSFReflector' + ':' + version + ':' + str(CheckRE) + ':' + str(timeout) + ':' + str(treactivate) + ':' + str(wptttime) + ':' + str(wpttcount) + ';'
                s.sendto(str.encode(info),addr) 

              if (cmd == b'QGWL'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'AGWL;' + lista_gw(clients) 
                s.sendto(str.encode(info),addr) 

              if (cmd == b'QLHL'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'ALHL;' + lista_invio(LH) 
                s.sendto(str.encode(info),addr)    

              if (cmd == b'QREJ'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'AREJ;' + lista_invio(BL) 
                s.sendto(str.encode(info),addr)        

              if (cmd == b'QLHD'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'ALHD;' + lista_invio(LHD) 
                s.sendto(str.encode(info),addr)    

              if (cmd == b'QRED'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                info = 'ARED;' + lista_invio(BLD) 
                s.sendto(str.encode(info),addr)        

              if (cmd == b'QACL'):
                printlog(0, 'Received command ' + cmd.decode() + ' from: ' + addr[0] + ':' + str(addr[1]))
                s_info = ''
                info = 'AACL;CS/' + str(len(BLACK_LIST)) + '|' + 'AL/' + str(len(WHITE_LIST)) + '|' +  'GW/' + str(len(GW_BL)) + '|' +  'IP/' + str(len(IP_BL)) + ';'
                i = 0
                for c in BLACK_LIST:
                  s_info += 'CS:' + c + ';'
                  i += 1
                  if (i >= 20):
                    break
                info += s_info 

                s_info = ''
                i = 0
                for c in WHITE_LIST:
                  s_info += 'AL:' + c + ';'
                  i += 1
                  if (i >= 20):
                    break
                info += s_info 

                s_info = ''
                i = 0
                for c in GW_BL:
                  s_info += 'GW:' + c + ';'
                  i += 1
                  if (i >= 20):
                    break
                info += s_info  

                s_info = ''
                i = 0
                for c in IP_BL:
                  s_info += 'IP:' + long2ip(c) + ';'
                  i += 1
                  if (i >= 20):
                    break
                info += s_info   

                s.sendto(str.encode(info),addr)

    s.close()


def printlog(log_level, mess):
    global filelog
    global log_basename
    global file_rotate
    global file_level
    global debug


    if file_rotate == "1":
        local_tz = pytz.timezone(T_Local_zone)  # Fuseau horaire Québec --------------------- T_Local_zone -------------------------
        now_local = datetime.datetime.now(local_tz)
        log_file = log_basename + '-' + now_local.strftime('%Y-%m-%d') + '.log'
    else:
        log_file = log_basename + '.log'
        now_local = datetime.datetime.now()  # pour avoir `now_local` toujours défini pour les messages

    try:
        if not os.path.isfile(log_file):
            filelog.flush()
            filelog.close()
            filelog = open(log_file, 'a')  # a ou x création fichier log si n'existe pas
    except Exception as e:
        # éventuellement gérer l'exception (print ou log)
        pass

    # Ensuite, construire la chaîne de log selon le niveau
    if isinstance(log_level, int):
        if file_level <= log_level:
            str_log = check_string('M: ' + now_local.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + ' ' + mess)
    else:
        if log_level == "d" and debug == 1:
            str_log = check_string('D: ' + now_local.strftime('%Y-%m-%d %H:%M:%S.%f')[:-3] + ' ' + mess)

    try:
        filelog.write(str_log + '\n')
        filelog.flush()
    except Exception as e:
        print("Failed to write to log file:", e)

def hex_dump(data):
    try:
        n = 0
        b = data[0:16]
        while b:
            s1 = " ".join([f"{i:02x}" for i in b]) # hex string
            s1 = s1[0:23] + " " + s1[23:]          # insert extra space between groups of 8 hex values

            s2 = "".join([chr(i) if 32 <= i <= 127 else "." for i in b]) # ascii string; chained comparison

            message = f"{n * 16:08x}  {s1:<48}  |{s2}|"
            printlog("d", message)
            n += 1
            b = data[n*16:(n+1)*16]

    except Exception as e:
        print(__file__, ": ", type(e).__name__, " - ", e, sep="", file=sys.stderr)

######## main ######## --------------------------------------------------------------------------------------

version = '20251105'

if len(sys.argv) != 2:
    print('Invalid Number of Arguments')
    print('use: YSFReflector <configuration file>')
    sys.exit()

if sys.argv[1].strip() == '-v':
    print('pYSFReflector version ' + version)
    sys.exit()

## reading configuration ## ---------------------------------------------------------------------------------
config = []
try:
    ReadConfig(sys.argv[1].strip(), config)
except:
    print('Unable to read configuration file')
    sys.exit()

log_basename = config[3] + '/' + config[4]
file_rotate = config[8]
display_level = config[11]
file_level = config[12]
debug = config[13]

### log ---------------------  T_Local_zone  --------------------------------------------------------------------------------
if file_rotate == "1":
    local_tz = pytz.timezone(T_Local_zone)
    now_local = datetime.datetime.now(local_tz)
    log_file = log_basename + '-' + now_local.strftime('%Y-%m-%d') + '.log'
else:
    log_file = log_basename + '.log'

try:
    if os.path.isfile(log_file):
        filelog = open(log_file,'a')
    else:
        filelog = open(log_file,'x')
except:
    print('Unable to Open Log File')
    sys.exit()

BLACK_LIST = [] 
WHITE_LIST = [] 
GW_BL = []
IP_BL = []  
GW_LK = []
IP_LK = []  
BLK_TMP = []
SCHED = []
W_PTT = []
lock_tx = threading.Lock()

RunServer(config)
