'''
/** Original work Copyright 2024 damiano IU3QGD
 * https://to-be-defined
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 *     Unless required by applicable law or agreed to in writing, software
 *     distributed under the License is distributed on an "AS IS" BASIS,
 *     WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *     See the License for the specific language governing permissions and
 *     limitations under the License.
 
 * Addition to the licence: NO company with more than 50employees can use this SW and you are NOT entitled to store this software 
 * on ANY storage that in some way attempts to monetize it (eg: github)
 * corporations and banks are too rich and too powerful, they MUST be split and ALL the money given back to the state

https://people.csail.mit.edu/hubert/pyaudio/docs/
https://docs.python.org/3.13/library/tkinter.html
https://www.tcl-lang.org/man/tcl8.6/TkCmd/contents.htm


- quando si cambia IP address, APPLY, per cambiare le impostazioni
- mandare ogni 10s un pacchetto vuoto a Wiradio, per stabilire il percorso AUDIO
  anche perchè ... potrebbe resettarsi e quindi avere bisogno di un riavvio
- Dare la possibilità di inviare un file di prova, per provare il percorso invio



'''


from __future__ import annotations

import ipaddress
import math
import pyaudio
import queue
import socket
import struct
from threading import Thread
import threading
import time
from tkinter import StringVar, IntVar
import tkinter
from tkinter.ttk import Widget
import typing
import wave

from app_config import AppConfig, ConfigSavable
from glob_fun import getResourcesFname, getResourcesPhotoimage
from glob_gui import JtkWinToplevel, TV_Entry, LogPanel, \
    GUI_hide_show_window, JtkPanelPackTop, JtkPanelPackLeft, \
    JtkLabel, JtkButtonText, JtkPanelGrid, JtkPanelLayered, QsPPM_img, \
    CanvasCircle
from glob_ippqs import Qsk5_req_radio_poll, Qsk5_res_screen_bmap
from ipp_parser import Qsk5_req_set_radio_params
import qpystat
import tkinter.ttk as ttk
from uvk5_lib import QSTCP_PORT


QSAUDIO_bitrate = 8000       # frames x second
QSAUDIO_bytes_xsample = 2    # it is a short int
QSAUDIO_chans_xstream = 2    # Left and Right channel

# this becomes 1024 bytes since there are stereo and 16 bits values
QSAUDIO_FRAMES_EACH_PACKET=256

QSAUDIO_PACKETS_EACH_S=QSAUDIO_bitrate/QSAUDIO_FRAMES_EACH_PACKET

QSAUDIO_PACKET_BYTES_LEN=QSAUDIO_FRAMES_EACH_PACKET*QSAUDIO_bytes_xsample*QSAUDIO_chans_xstream


QSGUI_CIRCLE_BK="#808080"

QSGUI_PIX_WIDTH=128
QSGUI_PIX_HEIGHT=64








# ====================================================================
# this is GUI and a Thread, because audio
class Wiremote_gui (Thread,ConfigSavable,GUI_hide_show_window):

    cfg_item_name = 'wiremote_cfg'

    # ----------------------------------------------------------------
    def __init__(self, stat : qpystat.Qpystat):
        Thread.__init__(self,name="Wiremote",daemon=True)
        
        self.stat = stat
        
        # each time a UDP packet is rx, the semaphore is aquired
        self._rx_UDP_event = threading.Event()
        
        self._qsfb_ppm = QsPPM_img(QSGUI_PIX_HEIGHT, QSGUI_PIX_WIDTH)
        
        self.stat.appconfig.addMyselfToSavables(self)

        self._toplevel = JtkWinToplevel("Wiremote Window")
        
        a_panel=JtkPanelPackTop(self._toplevel, padding=2 )     

        a_panel.addItem(self._newTopButtonsBar(a_panel))
        
        self._VUmeter = Qsaudio_VUmeter(a_panel)
        a_panel.addItem(self._VUmeter)
        
        a_panel.addItem(self._newLayeredPanel(a_panel))
        
        a_panel.pack(fill='both',expand=True)

        self.GUI_hide_window() 

        self._v_buttons : list[WireVirtualClick] = self._newVirtualButtons()
        
        self._println("Wiremote init complete")

    # -------------------------------------------------------------------
    # prints something on this window log
    def _println(self, msg):
        self._qaudio_log.println(msg)    
        
    # --------------------------------------------------------------------
    # show a window that has been hidden using the withdraw method
    def GUI_show_window(self):
        self._toplevel.deiconify()    

    # --------------------------------------------------------------------
    # hide completely a window
    def GUI_hide_window(self):
        self._toplevel.withdraw()    

    # -----------------------------------------------------------
    # Mostly a reminder that this method is available everywhere    
    def _runOnGuiIdle(self, func, *args ):
        self._toplevel.after_idle(func, *args)

    # -------------------------------------------------------------------
    # implement the config savable
    def appImportConfig(self, cfg : AppConfig ):
        adict = cfg.getDictValue(self.cfg_item_name, {})
        
        try:
            self._println("geometry "+self._toplevel.geometry())

            self._toplevel.setGeometry(adict['gui_cfg'])
            self._qsaudio_linein_dev.set(adict['qsaudio_linein_dev'])    

        except Exception as _exc :
            pass            

    # -------------------------------------------------------------------
    # implement the config savable
    def appSaveConfig(self, cfg : AppConfig ):
        # get the current dictionary, if any
        adict = cfg.getDictValue(self.cfg_item_name, {})

        adict['gui_cfg'] = self._toplevel.getGeometry()
        adict['qsaudio_linein_dev'] = self._qsaudio_linein_dev.get()

        cfg.setDictValue(self.cfg_item_name, adict )            

    # ---------------------------------------------------------------------------------
    
    def _newVirtualButtons(self) -> list[WireVirtualClick]:

        '''
        1   KEY_PTT,       // this is the first key to be put in the result, if available
        2   KEY_SIDE1,     // priority order these are next
        3   KEY_SIDE2,     // priority order these are next
        4   KEY_MENU,
        5   KEY_UP,
        6   KEY_DOWN,
        7   KEY_EXIT,
        8   KEY_STAR,
        9   KEY_F,
        10  KEY_0,       // since numbers are relative to KEY_0 you MUST have a sequence from KEY_0 to KEY_9
        11  KEY_1,
           KEY_2,
           KEY_3,
           KEY_4,
           KEY_5,
           KEY_6,
            KEY_7,
            KEY_8,
            KEY_9,
        '''           
        
        v_result : list[WireVirtualClick] = []
        
        v_result.append(WireVirtualClick_Ptt(self,67,213,40))       # PTT
        v_result.append(WireVirtualClick_Timed(self,66 ,344,25,2))  # key SIDE1 
        v_result.append(WireVirtualClick_Timed(self,66 ,403,25,3))  # key SIDE2 
        v_result.append(WireVirtualClick_Key(self,181,341,30,4))    # key Menu
        v_result.append(WireVirtualClick_Key(self,155,390,20,5))    # key UP
        v_result.append(WireVirtualClick_Key(self,155,439,20,6))    # key Down
        v_result.append(WireVirtualClick_Key(self,158,484,20,7))    # key Exit
        v_result.append(WireVirtualClick_Key(self,371,525,20,8))    # key Star
        v_result.append(WireVirtualClick_Timed(self,371,610,20,9))  # key Func
        v_result.append(WireVirtualClick_Key(self,371,577,20,10))  # key 0
        v_result.append(WireVirtualClick_Key(self,158,525,20,11))  # key 1
        v_result.append(WireVirtualClick_Key(self,229,525,20,12))  # key 2
        v_result.append(WireVirtualClick_Key(self,297,525,20,13))  # key 3
        v_result.append(WireVirtualClick_Key(self,152,572,20,14))  # key 4
        v_result.append(WireVirtualClick_Key(self,225,572,20,15))  # key 5
        v_result.append(WireVirtualClick_Key(self,299,570,20,16))  # key 6
        v_result.append(WireVirtualClick_Key(self,158,610,20,17))  # key 7
        v_result.append(WireVirtualClick_Key(self,229,610,20,18))  # key 8
        v_result.append(WireVirtualClick_Key(self,297,610,20,19))  # key 9

        return v_result
    
    # ---------------------------------------------------------------------------------
    def _newTopButtonsBar(self, win_parent : Widget ):
        
        a_panel = JtkPanelPackLeft(win_parent)
        
        a_panel.addItem(JtkButtonText(a_panel,'Radio',command=self._raiseShimPanel))
        a_panel.addItem(JtkButtonText(a_panel,'Config',command=self._raiseConfigPanel))
        a_panel.addItem(JtkButtonText(a_panel,'Status',command=self._raiseStatusPanel))
        a_panel.addItem(JtkButtonText(a_panel,'Log',command=self._raiseLogPanel))
        
        return a_panel

    # ------------------------------------------------------
    # NEED to have a two step call since the object is NOT created at compile time        
    def _raiseShimPanel (self):
        self._shimPanel.tkraise()

    # ------------------------------------------------------
    def _raiseConfigPanel (self):
        self._configPanel.tkraise()

    # ------------------------------------------------------
    def _raiseStatusPanel (self):
        self._statusPanel.tkraise()

    # ------------------------------------------------------
    def _raiseLogPanel (self):
        self._qaudio_log.tkraise()


    # --------------------------------------------------------------------------
    # each byte defines eight rows, starting from the start at a given col
    def _fbradio_parse_byte(self, pix_row_start : int, pix_col, b_value : int ):
        
        b_mask = 0x001
        
        for index in range(0,8):
            
            is_on = b_value & b_mask
            
            self._qsfb_ppm.set_bit_pixel(pix_row_start+index, pix_col, is_on != 0)
            
            b_mask = b_mask << 1
            

    # ------------------------------------------------------------
    # this gets a row of chars and should expand / rotate every single char
    def _fbradio_parse_row(self, text_row : int, f_buffer : bytes):
        
        # this is the pixel row start index
        pix_row_start = text_row * 8
        
        for col_idx, abyte in enumerate(f_buffer):
            self._fbradio_parse_byte(pix_row_start, col_idx, abyte)
        

    # ------------------------------------------------------------
    # parse the whole screen
    # the format is eitht rows of bytes, each byte describe eight rows of pixels, in vertical
    def _fbradio_parse_screen(self, line_idx : int, f_buffer : bytes):

        self._fbradio_parse_row(line_idx, f_buffer)

    # ------------------------------------------------------------
    # must be run in swing thread    
    def _canvas_update_fbuffer(self):

        self._qsfb_photoimage = self._newFbufferImage()
                
        self._canvas.itemconfigure(self._radio_bitmap_id, image=self._qsfb_photoimage)

    # ------------------------------------------------------------
    # qconnect is giving me a response for a poll command
    def parse_screen_bmap(self,res_screen : Qsk5_res_screen_bmap):
        
        # request to adjust the framebuffer image from the screen content
        self._fbradio_parse_screen(res_screen.line_idx, res_screen.frame_buffer)  
        
        # request the gui to update the image from the decoded content
        self._runOnGuiIdle(self._canvas_update_fbuffer)    
    

    # ---------------------------------------------------------------------------------
    def _newLayeredPanel(self, win_parent : Widget ):
        
        a_panel = JtkPanelLayered(win_parent)

        self._qaudio_log = LogPanel(a_panel,"Wiremote Log")
        a_panel.addItem(self._qaudio_log)
    
        self._configPanel = self._newConfigPanel(a_panel)
        a_panel.addItem(self._configPanel)

        self._controlPanel = self._newRadioControlPanel(a_panel)
        a_panel.addItem(self._controlPanel)
        
        self._statusPanel = self._newStatusPanel(a_panel)
        a_panel.addItem(self._statusPanel)

        self._shimPanel = self._newShimPanel(a_panel)
        a_panel.addItem(self._shimPanel)

        return a_panel


    # ---------------------------------------------------------------------------------
    # this is the other click, whatever it is
    def _mouseClickAltro(self,event):
        widget = event.widget
        p_x = int(widget.canvasx(event.x)) 
        p_y = int(widget.canvasy(event.y))
        a_text=str(p_x)+'.'+str(p_y)
        self._canvas.itemconfigure(self._mouse_pos_id, text=a_text)
        
        over : WireVirtualClick = self._parseVirtualButtons(p_x, p_y)
        
        self._key_click_circle.move_to( p_x, p_y )

        if over:
            self._key_click_circle.set_option(fill="#75FFFF")
            over.do_click(True)
        else:
            self._key_click_circle.set_option(fill=QSGUI_CIRCLE_BK)


    # ---------------------------------------------------------------------------------
    # this is the standard click
    def _mouseClick(self,event):
        widget = event.widget
        p_x = int(widget.canvasx(event.x)) 
        p_y = int(widget.canvasy(event.y))
        a_text=str(p_x)+'.'+str(p_y)
        self._canvas.itemconfigure(self._mouse_pos_id, text=a_text)
        
        over : WireVirtualClick = self._parseVirtualButtons(p_x, p_y)
        
        self._key_click_circle.move_to( p_x, p_y )

        if over:
            self._key_click_circle.set_option(fill="#FFE930")
            over.do_click(False)
        else:
            self._key_click_circle.set_option(fill=QSGUI_CIRCLE_BK)



    # ---------------------------------------------------------------------------------
    # when mouse moves the text display is updated
    def _mouseMove(self,event):
        widget = event.widget
        p_x = int(widget.canvasx(event.x)) 
        p_y = int(widget.canvasy(event.y))
        a_text=str(p_x)+'.'+str(p_y)
        self._canvas.itemconfigure(self._mouse_pos_id, text=a_text)
        
        over : WireVirtualClick = self._parseVirtualButtons(p_x, p_y)
        
        self._key_click_circle.move_to( p_x, p_y )

        if over:
            self._key_click_circle.set_option(fill="#00B627")
            
        else:
            self._key_click_circle.set_option(fill=QSGUI_CIRCLE_BK)
        
        
    # --------------------------------------------------------------------------------
    # 
    def _parseVirtualButtons(self, p_x : int , p_y : int) -> WireVirtualClick:
        
        for r_row in self._v_buttons:
            v_button : WireVirtualClick = r_row
            
            if v_button.is_point_over(p_x, p_y):
                return v_button
                
        return typing.cast(WireVirtualClick, None)
                    

    # ---------------------------------------------------------------------------------
    # create the new buffer image from framebuffer 
    def _newFbufferImage(self) -> tkinter.PhotoImage:

        xdata = self._qsfb_ppm.to_PPM_data()
        
        a_img : tkinter.PhotoImage = tkinter.PhotoImage(width=QSGUI_PIX_WIDTH, height=QSGUI_PIX_HEIGHT, data=bytes(xdata), format='PPM')
        
        # this will be used later to update the canvas
        return a_img.zoom(2,3) 
        
    # ---------------------------------------------------------------------------------
    # 
    def _newShimPanel(self, parent_panel) -> ttk.Frame:

        self.a_img = getResourcesPhotoimage('shim_QS-Kradio-A.png')
        i_w=self.a_img.width()
        i_h=self.a_img.height()
        
        self._println("intro img i_w="+str(i_w)+" i_h="+str(i_h))

#        self.b_img = a_img.subsample(1,1)
#        i_w=self.b_img.width()
#        i_h=self.b_img.height()
#        self._println("intro img i_w="+str(i_w)+" i_h="+str(i_h))
        
        apanel = ttk.Frame(parent_panel, height=i_h, width=i_w)

        self._canvas = tkinter.Canvas(apanel,height = i_h,width = i_w ) #, bg = 'yellow')
        self._canvas.place(x = 0, y = 0)

        # now, CENTER the image at i_w and i_h, note that it is NOT the corner, but the image center        
        self._canvas.create_image(i_w/2,i_h/2,image=self.a_img)

        # ----------------------------- the frame buffer image
        self._qsfb_photoimage = self._newFbufferImage()
        self._radio_bitmap_id = self._canvas.create_image(134,118,image=self._qsfb_photoimage,anchor=tkinter.NW)
        
        # ----------------------------- the mouse motioin
        self._mouse_pos_id = self._canvas.create_text(i_w-40, 7)
        self._canvas.bind("<Motion>", self._mouseMove)
        
        self._canvas.bind("<Button-1>", self._mouseClick)        
        self._canvas.bind("<Button-3>", self._mouseClickAltro)        
        
        
        self._key_click_circle = CanvasCircle(self._canvas,20,20,13)
        self._key_click_circle.set_option(outline='#6D6D6D', width=0, fill='#FFF9EB')

        return apanel

    # -------------------------------------------------------------------------------
    # make a new frame and put an input line in it
    def _newConfigPanel(self, parent):

        a_panel=JtkPanelGrid(parent) # ,padding='3')
        
        # the column with the name takes all the extra space
        a_panel.columnconfigure(1, weight=1)
        
        # this variable will store whatever the user has typed
        self._qsaudio_linein_dev = StringVar(a_panel)
        
        a_panel.addItem(JtkLabel(a_panel,"Audio Input device:"))
        a_panel.addItem(TV_Entry(a_panel, self._qsaudio_linein_dev, width=15))
        
        a_panel.nextRow()
        
        # this variable will store whatever the user has typed
        self._qsaudio_lineout_dev = StringVar(a_panel)
        
        a_panel.addItem(JtkLabel(a_panel,"Audio Output device:"))
        a_panel.addItem(TV_Entry(a_panel, self._qsaudio_lineout_dev, width=15))

        
        return a_panel
    
    # -------------------------------------------------------------------------------
    # make a new frame and put an input line in it
    def _newRadioControlPanel(self, parent):

        a_panel=JtkPanelPackLeft(parent) # ,padding='3')
        
        a_panel.addItem(JtkLabel(a_panel),fill='x',expand=True)
        a_panel.addItem(JtkLabel(a_panel),fill='x',expand=True)
        
        return a_panel
    

    # -------------------------------------------------------------------------------
    # Need to have an idea on what is happening
    def _newStatusPanel(self, parent_panel):

        a_panel=JtkPanelGrid(parent_panel) # ,padding='3')

        a_panel.addItem(JtkLabel(a_panel,"Audio TX packets: "))

        self._udp_TX_counter = 0

        self._udptx_pkcnt_label = JtkLabel(a_panel,"000")
        a_panel.addItem(self._udptx_pkcnt_label)
        
        a_panel.addItem(JtkLabel(a_panel,"Audio RX packets: "))

        self._udprx_pkcnt_label = JtkLabel(a_panel,"000")
        a_panel.addItem(self._udprx_pkcnt_label)

        a_panel.nextRow()

        a_panel.addItem(JtkLabel(a_panel,"Audio Test Tx Hz"))
        
        self._test_F_hz = IntVar(a_panel, 1000)
        a_panel.addItem(TV_Entry(a_panel, self._test_F_hz, width=10))
        a_panel.addItem(JtkButtonText(a_panel,'Start/Stop',command=self._udp_TX_start_beep))
        
        return a_panel

    # -----------------------------------------------------------------------------
    # subclasses call this to show something
    def _inc_UDP_TX_counter(self):
        self._udp_TX_counter += 1
        
        if self._udp_TX_counter > 999:
            self._udp_TX_counter = 0


    # -----------------------------------------------------------------------------
    # https://realpython.com/python-wav-files/#get-to-know-pythons-wave-module
    # play the given wav
    def play_wav_file(self, a_filename ):

        p_audio = self._qpaudio

        try:
            wav_file = wave.open(str(a_filename), 'rb')
            s_width=wav_file.getsampwidth()
            s_format = p_audio.get_format_from_width(s_width)

            # Read first blob of data
            data = wav_file.readframes(512)

            # Open stream
            stream = p_audio.open(format=s_format, channels=wav_file.getnchannels(), rate=wav_file.getframerate(), output=True)
        
            # Play stream
            while len(data) > 0:
                stream.write(data)
                data = wav_file.readframes(512)
        
            # Stop stream
            stream.stop_stream()
            stream.close()
            
            wav_file.close()
            
        except Exception as _exc :
            self._println("play_wav_file: FAIL "+str(_exc))

        

                    


    # -------------------------------------------------------------------------------
    def _print_device_info(self, p_audio : pyaudio.PyAudio, dev_index : int ):

        dev_info = p_audio.get_device_info_by_index(dev_index)
        
        max_ichan_u = dev_info.get('maxInputChannels')
        max_ichan : int = typing.cast(int, max_ichan_u)

        max_ochan_u = dev_info.get('maxOutputChannels')
        max_ochan : int = typing.cast(int, max_ochan_u)

        # because idiotic python can pad a string bute NOT shorten it, talking about {}        
        dev_name = "{:20}".format(dev_info.get('name'))[0:20]
        
        a_string = "{:03d} {:} {: 7d} {: 7d}".format(dev_index,dev_name,max_ichan,max_ochan)
        
        #self._println(str(dev_index)+"\t"+str(dev_name)+"\t"+str(max_ichan))        
        self._println(a_string)


    # -------------------------------------------------------------------------------
    # this is raw info, for debugging purpose
    def _print_pyaudio_info(self ):
        
        self._println("----------------- RAW pyaudio info -----------------")
        
        hapi_count : int = self._qpaudio.get_host_api_count()
        self._println("get_host_api_count: "+str(hapi_count))

        self._println("get_default_host_api_info "+str(self._qpaudio.get_default_host_api_info())+"\n")

        deviceCount = self._qpaudio.get_device_count()

        self._println("get_device_count: "+str(deviceCount))

        self._println("idx name                  max_Ich max_Och")

        for dev_index in range(deviceCount):
            self._print_device_info(self._qpaudio, dev_index)

        self._println(" ")

        self._println("get_default_input_device_info "+str(self._qpaudio.get_default_input_device_info())+"\n")
        self._println("get_default_output_device_info "+str(self._qpaudio.get_default_output_device_info())+"\n")

    # --------------------------------------------------------------
    def _audio_check_compatible(self) -> bool:

        try:
            i_devidx = self._audio_input.dev_index
            o_devidx = self._audio_output.dev_index
    
            res = self._qpaudio.is_format_supported(QSAUDIO_bitrate,input_device=i_devidx, input_channels=2, input_format=pyaudio.paInt16, output_device=o_devidx, output_channels=2, output_format=pyaudio.paInt16)

            self._println("_audio_check_compatible: "+str(res))

            return res

        except Exception as _exc :
            self._println("_audio_check_compatible: FAIL "+str(_exc))
            return False
        

    # --------------------------------------------------------------
    # this will open the devices, since it can be used by the rest of prepper
    # for various beep
    def _open_pyaudio_done(self) -> bool:
        
        try:
            # Initialize PyAudio maing manager
            self._qpaudio = pyaudio.PyAudio()
            
            self._print_pyaudio_info()
            
            self._audio_input = AudioDeviceInfo(self._qpaudio.get_default_input_device_info())
            
            dev_desc = str(self._audio_input)
            self._println("audio input "+dev_desc)
            self._qsaudio_linein_dev.set(dev_desc)
            
            self._audio_output = AudioDeviceInfo(self._qpaudio.get_default_output_device_info())
            
            dev_desc = str(self._audio_output)
            self._println("audio output "+dev_desc)
            self._qsaudio_lineout_dev.set(dev_desc)

            res = self._audio_check_compatible()
            
            return res
        
        except Exception as _exc :
            self._println("_open_pyaudio_done: FAIL"+str(_exc))
            return False
    
    # --------------------------------------------------------------
    # update the GUI statistics every half second, no need to do it more often
    def _update_gui_stats(self):
        self._udptx_pkcnt_label.setText(str(self._udp_TX_counter))
        self._udprx_pkcnt_label.setText(self._udp_rx._fifo_from_udp.get_counter())

    # --------------------------------------------------------------
    def _wait_for_wiradio_ipaddress(self) -> ipaddress.IPv4Address:

        while True:
            n_address : ipaddress.IPv4Address = self.stat.qconnect.get_wiradio_ipv4()

            if n_address:
                self._println("Have Wiradio IP address "+str(n_address))
                return n_address
            
            time.sleep(1)
            
        
    
    # --------------------------------------------------------------
    # Called by the Thread, do NOT call it, use start()
    # I wish this to stop on first error, so I can investigate
    # pippo
    def run(self):
        self._println ("START "+self.name)

        if not self._open_pyaudio_done():
            return

        # for testing
        wav_beep = getResourcesFname('messagy_new_msg.wav')
        self.play_wav_file(wav_beep)

        self._wiradio_ipaddr : ipaddress.IPv4Address =self._wait_for_wiradio_ipaddress()

        # this is a raw socket that does not yet have a sender port
        # it will be set on first TX, wiradio will send data to that port
        self.s_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        
        # The UDP tx can be started and will periodically attempt to send something to wiradio
        self._udp_tx : Qsaudio_UDP_tx = Qsaudio_UDP_tx(self.stat,self) 
        self._udp_tx.start()

        # allocate the rx thread, always active
        self._udp_rx  = Qsaudio_UDP_rx(self.stat, self )
        # do NOT start it here, start it when the socket has a source address
        self._udp_rx.start()

        
        # this, instead will be started on request, either PTT or other
        self._sampler_thread : Qsaudio_sampler_abstract = typing.cast(Qsaudio_sampler_abstract, None)
 

        
        # I can stay here and update the GUI every half second
        while True:
            self._update_gui_stats()
            time.sleep(0.5)

        self._println ("END "+self.name)

    # --------------------------------------------------------------
    # someone can call this one to start the TX thread
    # to stop the thread, set self._stop_sampler = True 
    # NOTE that python CANNOT reuse a task object, you MUST re create it
    def _udp_Tx_start_mic(self):

        if self._sampler_thread and self._sampler_thread.is_alive():
            self._println("_udp_Tx_start_mic: STOPPING previous sampler")
            self._sampler_thread.stop_sampler()
            return
            
        self._sampler_thread = Qsaudio_mic_sampler( self )
        self._sampler_thread.start()

    # -----------------------------------------------------------------------------
    # if there is no TX going on, attempt to send it out
    def _udp_TX_start_beep(self):
        if self._sampler_thread and self._sampler_thread.is_alive():
            self._println("_udp_TX_start_beep: STOPPING previous sampler")
            self._sampler_thread.stop_sampler()
            return
            
        self._sampler_thread = Qsaudio_beep_sampler( self, self._test_F_hz.get() )
        self._sampler_thread.start()




# =============================================================
# Since I have two implementations
# I could have an UDP tx that is enabled on demand and blocks waiting for a rx
# if it has nothing to send .... maybe it should send nothing, since wiradio will play empty
# this means that I can avoid all the magic filling, either there is a buffer to send or not
# then, there are the reader, that put data into what could be a two packets queue
# one reads from mic and put it in the queue, the other generate the sound wave ... 
# the sound wave, will queue a packet as soon as there is space, it is a two packets queue

# so, one thread for UDP tx, that can spin while idling, with a sleep of  half sec
# one thread mic reader and one F generator, they start and stop

class Qsaudio_UDP_tx(Thread):        

    # --------------------------------------------------------------------
    def __init__(self, stat : qpystat.Qpystat, o_parent : Wiremote_gui ):
        Thread.__init__(self,name="Audio UDP Tx",daemon=True)
        
        self._tx_queue : queue.Queue = queue.Queue(2)
        
        self._pause_udp_tx = False

        self.stat = stat
        self._parent = o_parent
        
        self._qaudio_log = o_parent._qaudio_log
        
        self.udp_sk = o_parent.s_socket
        self._VUmeter = self._parent._VUmeter
        
        self._rx_UDP_event = o_parent._rx_UDP_event

    # --------------------------------------------------------------------
    # queue a buffer only if it is free
    def queue_audio_buffer(self, a_buf : bytes, p_block : bool, p_timeout : float ):
        
        try:
            self._tx_queue.put(a_buf, block=p_block, timeout=p_timeout)
        except Exception as _exc:
            pass

    # --------------------------------------------------------------------
    def pause_UDP_tx(self):
        self._pause_udp_tx = True

    # --------------------------------------------------------------------
    def continue_UDP_tx(self):
        self._pause_udp_tx = False

    # --------------------------------------------------------------
    def _println(self, message : str ):
        self._qaudio_log.println(message)

    # --------------------------------------------------------------
    def _get_audio_buffer(self) -> bytearray:

        try:
            return self._tx_queue.get(False)
        except Exception as _exc :
            return bytearray()

    # --------------------------------------------------------------
    def _flush_queue(self):
        
        while self._get_audio_buffer():
            pass

    # --------------------------------------------------------------
    # should loop waiting for data
    def run(self):

        address_str = str(self._parent._wiradio_ipaddr) 

        # this will assign the address to the socket, source address
        self.udp_sk.sendto(bytearray(QSAUDIO_PACKET_BYTES_LEN), (address_str,QSTCP_PORT) )

        while True:

            if not self._rx_UDP_event.wait(2):
                # the idea is that if nothing is received I try to wake up Wiradio
                # it needs to know which port I am sendin from to be able to send anything
                self.udp_sk.sendto(bytearray(QSAUDIO_PACKET_BYTES_LEN), (address_str,QSTCP_PORT) )
                self._parent._inc_UDP_TX_counter() 
                continue

            # I must clear the gate to have another event at the proper timing            
            self._rx_UDP_event.clear()

            if self._pause_udp_tx:
                self._flush_queue()
                self._VUmeter.clear_TX()
                continue   
            
            to_udp = self._get_audio_buffer()
            
            if not to_udp:
                self._VUmeter.clear_TX()
                continue

            self._VUmeter.calc_TX(to_udp)
                               
            self.udp_sk.sendto(to_udp, (address_str,QSTCP_PORT) )   
            
            self._parent._inc_UDP_TX_counter() 




# ========================================================================
# Audio producers should queue data to the udp sender
class Qsaudio_sampler_abstract(Thread):        

    # --------------------------------------------------------------------
    def __init__(self, thread_name : str, o_parent : Wiremote_gui ):
        Thread.__init__(self,name=thread_name,daemon=True)

        self._run_sampler = True

        self.stat = o_parent.stat
        self._parent = o_parent
        
        self._qaudio_log = o_parent._qaudio_log
        
        self._VUmeter = self._parent._VUmeter
        
        self._rx_UDP_event = o_parent._rx_UDP_event
        self._udp_tx_queue = o_parent._udp_tx

    # --------------------------------------------------------------
    def _println(self, message : str ):
        self._qaudio_log.println(message)

    # --------------------------------------------------------------
    # what I normally need is to stop the sampler
    def stop_sampler(self):
        self._run_sampler=False

# =============================================================
# this is a thread that once started will queue data to be sent to wiradio
# the data is queued and will be sent in due time by another thread
# this is done since there is another possible sampler, to generate a tone
# maybe, in future, a way to send a generic audio stream....

class Qsaudio_mic_sampler (Qsaudio_sampler_abstract):
    
    # ----------------------------------------------------------------
    def __init__(self, o_parent : Wiremote_gui ):
        Qsaudio_sampler_abstract.__init__(self,"Qsaudio mic sampler", o_parent )
        
        self._qpyaudio = o_parent._qpaudio;
        self._audio_input = o_parent._audio_input

    # --------------------------------------------------------------
    # Called by the Thread, do NOT call it, use start()
    # I wish this to stop on first error, so I can investigate
    def run(self):

        self._println ("START "+self.name)

        self._audio_stream = self._qpyaudio.open(format=pyaudio.paInt16, channels=1, rate=QSAUDIO_bitrate, frames_per_buffer=QSAUDIO_FRAMES_EACH_PACKET, input=True)

        self._println ("stream opened "+str(self._audio_stream))

        while self._run_sampler:

            # the number of frames is the same, but the total len is different, since this is mono
            audio_mono = self._audio_stream.read(QSAUDIO_FRAMES_EACH_PACKET)

            self._udp_tx_queue.queue_audio_buffer(audio_mono, False, 0)
        
        # Stop stream, eventually....
        self._audio_stream.stop_stream()
        self._audio_stream.close()

        self._println ("END "+self.name)


# =============================================================
# a tone generator that returns int16 values
# one second is scaled to 2PI

class ToneGeneratorInt16():

    # -----------------------------------------------------------
    # frequency in HZ
    def __init__(self, f_hz : int, samples_x_sec : int ):
        self._f_hz = f_hz
        
        # this is how much I must add to cur_x to go to next sample 
        self._deltax = (2.0*math.pi*f_hz) / samples_x_sec
        
    # -----------------------------------------------------------
    # an iterator starts from the beginning
    def __iter__(self):
        self._cur_x=0.0
        self._v_prev=0
        return self
    
    # -----------------------------------------------------------
    # Once a full cycle has passed, everything begins again
    # wait, no, the wrap is when x is "zero" either MCM or MCD ...
    # need to sleep over it
    def __next__(self):
            
        v_out = round(math.sin(self._cur_x) * 2000.0)

        # I can only reset when coming from a negative slope
        # otherwise I hear a glitch since I am inverting the slope
        if v_out == 0 and self._v_prev < 0:
            self._cur_x = 0.0

        self._v_prev = v_out
        self._cur_x = self._cur_x + self._deltax
         
        return round(v_out)



# =============================================================
# this is a thread that once started will sends a beep to wiradio
# once the beep is sent, it will terminate
class Qsaudio_beep_sampler (Qsaudio_sampler_abstract):
    
    # ----------------------------------------------------------------
    def __init__(self, o_parent : Wiremote_gui, beep_F_hz : int ):
        Qsaudio_sampler_abstract.__init__(self,"Qsaudio beep sampler", o_parent )

        self._tonegen : ToneGeneratorInt16 = ToneGeneratorInt16(beep_F_hz,QSAUDIO_bitrate)
        
        self._tone_iter = iter(self._tonegen)

    # ----------------------------------------------------------------
    def _queue_one(self):

        to_buf : bytearray = bytearray(QSAUDIO_FRAMES_EACH_PACKET*QSAUDIO_bytes_xsample)

        for p_x in range(0, QSAUDIO_FRAMES_EACH_PACKET):
            
            a_v = next(self._tone_iter)
            
            struct.pack_into('<h', to_buf, p_x*2, a_v) 
            
        self._udp_tx_queue.queue_audio_buffer(to_buf, True, 1)

    # --------------------------------------------------------------
    # Called by the Thread, do NOT call it, use start()
    # I wish this to stop on first error, so I can investigate
    def run(self):

        self._println ("START "+self.name)

        while self._run_sampler:
            self._queue_one()

        self._println ("END "+self.name)














# =============================================================
# this is a thread that once started will listen on the port and print what it receives
# Having two thread is slightly inefficient.... but logically simpler

class Qsaudio_UDP_rx (Thread):
    
    # ----------------------------------------------------------------
    def __init__(self, stat : qpystat.Qpystat, o_parent : Wiremote_gui ):
        Thread.__init__(self,name="Qsaudio UDP rx",daemon=True)
        
        self.stat = stat
        self._parent = o_parent
        
        self._qaudio_log = o_parent._qaudio_log
        self._p_audio = o_parent._qpaudio
        
        self.udp_sk = o_parent.s_socket
        self._fifo_from_udp = ByteFifo_padded()
        
        self._VUmeter = self._parent._VUmeter
        self._rx_UDP_event = self._parent._rx_UDP_event

    # --------------------------------------------------------------
    def _println(self, message : str ):
        self._qaudio_log.println(message)

    # --------------------------------------------------------------
    # this is when something is received from UDP
    # data must be queued to received buffer
    def _rx_one(self):
        message, _address = self.udp_sk.recvfrom(2048)
        
        # it is actually a release, so, the TX thread can send
        self._rx_UDP_event.set()
        
        self._VUmeter.calc_RX(message)
        
        self._fifo_from_udp.put(message)
        
        self._fifo_from_udp.inc_counter()
        
    # --------------------------------------------------------------
    # Called by the Thread, do NOT call it, use start()
    def run(self):
        self._println ("START "+self.name)

        self._audio_stream = self._p_audio.open(format=pyaudio.paInt16, channels=2, rate=QSAUDIO_bitrate, frames_per_buffer=QSAUDIO_FRAMES_EACH_PACKET, output=True, start=False)
        
        self._audio_writer = AudioWriterToHeadphones(self._audio_stream, self._fifo_from_udp )
        self._audio_writer.start()
        
        while True:
            self._rx_one()

        self._println ("END "+self.name)

# ========================================================================
# apparently, in callback mode, it keeps complaining of underrun
# this version does not complain at all...
class AudioWriterToHeadphones(Thread):

    def __init__(self, a_stream : pyaudio.Stream, fifo_from_udp : ByteFifo_padded ):
        Thread.__init__(self,name="Qsaudio Writer to Headphones",daemon=True)
        
        self.a_stream : pyaudio.Stream = a_stream
        self._fifo_from_udp = fifo_from_udp

    # -------------------------------------------------------------------        
    def run(self):
        
        # does not work when the stream is NOT started
        #f_prefill = self.a_stream.get_write_available()
        
        self.a_stream.start_stream()
        
        while True:
            out_data = bytes(self._fifo_from_udp.get_padded(QSAUDIO_FRAMES_EACH_PACKET*4)) 

            self.a_stream.write(out_data,QSAUDIO_FRAMES_EACH_PACKET)
        
        
        
# =========================================================
# possibly inefficient class that implements a byte fifo
# the idea is that when I do a get I will return in any case the amount of bytes requested
        
class ByteFifo_padded:

    # --------------------------------------------------------
    def __init__(self):
        self._buf  = bytearray()
        self._lock = threading.Lock()
        self._counter=0
        
    # --------------------------------------------------------
    # for statistics
    def get_counter(self) ->str:
        return str(self._counter)
        
    # --------------------------------------------------------
    # call it anytime you do an operation
    def inc_counter(self):
        self._counter += 1

        if self._counter > 1000:
            self._counter = 0


    # --------------------------------------------------------
    # this put data at the end
    # if no data, append nothing
    def put(self, data):
        
        if data:
            self._lock.acquire(True)
            self._buf.extend(data)
            self._lock.release()
            
    # --------------------------------------------------------
    # if there are less data than requested, the result is padded
    def get_padded(self, size) -> bytearray:
        
        self._lock.acquire(True)

        missing = size - len(self._buf)
        
        if missing > 0:
            # can do this since I will create e new istance 
            risul = self._buf
            risul.extend(bytes(missing))  
            # detach the current buffer 
            self._buf=bytearray()
        else:
            # now I am sure I have the desired data
            risul = self._buf[:size]
            # The fast delete syntax
            self._buf[:size] = b''
        
        self._lock.release()
            
        return risul

    # --------------------------------------------------------
    # this gets what is in there, NO padding
    def get(self) -> bytes:
        
        self._lock.acquire(True)
             
        # now I am sure I have the desired data
        data = bytes(self._buf)
        
        # The fast delete syntax
        self._buf.clear()
        
        self._lock.release()
            
        return data

    # --------------------------------------------------------------
    def __len__(self):
        return len(self._buf)    
            
# ------------------------------------------------------------------
# I just wish to have pieces documented
class AudioDeviceInfo():
    
    # -------------------------------------------------------------
    # AHHHHHH, DAMMED python, now, typing enforce an idiotic definition of subtypes
    # because it was not enough to reinvent a circle into an exagon, it need to be a rectangle
    # so.... no types on p_dict
    def __init__(self, p_dict ):
        self.dev_index = int(p_dict['index'])
        self.dev_name = str(p_dict['name'])
        
        
    # --------------------------------------------------
    # this is the equivalent of toString()
    def __str__(self):
        return str(self.dev_index)+':'+str(self.dev_name)
    
        
# ===================================================================
# a virtual button is one that has a center coordinated and a method to call
# plus methods to know if we are over it
class WireVirtualClick():
    
    KEVENT_key_click = 1
    KEVENT_key_release = 2
    KEVENT_key_longpress = 3

    # ----------------------------------------------------------------
    def __init__(self, parent : Wiremote_gui, p_x : int, p_y : int, b_radius : int ):
        self._parent = parent
        self._p_x = p_x
        self._p_y = p_y
        self._b_radius = b_radius
        
    # ----------------------------------------------------------------
    def _calcKevent(self, modifier : int, k_value : int ):
        return modifier << 5 | k_value
        
    # ----------------------------------------------------------------
    # return True if the mouse is over the virtual button
    def is_point_over(self, h_x : int, h_y : int ) -> bool:
        
        d_x = h_x - self._p_x
        d_y = h_y - self._p_y
        
        square = d_x*d_x + d_y * d_y
        
        d_l = math.sqrt(square)
        
        return d_l < self._b_radius

    # ----------------------------------------------------------------
    # subclasses must implement whatever is needed to do a click
    def do_click(self, is_other_click : bool):
        pass
    
# ====================================================================
# PTT must switch on or off at each click, possibly configurable...    
class WireVirtualClick_Ptt(WireVirtualClick):    
        
    # ----------------------------------------------------------------        
    def __init__(self, parent : Wiremote_gui, p_x : int, p_y : int, b_radius : int ):
        WireVirtualClick.__init__(self, parent, p_x, p_y, b_radius)
        
        self.is_ptt_click = False
        
    # -----------------------------------------------------------------        
    def do_click(self, _is_other_click : bool):
        if self.is_ptt_click:

            self._parent._println("WireVirtualClick_Ptt: request OFF")

            key_event_menu = self._calcKevent(self.KEVENT_key_release, 1);
            cmd = Qsk5_req_set_radio_params(0,0,key_event_menu)
            self._parent.stat.qconnect.queuePut(cmd)
            self._parent._sampler_thread._run_sampler = False
            self.is_ptt_click = False
        else:
            self._parent._println("WireVirtualClick_Ptt: request ON")

            key_event_menu = self._calcKevent(self.KEVENT_key_click, 1);
            cmd = Qsk5_req_set_radio_params(0,0,key_event_menu)
            self._parent.stat.qconnect.queuePut(cmd)
            self._parent._udp_Tx_start_mic()
            self.is_ptt_click = True
            

# ====================================================================
# On each click, send the given keycode    
class WireVirtualClick_Key(WireVirtualClick):    
        
    # ----------------------------------------------------------------        
    def __init__(self, parent : Wiremote_gui, p_x : int, p_y : int, b_radius : int, key_value : int ):
        WireVirtualClick.__init__(self, parent, p_x, p_y, b_radius)
        
        self._key_value = key_value
        
    # -----------------------------------------------------------------        
    def do_click(self, _is_other_click : bool ):

        self._parent._println("WireVirtualClick_Key: "+str(self._key_value))

        key_event_menu = self._calcKevent(self.KEVENT_key_click, self._key_value);

        cmd = Qsk5_req_set_radio_params(0,0,key_event_menu)
        self._parent.stat.qconnect.queuePut(cmd)

            

# ====================================================================
# Fkey must send a key click AND a key release    
class WireVirtualClick_Timed(WireVirtualClick):    
        
    
    # ----------------------------------------------------------------        
    def __init__(self, parent : Wiremote_gui, p_x : int, p_y : int, b_radius : int, k_value : int ):
        WireVirtualClick.__init__(self, parent, p_x, p_y, b_radius)
        
        self._k_value = k_value
        
    # -----------------------------------------------------------------
    # a key that is a timed key has to send a click and release to have an activation 
    # See enum IPPU_key_event_e, the topmost three bits are an event       
    def do_click(self, is_other_click : bool ):

        self._parent._println("WireVirtualClick_Timed: Fkey "+str(self._k_value)+' '+str(is_other_click))

        key_event_menu = self._calcKevent(self.KEVENT_key_click, self._k_value);
        cmd = Qsk5_req_set_radio_params(0,0,key_event_menu)
        self._parent.stat.qconnect.queuePut(cmd)

        if is_other_click:
            key_event_menu = self._calcKevent(self.KEVENT_key_longpress, self._k_value);
            cmd = Qsk5_req_set_radio_params(0,0,key_event_menu)
            self._parent.stat.qconnect.queuePut(cmd)

        key_event_menu = self._calcKevent(self.KEVENT_key_release, self._k_value);
        cmd = Qsk5_req_set_radio_params(0,0,key_event_menu)
        self._parent.stat.qconnect.queuePut(cmd)

# =============================================================
# this is a thread that once started will send commands and receive response from Wiradio
# If connection cannot be made, it should be restarted
# NOTE that there is the issue of IP change !
# there should be a timeout to avoid getting stuck....

    
# ==============================================================
# I need a way to visually show if anything is arriving
# it is a panel that is on a single line and packed left
class Qsaudio_VUmeter(JtkPanelGrid):
    
    # ------------------------------------------------------------------------
    # You can decide the max number of lines to show
    # to pad contente you can use padding="3p.3p" or you can set padx=3, pady=3
    # Note that the name will be used to save the log content into a file 
    def __init__(self, parent_panel : ttk.Widget ):
        
        JtkPanelGrid.__init__(self, parent_panel, borderwidth=1, relief='solid', padding=3) 

        self.columnconfigure(1, weight = 1)   # since the other is zero, this will get all the extra space

        self.rx_value = 0    # this is what will be adjusted by the updater
        self.tx_value = 0

        w_font_bold = ('Consolas',10,'bold')

        self._rx_vLabel = JtkLabel(self, font=w_font_bold)
        self.addItem(self._rx_vLabel,sticky='e')
        self._rx_vBar = ttk.Progressbar(self, orient='horizontal', mode='determinate', maximum=1600)
        self.addItem(self._rx_vBar)

        self.nextRow()

        self._tx_vLabel = JtkLabel(self, font=w_font_bold)
        self.addItem(self._tx_vLabel,sticky='e')
        self._tx_vBar = ttk.Progressbar(self, orient='horizontal', mode='determinate', maximum=1600)
        self.addItem(self._tx_vBar)
        
        # kick the priodic update
        self.after(500, self._swing_update_Vumeter)
        
    # -----------------------------------------------------------------
    def _swing_update_Vumeter(self):
        self._rx_vBar['value'] = self.rx_value 
        self._rx_vLabel.setText("Audio Rx {:5d}".format(self.rx_value))

        self._tx_vBar['value'] = self.tx_value 
        self._tx_vLabel.setText("Audio Tx {:5d}".format(self.tx_value))

        # need to book the next event
        self.after(500, self._swing_update_Vumeter)
        
    # -----------------------------------------------------------------
    def calc_RX (self, received_stereo):

        # need to decode a signed short
        iter_lr=struct.iter_unpack('<hh', received_stereo)
        
        a_sum = 0
        a_count = 0
        
        for l_v,_r_v in iter_lr:
            if l_v >=0:
                a_sum = a_sum + l_v 
                a_count = a_count + 1

        self.rx_value = 0
        
        if a_count > 0:
            self.rx_value = int(a_sum/a_count)

    # -----------------------------------------------------------------
    def calc_TX (self, received_mono):

        # need to decode a signed short
        iter_lr=struct.iter_unpack('<h', received_mono)
        
        a_sum = 0
        a_count = 0
        
        for a_touple in iter_lr:
            l_v = a_touple[0]
            if l_v >=0:
                a_sum = a_sum + l_v 
                a_count = a_count + 1

        self.tx_value = 0
        
        if a_count > 0:
            self.tx_value = int(a_sum/a_count)
            
    # -----------------------------------------------------------------
    # TX can be switched off
    def clear_TX(self):
        self.tx_value = 0            
        
        
        
        
           
