#!/bin/python

from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .mbio import MBIOGateway


from packaging.version import Version
import re
import ipcalc
import requests

from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)

from .netscan import MBIONetworkScanner
from .mbiosocket import MBIOSocketString

from .device import MBIODevice
from prettytable import PrettyTable
import pprint
import pathlib

from .xmlconfig import XMLConfig


class MBIODeviceDigimatSIO(MBIODevice):
    MAXNBBOARD=6
    NBCHANNELDI=4
    NBCHANNELDO=4
    NBCHANNELAI=8
    NBCHANNELAO=4
    MAXNBMPBUSDEVICES=0
    AO_RAMP_REGISTER_OFFSET=13
    BOARDS_REGISTER=172
    WATCHDOG_REGISTER=171
    SAVEPOINT_REGISTER=199

    def onInit(self):
        self._vendor='Digimat'
        self._model='SIO'
        self._timeoutRefreshSlow=0
        self._timeoutRefreshDI=0
        self._timeoutRefreshDO=0
        self._timeoutRefreshAI=0
        self._timeoutRefreshAO=0
        self._timeoutRefreshMPBUS=0

        self.setPingInputRegister(98)

        self.config.set('watchdog', 60)
        self.config.set('boards', 1)
        self.config.set('mpbus', False)
        self.config.set('extended', False)

        self.DI=[]
        self.DO=[]
        self.AI=[]
        self.AO=[]
        self.MPBUS={}
        self.LEDR=[]
        self.LEDG=[]
        self.LEDB=[]
        self.BUZZER=None
        self.T=None
        self.UPTIME=None

    def AO_raw2state(self, value, raw):
        if raw is not None:
            state=raw/10000*100.0
            lrange=value.config.lrange
            hrange=value.config.hrange
            if lrange>0 or hrange<100:
                state=max(lrange, state)
                state=min(hrange, state)
                state=(state-lrange)/(hrange-lrange)*100
            if value.isInverted():
                state=100.0-state
            return state

    def AO_state2raw(self, value, state):
        if state is not None:
            lrange=value.config.lrange
            hrange=value.config.hrange
            state=min(state, value.config.max)
            state=max(state, value.config.min)
            if value.isInverted():
                state=100.0-state
            if lrange>0 or hrange<100:
                raw=10000/100.0*(lrange+(hrange-lrange)*state/100.0)
            else:
                raw=state/100.0*10000
            return int(raw)

    def getBoardConfigRegister0(self, board):
        return 35+(17*board)

    def getBoardStatusRegister0(self, board):
        return 0+board*50

    def getBoardMPBUSStatusRegister0(self, board, address):
        # FIXME: the SIO architecture don't supports yet a MPBUS extension other that on board 0
        if board<1:
            device=address-1
            if device>=0 and device<self.MAXNBMPBUSDEVICES:
                return 550+450*board+device*50
        return 0

    def getMPBusModelFromName(self, name):
        models={'epiv': 1, 'ep015r2': 1, 'vav': 2, 'nmv-d3': 2}
        if name is not None:
            return models.get(name)
        return None

    def getMPBusValueName(self, board, address, name, vtype, tag):
        if tag:
            return f'mp{board}_{vtype}{address}{tag}_{name}'
        return f'mp{board}_{vtype}{address}_{name}'

    # EPIV
    def loadMPBusDeviceModel1(self, xml: XMLConfig, board, address, data, tag):
        vtype='epiv'
        data['type']=vtype
        data['nbstatusreg']=21
        data['refresh']=10
        data['vunit']=xml.get('vunit', 'l/s')

        name='spr'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit='%', resolution=0.1,
                commissionable=True,
                shortKey=True)
        data['values'][name]=value

        name='spa'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit=data['vunit'], resolution=0.1,
                commissionable=True,
                shortKey=True)
        data['values'][name]=value

        name='pos'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit='%', resolution=0.1,
                commissionable=False,
                shortKey=True)
        data['values'][name]=value

        name='vmin'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit=data['vunit'], resolution=0.1,
                commissionable=False,
                shortKey=True)
        data['values'][name]=value

        name='vmax'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit=data['vunit'], resolution=0.1,
                commissionable=False,
                shortKey=True)
        data['values'][name]=value

        name='vnom'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit=data['vunit'], resolution=0.1,
                commissionable=False,
                shortKey=True)
        data['values'][name]=value

        name='vr'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit='%', resolution=0.1,
                commissionable=True,
                shortKey=True)
        data['values'][name]=value

        name='va'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit=data['vunit'], resolution=0.1,
                commissionable=True,
                shortKey=True)
        data['values'][name]=value

        name='t'
        value=self.value(self.getMPBusValueName(board, address, name, vtype, tag),
                unit='C', resolution=0.1,
                commissionable=False,
                shortKey=True)

        data['values'][name]=value

    # VAV
    def loadMPBusDeviceModel2(self, xml: XMLConfig, board, address, data, tag):
        vtype='vav'
        data['type']=vtype
        data['refresh']=10
        pass

    def loadMPBusDeviceModel(self, xml: XMLConfig, model, board, address, data, tag):
        if model==1:
            self.loadMPBusDeviceModel1(xml, board, address, data, tag)
        elif model==2:
            self.loadMPBusDeviceModel2(xml, board, address, data, tag)

    def loadMPBusDevice(self, xml: XMLConfig, board: int, device: int) -> dict:
        item=xml.child(f'mp{device+1}')
        if item:
            address=device+1
            modelname=item.get('model')
            tag=item.get('tag')
            model=self.getMPBusModelFromName(modelname)
            if model is not None:
                data={'address': address, 'tag': tag,
                      'model': model, 'modelname': modelname,
                      'error': False,
                      'values': {},
                      'timeout': 0,
                      'comerrcount': 0,
                      'refresh' : 60,
                      'nbstatusreg': 49}
                self.loadMPBusDeviceModel(item, model, board, address, data, tag)
                return data

        return {}

    def loadMPBus(self, xml: XMLConfig):
        if xml is None or not xml.hasChild('mpbus'):
            return

        for item in xml.children('mpbus'):
            board=item.getInt('board', 0)
            if board>=0 and board<self.MAXNBBOARD:
                self.config.update('mpbus', True)
                self.MPBUS[board]={'board': board,
                                   'devices': {},
                                   'comerr': self.valueDigital(f'mp{board}_comerr', default=False, shortKey=True)}

                for device in range(self.MAXNBMPBUSDEVICES):
                    data=self.loadMPBusDevice(item, board, device)
                    address=data.get('address')
                    if address is not None and address>0:
                        self.MPBUS[board]['devices'][address]=data
                        self.config.update('mpbus', True)

        self.logger.warning(self.MPBUS)

    def onLoad(self, xml: XMLConfig):
        self.config.update('watchdog', xml.getInt('watchdog'))
        self.config.update('boards', xml.getInt('boards', vmin=1, vmax=self.MAXNBBOARD))
        self.config.update('extended', xml.getBool('extended'))
        if self.config.extended:
            self.NBCHANNELDI=8

        for board in range(self.config.boards):
            self.LEDR.append(self.valueDigital('ledr%d' % board, writable=True))
            self.LEDG.append(self.valueDigital('ledg%d' % board, writable=True))
            self.LEDB.append(self.valueDigital('ledb%d' % board, writable=True))

        self.BUZZER=self.valueDigital('buzzer', writable=True)
        self.T=self.value('t', unit='C', resolution=0.1)
        self.UPTIME=self.value('up', unit='h', resolution=0.1)

        for channel in range(self.NBCHANNELDI*self.config.boards):
            value=self.valueDigital('di%d' % channel, commissionable=True)
            self.DI.append(value)

            item=xml.child(value.name)
            if item:
                if not item.getBool('enable', True):
                    value.disable()
                value.config.set('zone', item.get('zone'))
                if item.getBool('invert'):
                    value.invert()

        if self.NBCHANNELDO>0:
            for channel in range(self.NBCHANNELDO*self.config.boards):
                value=self.valueDigital('do%d' % channel, writable=True, commissionable=True)
                value.config.set('default', None)
                self.DO.append(value)

                item=xml.child(value.name)
                if item:
                    if not item.getBool('enable', True):
                        value.disable()
                    value.config.set('zone', item.get('zone'))
                    value.config.xmlUpdateBool(item, 'default')
                    if item.getBool('invert'):
                        value.invert()

        for channel in range(self.NBCHANNELAI*self.config.boards):
            value=self.value('ai%d' % channel, commissionable=True)
            value.config.set('type', '10v')
            value.config.set('resolution', 0.1)
            value.config.set('offset', 0)
            value.config.set('voffset', 0)
            self.AI.append(value)

            item=xml.child(value.name)
            if item:
                if not item.getBool('enable', True):
                    value.disable()
                value.config.set('zone', item.get('zone'))
                value.config.xmlUpdate(item, 'type')
                value.config.xmlUpdateFloat(item, 'resolution', vmin=0)
                value.config.xmlUpdateFloat(item, 'offset')
                if value.config.contains('type', '10v'):
                    value.config.xmlUpdateFloat(item, 'voffset')
                    value.config.set('unit', 'V')
                    value.config.xmlUpdate(item, 'unit')
                    if value.config.type=='2-10v':
                        value.config.set('x0', 2.0)
                    else:
                        value.config.set('x0', 0.0)
                    value.config.xmlUpdateFloat(item, 'x0', vmin=0)
                    value.config.set('x1', 10.0)
                    value.config.xmlUpdateFloat(item, 'x1', vmin=value.config.x0, vmax=10)
                    value.config.set('y0', 0.0)
                    value.config.xmlUpdateFloat(item, 'y0')
                    value.config.set('y1', 10.0)
                    value.config.xmlUpdateFloat(item, 'y1', vmin=value.config.y0)
                if value.config.contains('type', 'pot'):
                    value.config.set('unit', 'ohm')
                    value.config.xmlUpdate(item, 'unit')
                    value.config.set('x0', 0.0)
                    value.config.xmlUpdateFloat(item, 'x0')
                    value.config.set('x1', 10000.0)
                    value.config.xmlUpdateFloat(item, 'x1')
                    value.config.set('y0', 0.0)
                    value.config.xmlUpdateFloat(item, 'y0')
                    value.config.set('y1', 10000.0)
                    value.config.xmlUpdateFloat(item, 'y1')
                if value.config.contains('type', '20ma'):
                    value.config.set('unit', '%')
                    value.config.xmlUpdate(item, 'unit')
                    value.config.set('y0', 0.0)
                    value.config.xmlUpdateFloat(item, 'y0')
                    value.config.set('y1', 100.0)
                    value.config.xmlUpdateFloat(item, 'y1', vmin=value.config.y0)
                if value.config.contains('type', '100%'):
                    value.config.set('unit', '%')
                    value.config.xmlUpdate(item, 'unit')
                    if value.config.type=='20-100%':
                        value.config.set('x0', 2.0)
                    else:
                        value.config.set('x0', 0.0)
                    value.config.xmlUpdateFloat(item, 'x0', vmin=0)
                    value.config.set('x1', 10.0)
                    value.config.xmlUpdateFloat(item, 'x1', vmin=value.config.x0, vmax=10)
                    value.config.set('y0', 0.0)
                    value.config.xmlUpdateFloat(item, 'y0')
                    value.config.set('y1', 100.0)
                    value.config.xmlUpdateFloat(item, 'y1', vmin=value.config.y0)

            value.resolution=value.config.resolution

        for channel in range(self.NBCHANNELAO*self.config.boards):
            value=self.value('ao%d' % channel, unit='%', resolution=1, writable=True, commissionable=True)
            value.setRange(0, 100)
            value.config.set('default', None)
            value.config.set('lrange', 0)
            value.config.set('hrange', 100)
            value.config.set('resolution', 1)
            value.config.set('min', 0)
            value.config.set('max', 100)
            value.config.set('ramp', 30)
            self.AO.append(value)

            item=xml.child(value.name)
            if item:
                if not item.getBool('enable', True):
                    value.disable()
                value.config.set('zone', item.get('zone'))
                value.config.xmlUpdateFloat(item, 'default')
                if item.getBool('invert'):
                    value.invert()
                value.config.xmlUpdateFloat(item, 'lrange', vmin=0, vmax=100)
                value.config.xmlUpdateFloat(item, 'hrange', vmin=value.config.lrange, vmax=100)
                value.config.xmlUpdateFloat(item, 'resolution', vmin=0)
                value.config.xmlUpdateInt(item, 'min', vmin=0, vmax=100)
                value.config.xmlUpdateInt(item, 'max', vmin=value.config.min, vmax=100)
                value.config.xmlUpdateInt(item, 'ramp', vmin=0)
                value.config.xmlUpdateInt(item, 'ramp0', vmin=0)
                value.config.xmlUpdateInt(item, 'ramp1', vmin=0)
            value.resolution=value.config.resolution

        self.loadMPBus(xml)

    def poweronDI(self):
        pass

    def poweronDO(self):
        if self.NBCHANNELDO==0:
            return

        for board in range(self.config.boards):
            configRegister0=self.getBoardConfigRegister0(board)
            channel0=board*self.NBCHANNELDO
            data=0x0
            for channel in range(self.NBCHANNELDO):
                value=self.DO[channel0+channel]
                if value.config.default is not None:
                    state=value.config.default
                    if value.isInverted():
                        state=not state
                    if state:
                        data |= (0b01 << 2*channel)
                    else:
                        data |= (0b00 << 2*channel)
                else:
                    data |= (0b10 << 2*channel)
            self.writeRegistersIfChanged(configRegister0+8, data)

    def poweronAI(self):
        for board in range(self.config.boards):
            configRegister0=self.getBoardConfigRegister0(board)
            channel0=board*self.NBCHANNELAI
            for channel in range(self.NBCHANNELAI):
                value=self.AI[channel0+channel]
                data=0x0
                if value.config.type is not None:
                    if value.config.type=='+pt1000':
                        data |= 0b11
                        data |= (0b10 << 6)
                        value.unit='C'
                    elif value.config.type=='+pt100':
                        data |= 0b11
                        data |= (0b01 << 6)
                        value.unit='C'
                    elif value.config.type=='ntc':
                        data |= 0b00
                        data |= (0b01 << 6)
                        value.unit='C'
                    elif value.config.type=='+ni1000':
                        data |= 0b11
                        data |= (0b11 << 6)
                        value.unit='C'
                    elif '20ma' in value.config.type:
                        data |= 0b01
                        data |= (0b01 << 6)
                        value.unit='%'
                        if value.config.get('unit'):
                            value.unit=value.config.unit
                    elif '10v' in value.config.type:
                        data |= 0b10
                        data |= (0b00 << 6)
                        value.unit='V'
                        # allow custom unit
                        if value.config.get('unit'):
                            value.unit=value.config.unit
                    elif 'pot' in value.config.type:
                        data |= 0b10
                        data |= (0b00 << 6)
                        value.config.set('r2t', 'r')
                        value.unit='ohm'
                        # allow custom unit
                        if value.config.get('unit'):
                            value.unit=value.config.unit
                    elif value.config.type=='ohm':
                        data |= 0b11
                        data |= (0b00 << 6)
                        value.config.set('r2t', 'r')
                        value.unit='ohm'
                    elif value.config.type=='ohm10k':
                        data |= 0b00
                        data |= (0b00 << 6)
                        value.config.set('r2t', 'r')
                        value.unit='ohm'
                    elif value.config.type in ['pt1000', 'pt100', 'ni1000', 'ni1000-5k']:
                        # the conversion will be made by the mbio processor
                        # so, the ai channel is set to "ohm" mode
                        data |= 0b11
                        data |= (0b00 << 6)
                        value.config.set('r2t', value.config.type)
                        value.unit='C'
                else:
                    # +PT1000
                    value.config.type='pt1000'
                    data |= 0b11
                    data |= (0b00 << 6)
                    value.config.set('r2t', value.config.type)
                    value.unit='C'
                self.writeRegistersIfChanged(configRegister0+0+channel, data)

    def poweronAO(self):
        for board in range(self.config.boards):
            configRegister0=self.getBoardConfigRegister0(board)
            channel0=board*self.NBCHANNELAO
            for channel in range(self.NBCHANNELAO):
                value=self.AO[channel0+channel]
                # default value
                data=0x0
                if value.config.default is not None:
                    v=self.AO_state2raw(value, value.config.default) / 100.0
                    data |= (int(v) & 0xff)
                else:
                    data |= (0b1 << 8)
                self.writeRegistersIfChanged(configRegister0+9+channel, data)
                # ramp
                data=0x0
                v0=max(min(value.config.ramp0 or value.config.ramp or 0, 255), 0)
                v1=max(min(value.config.ramp1 or value.config.ramp or 0, 255), 0)
                data=(v1 & 0xff) | ((v0 & 0xff) << 8)
                self.writeRegistersIfChanged(configRegister0+self.AO_RAMP_REGISTER_OFFSET+channel, data)

    def poweronMPBus(self):
        if self.config.mpbus:
            for board in self.MPBUS.keys():
                self.logger.debug(f'Enabling SIO {self.key} MPBUS{board}...')
                configRegister0=self.getBoardConfigRegister0(board)
                # MPBUS extension module
                extensionType=1
                enableRegister=0
                self.writeRegistersIfChanged(configRegister0+25, extensionType)
                for device in self.MPBUS[board]['devices'].values():
                    address=device['address']
                    device['timeout']=0
                    enableRegister |= (0x1<<(address-1))
                    model=device['model']
                    modelname=device['modelname']
                    self.logger.debug(f'Enabling SIO {self.key} MPBUS{board} device {address}:{modelname}:{model}...')
                    self.writeRegistersIfChanged(configRegister0+27+address-1, model)
                self.writeRegistersIfChanged(configRegister0+26, enableRegister)

    def poweron(self):
        self.UPTIME.updateValue(0)

        # set number of SIO boards
        self.writeRegistersIfChanged(self.BOARDS_REGISTER, self.config.boards)
        if self.config.watchdog is not None and self.config.watchdog>0:
            data=max(min(int(self.config.watchdog), 255), 0)
            self.writeRegistersIfChanged(self.WATCHDOG_REGISTER, data & 0xff)
        else:
            self.writeRegistersIfChanged(self.WATCHDOG_REGISTER, 0)

        self.poweronDI()
        self.poweronDO()
        self.poweronAI()
        self.poweronAO()
        self.poweronMPBus()

        return True

    def poweronsave(self):
        self.writeRegisters(self.SAVEPOINT_REGISTER, 0xAAAA)

    def isreadytorun(self):
        r=self.readHoldingRegisters(self.SAVEPOINT_REGISTER)
        if r and r[0]==0x5555:
            return True
        return False

    def poweroff(self):
        return True

    def refreshDI(self):
        pass

    def refreshDO(self):
        pass

    def refreshAI(self):
        pass

    def refreshAO(self):
        pass

    def convertFlowToUserUnit(self, v, deviceunit, unitstr):
        try:
            # deviceunit table (belimo) conversion factor to normalized m3/h
            toM3H={0: 3600, 1: 1.0, 2: 3.6, 3: 0.06, 4: 1000.0}
            v0=v*toM3H.get(deviceunit, 1.0)

            if unitstr=='m3/h':
                return v0
            if unitstr=='l/s':
                return v0*0.277778
            if unitstr=='l/h':
                return v0*1000.0
        except:
            pass

        return v

    # EPIV
    def decodeMPBusDeviceDataModel1(self, data, decoder):
        # self.logger.warning(decoder.registers())

        status=decoder.word(8)
        verror=status & (0b11010101101)
        vmanual=status & (0b10)
        deviceunit=(decoder.word(13)>>8) & 0xff

        pos=decoder.word(0)/100.0
        value=data['values']['pos']
        value.updateValue(pos)
        value.setError(verror)
        value.setOverride(vmanual)

        value=data['values']['vr']
        v=decoder.word(3)/100.0
        value.updateValue(v)
        value.setError(verror)
        value.setOverride(vmanual)

        value=data['values']['va']
        userunitstr=value.unitstr()
        v=self.convertFlowToUserUnit(v, deviceunit, userunitstr)
        value.updateValue(v)
        value.setError(verror)
        value.setOverride(vmanual)

        value=data['values']['t']
        t=decoder.int16(7)/100.0
        value.updateValue(t)
        value.setError(False)

        # alwasy in l/s (deviceunit 2)
        value=data['values']['vnom']
        vnom=decoder.word(12)/100.0
        vnom=self.convertFlowToUserUnit(vnom, 2, userunitstr)
        value.updateValue(vnom)
        value.setError(False)

        # store additional data to vnom's config
        value.config.set('vdeviceunit', deviceunit)

        # not absolutely useful
        value.config.set('mpserial1', hex(decoder.dword(14)))
        value.config.set('mpserial2', hex(decoder.dword(18)))
        value.config.set('mpstatus', hex(status))

        value=data['values']['vmax']
        vmax=decoder.word(11)/10000.0*vnom
        value.updateValue(vmax)
        value.setError(False)

        value=data['values']['vmin']
        vmin=decoder.word(10)/10000.0*vmax
        value.updateValue(vmin)
        value.setError(False)

        value=data['values']['spr']
        sp=decoder.word(2)/100.0
        value.updateValue(sp)
        value.setError(False)

        value=data['values']['spa']
        sp=decoder.word(2)/10000.0
        spa=(vmax-vmin)*sp+vmin
        value.updateValue(spa)
        value.setError(False)

        #TODO:
        return True

    def decodeMPBusDeviceData(self, board, address, decoder):
        try:
            data=self.MPBUS[board]['devices'][address]
            model=data['model']
            if model==1:
                return self.decodeMPBusDeviceDataModel1(data, decoder)

            #TODO:

        except:
            self.logger.exception('mpbus')

        self.logger.debug(f'Error decoding SIO {self.key} MPBUS{board} device {address}:{model} data...')
        return False

    def refreshMPBus(self):
        if not self.config.mpbus:
            return

        # {0: {'board': 0, 'devices': {1: {'address': 1, 'model': 1, 'modelname': 'epiv'}}}}
        for b in self.MPBUS.values():
            board=b['board']

            statusRegister0=self.getBoardStatusRegister0(board)

            result=True
            statusExtensionBoard=False
            statusCommunication=0

            # EXT_status, EXT_status_r0
            r=self.readInputRegisters(16, 2)
            if r:
                statusCommunication=r[1]
                if r[0]==0x1:
                    statusExtensionBoard=True

            for d in b['devices'].values():
                self.microsleep()

                address=d['address']
                timeout=d['timeout']

                if not statusExtensionBoard:
                    # extension board not ok
                    d['error']=True
                    self.logger.error(f'SIO {self.key} MPBUS{board} extension card fault ({r})!')
                    continue

                # check device error
                if statusCommunication & (0x1<<(address-1)):
                    d['comerrcount']+=1
                    if d['comerrcount']>15:
                        # device is not responding
                        d['error']=True
                        self.logger.error(f'SIO {self.key} MPBUS{board}:{address} device communication fault ({r})!')
                        continue
                else:
                    d['comerrcount']=0

                if not self.isTimeout(timeout):
                    continue

                d['timeout']=self.timeout(d['refresh'])
                nbreg=d.get('nbstatusreg', 49)
                statusMPBusRegister0=self.getBoardMPBUSStatusRegister0(board, address)
                if statusMPBusRegister0>0:
                    r=self.readInputRegisters(statusMPBusRegister0, nbreg)
                    if r:
                        self.logger.warning(f'MPBUS{board}:{address} DATA={r}')
                        decoder=self.decoderFromRegisters(r)
                        if decoder:
                            d['error']=False
                            if not self.decodeMPBusDeviceData(board, address, decoder):
                                d['error']=True

            #  update error state
            for d in b['devices'].values():
                error=d['error']
                if error:
                    result=False
                    d['timeout']=self.timeout(60)

                    values=d['values']
                    for value in values.values():
                        value.setError(error)

            value=b['comerr']
            value.updateValue(not result)

    def checkUptime(self, t):
        if t is not None and t>0:
            value=self.UPTIME
            if value is not None:
                t0=value.value
                if t0 is not None and t0>0:
                    if t<t0:
                        self.logger.error('SIO device %s timestamp suggest internal REBOOT!' % self.key)
                        self.beep()
                        self.reset()
                        return False
        return True

    def refreshSlow(self):
        pass

    def refresh(self):
        if self.isTimeout(self._timeoutRefreshDI):
            self._timeoutRefreshDI=self.timeout(2.0)
            self.refreshDI()
            self.microsleep()

        if self.isTimeout(self._timeoutRefreshDO):
            self._timeoutRefreshDO=self.timeout(5.0)
            self.refreshDO()
            self.microsleep()

        if self.isTimeout(self._timeoutRefreshAI):
            self._timeoutRefreshAI=self.timeout(2.0)
            self.refreshAI()
            self.microsleep()

        if self.isTimeout(self._timeoutRefreshAO):
            self._timeoutRefreshAO=self.timeout(5.0)
            self.refreshAO()
            self.microsleep()

        if self.isTimeout(self._timeoutRefreshMPBUS):
            self._timeoutRefreshMPBUS=self.timeout(5.0)
            self.refreshMPBus()
            self.microsleep()

        if self.isTimeout(self._timeoutRefreshSlow):
            self._timeoutRefreshSlow=self.timeout(15)
            self.refreshSlow()

        return 1.0

    def sync(self):
        return True

    def off(self):
        if self.NBCHANNELDO==0:
            return
        for channel in range(self.NBCHANNELDO):
            self.DO[channel].off()

    def on(self):
        if self.NBCHANNELDO==0:
            return
        for channel in range(self.NBCHANNELDO):
            self.DO[channel].on()

    def toggle(self):
        if self.NBCHANNELDO==0:
            return
        for channel in range(self.NBCHANNELDO):
            self.DO[channel].toggle()
        for channel in range(self.NBCHANNELAO):
            if self.AO[channel].value:
                self.AO[channel].set(0)
            else:
                self.AO[channel].set(100)

    def probe(self):
        return None

    def configurator(self):
        configurator=SIOConfigurator(self.gateway.host)
        return configurator

    def isRebootPossible(self):
        return True

    def reboot(self):
        c=self.configurator()
        if c.connect():
            c.reboot()
            self.beep()
            self.reset()
            return True
        return False

    def isUpgradePossible(self):
        return True

    def upgrade(self):
        s=self.mbio.sioscanner()
        return s.upgrade(self.gateway.host)


class MBIODeviceDigimatSIO_V12(MBIODeviceDigimatSIO):
    def onInit(self):
        super().onInit()
        self._model='SIOV2'

    def probe(self):
        self.logger.debug('Probing device address %d with modbus TCP' % self.address)
        r=self.readInputRegisters(98, 1)
        if r and (r[0] & 0xFF00) == 0xA500:
            v=r[0] & 0xff
            data={'version': str(v),
                  'model': 'SIOV2',
                  'firmware': None}

            # Model check
            if v not in [0,1,2]:
                self.logger.error(f"SIO {self.key} hardware model version {v} doesn't match required version {data}!")
                return None

            # read hw version
            r=self.readInputRegisters(109, 8)
            if r:
                data['hardware']=r

            # read firmware revision (not available before 0.9.11)
            r=self.readInputRegisters(116, 3)
            if r:
                data['firmware']=f'{r[0]}.{r[1]}.{r[2]}'

            r=self.readInputRegisters(104, 3)
            if r:
                mac='%02x:%02x:%02x:%02x:%02x:%02x' % (r[0] >> 8, r[0] & 0xff,
                    r[1] >> 8, r[1] & 0xff,
                    r[2] >> 8, r[2] & 0xff)

                data['mac']=self.getMBIO().normalizeMAC(mac)
                self.logger.warning(data)
                return data

        return None

    def refreshDI(self):
        r=self.readDiscreteInputs(0, 8*self.config.boards)
        if r:
            for board in range(self.config.boards):
                channel0=board*self.NBCHANNELDI
                for channel in range(self.NBCHANNELDI):
                    value=self.DI[channel0+channel]
                    state=r[board*8+channel]
                    if value.isInverted():
                        state=not state
                    value.updateValue(state)

    def refreshDO(self):
        if self.NBCHANNELDO==0:
            return
        r=self.readCoils(0, 8*self.config.boards)
        if r:
            self.BUZZER.updateValue(r[7])
            for board in range(self.config.boards):
                channel0=board*self.NBCHANNELDO
                for channel in range(self.NBCHANNELDO):
                    value=self.DO[channel0+channel]
                    state=r[board*8+channel]
                    if value.isInverted():
                        state=not state
                    value.updateValue(state)

                self.LEDR[board].updateValue(r[board*8+4])
                self.LEDG[board].updateValue(r[board*8+5])
                self.LEDB[board].updateValue(r[board*8+6])

    def refreshAI(self):
        r=self.readInputRegisters(0, self.config.boards*self.NBCHANNELAI)
        if r:
            for board in range(self.config.boards):
                channel0=board*self.NBCHANNELAI
                r0=self.NBCHANNELAI*board
                for channel in range(self.NBCHANNELAI):
                    value=self.AI[channel0+channel]
                    data=r[r0+channel]/10.0

                    try:
                        # value offset (expressed in measure unit : V or ohms)
                        data+=value.config.voffset
                    except:
                        pass

                    try:
                        # trap internal ohm->specific conversion curves
                        if value.config.r2t:
                            data=self.r2t(value.config.r2t, data)
                            # self.logger.warning(data)
                    except:
                        pass

                    try:
                        dy=(value.config.y1-value.config.y0)
                        dx=(value.config.x1-value.config.x0)
                        data=value.config.y0+(data-value.config.x0)/dx*dy
                        if data<value.config.y0:
                            data=value.config.y0
                        if data>value.config.y1:
                            data=value.config.y1
                    except:
                        pass

                    value.updateValue(data+value.config.offset)

    def refreshAO(self):
        r=self.readInputRegisters(64, self.config.boards*self.NBCHANNELAO)
        if r:
            for board in range(self.config.boards):
                channel0=board*self.NBCHANNELAO
                r0=self.NBCHANNELAO*board
                for channel in range(self.NBCHANNELAO):
                    value=self.AO[channel0+channel]
                    state=self.AO_raw2state(value, r[r0+channel])
                    value.updateValue(state)

    def refreshSlow(self):
        # for board in range(self.config.boards):
        r=self.readInputRegisters(98, 11)
        if r:
            value=self.T
            value.updateValue(float(r[9])/100.0)

            value=self.UPTIME
            v=(r[2]+r[3]*60+r[4]*3600+r[5]*86400)/3600.0
            if self.checkUptime(v):
                value.updateValue()

    def sync(self):
        for board in range(self.config.boards):
            self.microsleep()

            if self.NBCHANNELDO>0:
                channel0=board*self.NBCHANNELDO
                for channel in range(self.NBCHANNELDO):
                    value=self.DO[channel0+channel]
                    if not value.isEnabled():
                        continue
                    if value.isPendingSync():
                        self.signalRefresh(0.1)
                        state=value.toReachValue
                        if value.isInverted():
                            state=not state
                        self.writeCoils(board*8+channel, state)
                        value.clearSync()
                        self._timeoutRefreshDO=0

            value=self.LEDR[board]
            if value.isPendingSync():
                self.writeCoils(board*8+4, value.toReachValue)
                value.clearSync()
                self.signalRefresh(0.1)
            value=self.LEDG[board]
            if value.isPendingSync():
                self.writeCoils(board*8+5, value.toReachValue)
                value.clearSync()
                self.signalRefresh(0.1)
            value=self.LEDB[board]
            if value.isPendingSync():
                self.writeCoils(board*8+6, value.toReachValue)
                value.clearSync()
                self.signalRefresh(0.1)

        value=self.BUZZER
        if value.isPendingSync():
            self.writeCoils(7, value.toReachValue)
            value.clearSync()
            self._timeoutRefreshSlow=0
            self.signalRefresh(0.1)

        for board in range(self.config.boards):
            self.microsleep()
            channel0=board*self.NBCHANNELAO
            for channel in range(self.NBCHANNELAO):
                value=self.AO[channel0+channel]
                if not value.isEnabled():
                    continue
                if value.isPendingSync():
                    self.signalRefresh(1.0)
                    raw=self.AO_state2raw(value, value.toReachValue)
                    if self.writeRegisters(channel0+channel, raw):
                        # Write the corresponding MANUAL flag in the device
                        v=value.isManual() or value.isRemoteManual()
                        if self.writeCoils(64+channel0+channel, v):
                            value.clearSync()
                            self._timeoutRefreshAO=self.timeout(1.0)

        return True


class MBIODeviceDigimatSIO_V3(MBIODeviceDigimatSIO):
    NBCHANNELDI=8
    NBCHANNELDO=0
    NBCHANNELAI=8
    NBCHANNELAO=8
    AO_RAMP_REGISTER_OFFSET=17
    BOARDS_REGISTER=512
    WATCHDOG_REGISTER=511
    SAVEPOINT_REGISTER=510
    MAXNBMPBUSDEVICES=8

    def getBoardConfigRegister0(self, board):
        return 100+(50*board)

    def onInit(self):
        super().onInit()
        self._model='SIOV3'

    def probe(self):
        self.logger.debug('Probing device address %d with modbus TCP' % self.address)
        r=self.readInputRegisters(500, 1)
        if r and (r[0] & 0xFF00) == 0xA500:
            v=r[0] & 0xff
            data={'version': str(v),
                  'model': 'SIOV3',
                  'firmware': None}

            # Model check
            if v not in [3]:
                self.logger.error(f"SIO {self.key} hardware model version {v} doesn't match required version {data}!")
                return None

            # read hw version
            r=self.readInputRegisters(510, 8)
            if r:
                data['hardware']=r

            # read firmware revision (not available before 0.9.11)
            r=self.readInputRegisters(519, 3)
            if r:
                data['firmware']=f'{r[0]}.{r[1]}.{r[2]}'

            r=self.readInputRegisters(505, 3)
            if r:
                mac='%02x:%02x:%02x:%02x:%02x:%02x' % (r[0] >> 8, r[0] & 0xff,
                    r[1] >> 8, r[1] & 0xff,
                    r[2] >> 8, r[2] & 0xff)

                data['mac']=self.getMBIO().normalizeMAC(mac)
                self.logger.warning(data)
                return data

        return None

    def refreshDI(self):
        # FIXME: QUID?
        r=self.readDiscreteInputs(0, 8*self.config.boards)
        if r:
            for board in range(self.config.boards):
                channel0=board*self.NBCHANNELDI
                for channel in range(self.NBCHANNELDI):
                    value=self.DI[channel0+channel]
                    state=r[board*8+channel]
                    if value.isInverted():
                        state=not state
                    value.updateValue(state)

    def refreshDO(self):
        # FIXME: QUID?
        r=self.readCoils(0, 8*self.config.boards)
        if r:
            self.BUZZER.updateValue(r[7])
            for board in range(self.config.boards):
                channel0=board*self.NBCHANNELDO
                for channel in range(self.NBCHANNELDO):
                    value=self.DO[channel0+channel]
                    state=r[board*8+channel]
                    if value.isInverted():
                        state=not state
                    value.updateValue(state)

                self.LEDR[board].updateValue(r[board*8+4])
                self.LEDG[board].updateValue(r[board*8+5])
                self.LEDB[board].updateValue(r[board*8+6])

    def refreshAI(self):
        for board in range(self.config.boards):
            channel0=board*self.NBCHANNELAI
            r0=self.getBoardStatusRegister0(board)

            r=self.readInputRegisters(r0, self.NBCHANNELAI)
            if r:
                for channel in range(self.NBCHANNELAI):
                    value=self.AI[channel0+channel]
                    data=r[channel]/10.0

                    try:
                        # value offset (expressed in measure unit : V or ohms)
                        data+=value.config.voffset
                    except:
                        pass

                    try:
                        # trap internal ohm->specific conversion curves
                        if value.config.r2t:
                            data=self.r2t(value.config.r2t, data)
                            # self.logger.warning(data)
                    except:
                        pass

                    try:
                        dy=(value.config.y1-value.config.y0)
                        dx=(value.config.x1-value.config.x0)
                        data=value.config.y0+(data-value.config.x0)/dx*dy
                        if data<value.config.y0:
                            data=value.config.y0
                        if data>value.config.y1:
                            data=value.config.y1
                    except:
                        pass

                    value.updateValue(data+value.config.offset)

    def refreshAO(self):
        for board in range(self.config.boards):
            channel0=board*self.NBCHANNELAO
            r0=self.getBoardStatusRegister0(board)
            r=self.readInputRegisters(r0+8, self.NBCHANNELAO)
            if r:
                for channel in range(self.NBCHANNELAO):
                    value=self.AO[channel0+channel]
                    state=self.AO_raw2state(value, r[channel])
                    value.updateValue(state)

    def refreshSlow(self):
        # for board in range(self.config.boards):
        r=self.readInputRegisters(500, 10)
        if r:
            value=self.T
            value.updateValue(float(r[8])/100.0)
            value=self.UPTIME
            v=(r[1]+r[2]*60+r[3]*3600+r[4]*86400)/3600.0

            # device auto reset detection
            if self.checkUptime(v):
                value.updateValue(v)

    def sync(self):
        for board in range(self.config.boards):
            self.microsleep()

            channel0=board*self.NBCHANNELDO
            for channel in range(self.NBCHANNELDO):
                value=self.DO[channel0+channel]
                if not value.isEnabled():
                    continue
                if value.isPendingSync():
                    self.signalRefresh(0.1)
                    state=value.toReachValue
                    if value.isInverted():
                        state=not state
                    self.writeCoils(board*8+channel, state)
                    value.clearSync()
                    self._timeoutRefreshDO=0

            value=self.LEDR[board]
            if value.isPendingSync():
                self.writeCoils(board*8+4, value.toReachValue)
                value.clearSync()
                self.signalRefresh(0.1)
            value=self.LEDG[board]
            if value.isPendingSync():
                self.writeCoils(board*8+5, value.toReachValue)
                value.clearSync()
                self.signalRefresh(0.1)
            value=self.LEDB[board]
            if value.isPendingSync():
                self.writeCoils(board*8+6, value.toReachValue)
                value.clearSync()
                self.signalRefresh(0.1)

        value=self.BUZZER
        if value.isPendingSync():
            self.writeCoils(7, value.toReachValue)
            value.clearSync()
            self._timeoutRefreshSlow=0
            self.signalRefresh(0.1)

        for board in range(self.config.boards):
            self.microsleep()
            channel0=board*self.NBCHANNELAO
            for channel in range(self.NBCHANNELAO):
                value=self.AO[channel0+channel]
                if not value.isEnabled():
                    continue
                if value.isPendingSync():
                    self.signalRefresh(1.0)
                    raw=self.AO_state2raw(value, value.toReachValue)
                    if self.writeRegisters(channel0+channel, raw):
                        # Write the corresponding MANUAL flag in the device
                        v=value.isManual() or value.isRemoteManual()
                        if self.writeCoils(64+channel0+channel, v):
                            value.clearSync()
                            self._timeoutRefreshAO=self.timeout(1.0)

        return True


class SIOConfigurator(object):
    def __init__(self, host, token=None, logger=None):
        self._host=host
        self._token=token or 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
        self._logger=logger
        self._config={}
        self._updated=False
        self._response=None
        self.reset()

    @property
    def logger(self):
        return self._logger

    def reset(self):
        self._config={}
        self._updated=False
        self._response=None

    @property
    def MAC(self):
        try:
            self.connect()
            return self._config['info']['mac']
        except:
            pass

    @property
    def IP(self):
        try:
            self.connect()
            return self._config['lan']['staticip']
        except:
            pass

    @property
    def MASK(self):
        try:
            self.connect()
            return self._config['lan']['staticnetmask']
        except:
            pass

    @property
    def GW(self):
        try:
            self.connect()
            return self._config['lan']['staticnetgw']
        except:
            pass

    @property
    def version(self) -> Version:
        try:
            self.connect()
            return Version(self._config['info']['swversion'])
        except:
            pass

    @property
    def hwversion(self) -> Version:
        try:
            self.connect()
            return Version(self._config['info']['hwversion'])
        except:
            pass

    def isDHCP(self):
        try:
            self.connect()
            if self._config['lan']['dhcp']:
                return True
        except:
            pass
        return False

    def isConnected(self):
        try:
            if self._config['info']['mac']:
                return True
        except:
            pass
        return False

    def url(self, path=None):
        url='http://%s/api/v1' % self._host
        if path:
            url += '/%s' % path
        return url

    def post(self, path, data=None, timeout=3):
        try:
            url=self.url(path)
            headers={'api_token': self._token}
            proxies = { 'http': '', 'https': '' }
            if data is None:
                data={}

            r=requests.post(url, headers=headers, json=data, verify=False, timeout=timeout, proxies=proxies)
            if r and r.ok:
                self._response=r.text
                return True
        except:
            pass

    def get(self, path, data=None):
        try:
            url=self.url(path)
            headers={'api_token': self._token}
            proxies = { 'http': '', 'https': '' }
            if data is None:
                data={}

            r=requests.get(url, headers=headers, params=data, verify=False, timeout=3.0, proxies=proxies)
            if r and r.ok:
                data=r.json()
                return data
        except:
            # self.logger.exception('get')
            pass

    def connect(self):
        if self.isConnected():
            return True

        config=self.get('system/info')
        if config:
            try:
                if config['info']['mac'] and config['info']['hwversion']:
                    self._config=config
                    return True
            except:
                pass
        return False

    def disconnect(self):
        self.reset()

    def __del__(self):
        self.disconnect()

    def isUpdated(self):
        return self._updated

    def reboot(self):
        if self.connect():
            api='system/reboot'
            if self.post(api, timeout=10):
                return True
        return False

    def setDHCP(self):
        if self.connect():
            if not self.isDHCP():
                api='system/lan'
                data=self.get(api)
                if data and not data['dhcp']:
                    if self.post(api, {'lan': {'dhcp': True}}):
                        self._updated=True
                        return True
        return False

    def setFixedIP(self, ip, mask, gw):
        if self.connect():
            try:
                n=ipcalc.Network('%s/%s' % (ip, mask))
                ip=n.to_tuple()[0]
                mask=str(n.netmask())
                if not self.isDHCP() and self.IP==ip and self.MASK==mask and self.GW==gw:
                    return True

                api='system/lan'
                config={'lan': {'dhcp': False, 'staticip': ip, 'staticnetmask': mask, 'staticgateway': gw}}
                if self.post(api, data=config):
                    self._updated=True
                    return True
            except:
                pass

        return False

    def upgrade(self, url):
        if url and self.connect():
            api='system/update'
            if self.post(api, {'url': url}, timeout=60):
                return True
        return False

    def dump(self):
        if self.connect():
            pprint.pprint(self._config)


class SIOScanner(MBIONetworkScanner):
    def onInit(self):
        self._token=None
        self._firmwares={}

    def buildVersion(self, v, default='0'):
        try:
            if v is not None:
                if isinstance(v, bytes):
                    v=v.decode()
                v=str(v).lower()
                if v[0]=='v':
                    v=v[1:]
                return Version(v)
        except:
            pass
        return Version(default)

    def firmware(self, hardware):
        v=self.buildVersion(hardware)
        return self._firmwares.get(str(v))

    def extractFirmwareVersion(self, data) -> dict:
        version={}
        if data:
            matches=re.findall(b'FWMODEL=\\[(SIO.*)\\]', data)
            if matches:
                model=matches[0].lower()
                version['model']=model

                hardware=self.buildVersion(model[3:], '1')
                version['hardware']=hardware

                matches=re.findall(b'FWVERSION=\\[([0-9]+\\.[0-9]+\\.[0-9]+)\\]', data)
                if matches:
                    version['software']=Version(matches[0].decode())
                    return version

        self.logger.error(f'Unable to extract SIO firmware version {version}')
        return None

    def downloadFirmware(self, hardware, fname=None):
        if hardware:
            v=self.buildVersion(hardware)
            url=f'~netupdate/firmware/sio/sio-v{str(v)}.bin'
            fname=fname or f'/tmp/sio-v{str(v)}.bin'
            if self.getMBIO().downloadFile(url, fname):
                data=self.mbio.wsGetFileContent(fname)
                version=self.extractFirmwareVersion(data)
                if version and version['hardware']==v:
                    return version
        return None

    def retrieveFirmware(self, hardware, download=False) -> Version:
        if hardware:
            v=self.buildVersion(hardware)
            fname=f'sio-v{str(v)}.bin'
            falias=f'sio-v{str(v)}-firmware.bin'
            fpath='/tmp/%s' % fname
            p=pathlib.Path(fpath)

            if download or not p.is_file():
                self.downloadFirmware(hardware, '/tmp/%s' % fname)

            url=self.mbio.wsExposeFile('/tmp/%s' % fname, alias=falias)
            if not url:
                url=self.mbio.wsExposeFile('/etc/sysconfig/digimat/firmware/%s' % fname, alias=falias)

            data=self.mbio.wsGetFileContent(falias)
            version=self.extractFirmwareVersion(data)

            firmware={}
            if version and version['hardware']==v:
                firmware={'name': falias, 'url': url,
                        'version': version['software'],
                        'hardware': version['hardware']}
                self._firmwares[str(v)]=firmware
                self.logger.info('Found SIO firmware %s' % firmware)

            # this may contain a "huge" amount of RAM (>800kB)
            del data
            return firmware

    def upgrade(self, host, force=False):
        result=False
        c=self.configurator(host)
        if c and c.connect():
            firmware=self.retrieveFirmware(c.hwversion)
            if force or (firmware and firmware['version']>c.version):
                self.logger.info('Requesting SIO device %s [%s:%s] firmware upgrade...' %
                    (host, c.version, c.hwversion))

                if c.upgrade(firmware['url']):
                    result=True

                self.logger.debug('UPGRADE POST response is [%s]' % c._response)
            else:
                self.logger.warning('No valid firmware found for SIO device %s [%s:%s]' %
                    (host, c.version, c.hwversion))
            c.disconnect()
            return result
        return False

    def upgradeAll(self):
        if not self._hosts:
            self.scan()
        if self._hosts:
            for host in self._hosts.keys():
                self.upgrade(host)

    def setToken(self, token):
        if token:
            self._token=token

    def configurator(self, host):
        return SIOConfigurator(host, token=self._token, logger=self.logger)

    def probe(self, host):
        hostid=None
        c=self.configurator(host)
        if c.connect():
            hostid=self.mbio.normalizeMAC(c.MAC)
        c.disconnect()
        return hostid

    def resetmbiogateway(self, host):
        try:
            gateway=self.getMBIO().gateway(host)
            if gateway is not None:
                gateway.reset()
                return True
        except:
            pass
        return False

    def configureHostFromGateway(self, host, gateway):
        c=self.configurator(host)
        if c.connect():
            mask=self.getMBIO().netmask()
            if mask is not None:
                if c.isDHCP() or c.IP!=gateway.host or c.MASK!=mask:
                    # self.logger.debug(c._config)
                    gw=self.getMBIO().gw
                    self.logger.warning('Reconfiguring SIO Gateway %s -> %s/%s/%s...' % (gateway.MAC, gateway.host, mask, gw))
                    self.getMBIO().beep()
                    c.setFixedIP(gateway.host, mask, gw)

                    # TODO: set RS485 and other things...

                    # Try to auto-upgrade firmware
                    if self.upgrade(host):
                        # Device reboot will be automatic
                        self.sleep(2)
                        self.logger.warning('Restarting SIO Gateway %s...' % (gateway.host))
                        gateway.reset()
                        return

                    if c.isUpdated():
                        self.logger.warning('Rebooting updated SIO Gateway %s...' % (gateway.MAC))
                        c.reboot()
                        self.sleep(2)
                        self.logger.warning('Restarting SIO Gateway %s...' % (gateway.host))
                        # Restart mbio gateway
                        gateway.reset()

    def __repr__(self):
        return '%s(%u hosts)' % (self.__class__.__name__, self.count())


if __name__ == "__main__":
    pass
