# *****************************************************************************
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 2 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, write to the Free Software Foundation, Inc.,
# 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Module authors:
#   Markus Zolliker <markus.zolliker@psi.ch>
# *****************************************************************************
"""generic SEA driver

a object or subobject in sea may be assigned to a SECoP module

Examples:

SECoP       SEA          hipadaba path   mod.obj  mod.sub  par.sub   mod.path
-------------------------------------------------------------------------------
tt:maxwait  tt           /tt/maxwait     tt                maxwait   /tt
tt:ramp     tt set/ramp  /tt/set/ramp    tt                set/ramp  /tt
t1:raw      tt t1/raw    /tt/t1/raw      tt       t1       raw       /tt/t1
rx:bla      rx bla       /some/rx_a/bla  rx                bla       /some/rx_a
"""

import json
import threading
import time
import os
from pathlib import Path

from frappy.client import ProxyClient, CacheItem
from frappy.datatypes import ArrayOf, BoolType, \
    EnumType, FloatRange, IntRange, StringType, StatusType
from frappy.core import IDLE, BUSY, WARN, ERROR, DISABLED
from frappy.errors import ConfigError, HardwareError, ReadFailedError, \
    CommunicationFailedError, ProgrammingError
from frappy.lib import generalConfig, mkthread, lazy_property
from frappy.lib.asynconn import AsynConn, ConnectionClosed
from frappy.modulebase import Done
from frappy.modules import Attached, Command, Drivable, \
    Module, Parameter, Property, Readable, Writable


CFG_HEADER = """Node('%(config)s.sea.psi.ch',
    '''%(nodedescr)s''',
)
Mod(%(seaconn)r,
    'frappy_psi.sea.SeaClient',
    '%(service)s sea connection for %(config)s',
    config = %(config)r,
    service = %(service)r,
)
"""

CFG_MODULE = """Mod(%(module)r,
    'frappy_psi.sea.%(modcls)s', '',
    io = %(seaconn)r,
    sea_object = %(module)r,
)
"""

SERVICE_NAMES = {
    'config': 'main',
    'stick': 'stick',
    'addon': 'addons',
}


SEA_DIR = Path('~/sea').expanduser()


def get_sea_port(instance):
    return os.environ.get('SEA_SEA_PORT', '8641')


class SeaConfig:
    @lazy_property
    def dirs(self):
        seaconfdirs = os.environ.get('FRAPPY_SEA_DIR', '').replace(':', ' ').replace(',', ' ')
        result = [Path(v) for v in seaconfdirs.split() if Path(v).expanduser().is_dir()]
        if not result:
            for confdir in generalConfig.confdir:
                seaconfdir = confdir / 'sea'
                if seaconfdir.is_dir():
                    result.append(seaconfdir)
        return result

    def load_json(self, json_file):
        for dir in self.dirs:
            file = dir / json_file
            if file.is_file():
                return json.loads(file.read_text())
        raise FileNotFoundError(f'{json_file} not found')


seaconfig = SeaConfig()


class SeaClient(ProxyClient, Module):
    """connection to SEA"""

    uri = Parameter('hostname:portnumber', datatype=StringType(), default='localhost:5000')
    timeout = Parameter('timeout for connecting and requests',
                        datatype=FloatRange(0), default=10)
    config = Property("""needed SEA configuration, space separated

                      Example: "ori4.config ori4.stick"
                      """, StringType(), default='')
    service = Property("main/stick/addons", StringType(), default='')
    visibility = 'expert'
    default_json_file = {}
    _instance = None
    _last_connect = 0

    def __init__(self, name, log, opts, srv):
        instance = srv.secnode.name.rsplit('_', 1)[0]
        if 'uri' not in opts:
            self._instance = instance
            port = get_sea_port(instance)
            if port is None:
                raise ConfigError('missing sea port for %s' % instance)
            log.info('connect to sea on port %r', port)
            opts['uri'] = {'value': 'tcp://localhost:%s' % port}
        self.objects = set()
        self._shutdown = threading.Event()
        self.path2param = {}
        self._write_lock = threading.RLock()
        self._connect_thread = None
        self._connected = threading.Event()
        config = opts.get('config')
        if isinstance(config, dict):
            config = config['value']
        if config:
            self.default_json_file[name] = config.split()[0] + '.json'
        self.syncio = None
        self.asynio = None
        ProxyClient.__init__(self)
        Module.__init__(self, name, log, opts, srv)

    def doPoll(self):
        if not self._connected.is_set() and time.time() > self._last_connect + self.timeout:
            if not self._last_connect:
                self.log.info('reconnect to SEA %s', self.service)
            if self._connect_thread is None:
                self._connect_thread = mkthread(self._connect)
            self._connected.wait(self.timeout)

    def register_obj(self, module, obj):
        self.objects.add(obj)
        for k, v in module.path2param.items():
            self.path2param.setdefault(k, []).extend(v)
        self.register_callback(module.name, module.updateEvent)

    def _connect(self):
        try:
            if self.syncio:
                try:
                    self.syncio.disconnect()
                except Exception:
                    pass
            self._last_connect = time.time()
            if self._instance and False:
                try:
                    from linsetools.base import MarcheControl  # pylint: disable=import-outside-toplevel
                    MarcheControl().start('sea@sea')
                    MarcheControl().start('sea@graph')
                except ImportError:
                    try:
                        from servicemanager import SeaManager  # pylint: disable=import-outside-toplevel
                        SeaManager().do_start(self._instance)
                    except ImportError:
                        pass
            if '//' not in self.uri:
                self.uri = 'tcp://' + self.uri
            self.asynio = AsynConn(self.uri)
            reply = self.asynio.readline()
            if reply != b'OK':
                raise CommunicationFailedError('reply %r should be "OK"' % reply)
            for _ in range(2):
                self.asynio.writeline(b'Spy 007')
                reply = self.asynio.readline()
                if reply == b'Login OK':
                    break
            else:
                raise CommunicationFailedError('reply %r should be "Login OK"' % reply)
            self.syncio = AsynConn(self.uri)
            assert self.syncio.readline() == b'OK'
            self.syncio.writeline(b'seauser seaser')
            assert self.syncio.readline() == b'Login OK'
            if self.service and self.config:
                result = self.raw_request('frappy_config %s %s' % (self.service, self.config))
                if result.startswith('ERROR:'):
                    raise CommunicationFailedError(f'reply from frappy_config: {result}')
            # frappy_async_client switches to the json protocol (better for updates)
            self.asynio.writeline(b'frappy_async_client')
            if self.config == 'device':
                self.asynio.writeline(b'get_all_param device')
            else:
                self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
            self.log.info('connected to %s', self.uri)
            self._connected.set()
            mkthread(self._rxthread)
        except CommunicationFailedError:
            pass
        finally:
            self._connect_thread = None

    def request(self, command, quiet=False):
        with self._write_lock:
            if not self._connected.is_set():
                if self._connect_thread is None:
                    # let doPoll do the reconnect
                    if self.pollInfo:  # is None in playground
                        self.pollInfo.trigger(True)
                    else:
                        self.log.info('sea_main.doPoll() will connect')
                raise ConnectionClosed('disconnected - reconnect is tried later')
            return self.raw_request(command, quiet)

    def raw_request(self, command, quiet=False):
        """send a request and wait for reply"""
        try:
            self.syncio.flush_recv()
            ft = 'fulltransAct' if quiet else 'fulltransact'
            self.syncio.writeline(('%s %s' % (ft, command)).encode())
            result = None
            deadline = time.time() + self.timeout
            while time.time() < deadline:
                reply = self.syncio.readline()
                if reply is None:
                    continue
                reply = reply.decode()
                if reply.startswith('TRANSACTIONSTART'):
                    result = []
                    continue
                if reply == 'TRANSACTIONFINISHED':
                    if result is None:
                        self.log.info('missing TRANSACTIONSTART on: %s', command)
                        return ''
                    if not result:
                        return ''
                    return '\n'.join(result)
                if result is None:
                    self.log.info('swallow: %s', reply)
                    continue
                if not result:
                    result = [reply.split('=', 1)[-1]]
                else:
                    result.append(reply)
            raise TimeoutError('no response within 10s')
        except ConnectionClosed:
            self.close_connections()
            raise

    def close_connections(self):
        connections = self.syncio, self.asynio
        self.syncio = self.asynio = None
        for conn in connections:
            try:
                conn.disconnect()
            except Exception:
                pass
        self._connected.clear()

    def _rxthread(self):
        recheck = None
        while not self._shutdown.is_set():
            if recheck and time.time() > recheck:
                # try to collect device changes within 1 sec
                recheck = None
                if self.service:
                    result = self.request('check_config %s %s' % (self.service, self.config))
                else:
                    result = '1'
                if result == '1':
                    self.asynio.writeline(('get_all_param ' + ' '.join(self.objects)).encode())
                else:
                    print('SHUTDOWN', result)
                    self.secNode.srv.shutdown()
            try:
                reply = self.asynio.readline()
                if reply is None:
                    continue
            except ConnectionClosed:
                self.close_connections()
                print('CLOSED')
                break
            try:
                msg = json.loads(reply)
            except Exception as e:
                self.log.warn('bad reply %r %r', e, reply)
                continue
            if isinstance(msg, str):
                if msg.startswith('_E '):
                    try:
                        _, path, readerror = msg.split(None, 2)
                    except ValueError:
                        continue
                else:
                    continue
                # path from sea may contain double slash //
                # this should be fixed, however in the meantime fix it here
                path = path.replace('//', '/')
                data = {'%s.geterror' % path: readerror.replace('ERROR: ', '')}
                obj = None
                flag = 'hdbevent'
            else:
                obj = msg['object']
                flag = msg['flag']
                data = msg['data']
            if flag == 'finish' and obj == 'get_all_param':
                # first updates have finished
                continue
            if flag != 'hdbevent':
                if obj not in ('frappy_async_client', 'get_all_param'):
                    self.log.debug('skip %r', msg)
                continue
            if not data:
                continue
            if not isinstance(data, dict):
                self.log.debug('what means %r', msg)
                continue
            now = time.time()
            for path, value in data.items():
                readerror = None
                if path.endswith('.geterror'):
                    if value:
                        # TODO: add mechanism in SEA to indicate hardware errors
                        readerror = ReadFailedError(value)
                    path = path.rsplit('.', 1)[0]
                    value = None
                mplist = self.path2param.get(path)
                if mplist is None:
                    if path.startswith('/device'):
                        if path == '/device/changetime':
                            recheck = time.time() + 1
                        elif self.service and path.startswith('/device/frappy_%s' % self.service) and value == '':
                            print('SHUT', self.service, path)
                            self.secNode.srv.shutdown()
                else:
                    for module, param in mplist:
                        oldv, oldt, oldr = self.cache[module, param]
                        if value is None:
                            value = oldv
                        self.cache[module, param] = CacheItem(value, now, readerror)
                        if value != oldv or str(readerror) != str(oldr) or abs(now - oldt) > 60:
                            # do not update unchanged values within 60 sec
                            self.updateValue(module, param, value, now, readerror)

    def stopPollThread(self):
        self._shutdown.set()
        super().stopPollThread()

    @Command(StringType(), result=StringType())
    def communicate(self, command):
        """send a command to SEA"""
        reply = self.request(command)
        return reply

    @Command(StringType(), result=StringType())
    def query(self, cmd, quiet=False):
        """a request checking for errors and accepting 0 or 1 line as result"""
        errors = []
        reply = None
        for line in self.request(cmd, quiet).split('\n'):
            if line.strip().startswith('ERROR:'):
                errors.append(line[6:].strip())
            elif reply is None:
                reply = line.strip()
            else:
                self.log.info('SEA: superfluous reply %r to %r', reply, cmd)
        if errors:
            raise HardwareError('; '.join(errors))
        return reply


class SeaConfigCreator(SeaClient):
    def startModule(self, start_events):
        """save objects (and sub-objects) description and exit"""
        self._connect()
        reply = self.request('describe_all')
        reply = ''.join('' if line.startswith('WARNING') else line for line in reply.split('\n'))
        description, reply = json.loads(reply)
        modules = {}
        modcls = {}
        for filename, obj, descr in reply:
            if filename not in modules:
                modules[filename] = {}
            if descr['params'][0].get('cmd', '').startswith('run '):
                modcls[obj] = 'SeaDrivable'
            elif not descr['params'][0].get('readonly', True):
                modcls[obj] = 'SeaWritable'
            else:
                modcls[obj] = 'SeaReadable'
            modules.setdefault(filename, {})[obj] = descr

        result = []
        for filename, descr in modules.items():
            stripped, _, ext = filename.rpartition('.')
            service = SERVICE_NAMES[ext]
            seaconn = 'sea_' + service
            cfgfile = seaconfig.dirs[0] / (stripped + '_cfg.py')
            with cfgfile.open('w', encoding='utf-8') as fp:
                fp.write(CFG_HEADER % {'config': filename, 'seaconn': seaconn, 'service': service,
                                       'nodedescr': description.get(filename, filename)})
                for obj in descr:
                    fp.write(CFG_MODULE % {'modcls': modcls[obj], 'module': obj, 'seaconn': seaconn})
            content = json.dumps(descr).replace('}, {', '},\n{').replace('[{', '[\n{').replace('}]}, ', '}]},\n\n')
            result.append('%s\n' % cfgfile)
            fpath = seaconfig.dirs[0] / (filename + '.json')
            fpath.write_text(content + '\n', encoding='utf-8')
            result.append('%s: %s' % (filename, ','.join(n for n in descr)))
        raise SystemExit('; '.join(result))

    @Command(StringType(), result=StringType())
    def query(self, cmd, quiet=False):
        """a request checking for errors and accepting 0 or 1 line as result"""
        errors = []
        reply = None
        for line in self.request(cmd).split('\n'):
            if line.strip().startswith('ERROR:'):
                errors.append(line[6:].strip())
            elif reply is None:
                reply = line.strip()
            else:
                self.log.info('SEA: superfluous reply %r to %r', reply, cmd)
        if errors:
            raise HardwareError('; '.join(errors))
        return reply


class SeaBool(BoolType):
    """some sea enum nodes used as boolean have text type -> accept '<integer>' also"""
    def copy(self):
        return SeaBool()

    def __call__(self, value):
        try:
            value = int(value)
            return super().__call__(value)
        except Exception as e:
            raise ReadFailedError(e) from e


SEA_TO_SECOPTYPE = {
    'float': FloatRange(),
    'text': StringType(),
    'int': IntRange(-1 << 63, 1 << 63 - 1),
    'bool': SeaBool(),
    'none': None,
    'floatvarar': ArrayOf(FloatRange(), 0, 400),  # 400 is the current limit for proper notify events in SEA
}


class SeaEnum(EnumType):
    """some sea enum nodes have text type -> accept '<integer>' also"""
    def copy(self):
        return SeaEnum(self._enum)

    def __call__(self, value):
        try:
            value = int(value)
            return super().__call__(value)
        except Exception as e:
            raise ReadFailedError(e) from e


def get_datatype(paramdesc):
    typ = paramdesc['type']
    result = SEA_TO_SECOPTYPE.get(typ, False)
    if result is not False:  # general case
        return result
    # special cases
    if typ == 'enum':
        return SeaEnum(paramdesc['enum'])
    raise ValueError('unknown SEA type %r' % typ)


def get_cfg(cfgdict, *args):
    result = cfgdict.get(*args)
    return result['value'] if isinstance(result, dict) else result


def pop_cfg(cfgdict, *args):
    result = cfgdict.pop(*args)
    return result['value'] if isinstance(result, dict) else result


class SeaModule(Module):
    io = Attached(SeaClient)

    # pylint: disable=too-many-statements,arguments-differ,too-many-branches
    def __new__(cls, name, logger, cfgdict, srv):
        if hasattr(srv, 'extra_sea_modules'):
            extra_modules = srv.extra_sea_modules
        else:
            extra_modules = {}
            srv.extra_sea_modules = extra_modules

        json_file = pop_cfg(cfgdict, 'json_file', None) or SeaClient.default_json_file[get_cfg(cfgdict, 'io')]
        visibility_level = pop_cfg(cfgdict, 'visibility_level', 2)

        single_module = pop_cfg(cfgdict, 'single_module', None)
        if single_module:
            sea_object, base, paramdesc = extra_modules[single_module]
            params = [paramdesc]
            paramdesc['key'] = 'value'
            if issubclass(cls, SeaWritable):  # and not SeaDrivable!
                if paramdesc.get('readonly', True):
                    raise ConfigError(f"{sea_object}/{paramdesc['path']} is not writable")
                params.insert(0, paramdesc.copy())  # copy value
                paramdesc['key'] = 'target'
                paramdesc['readonly'] = False
            extra_module_set = ()
            if not get_cfg(cfgdict, 'description'):
                cfgdict['description'] = f'{single_module}@{json_file}'
        else:
            sea_object = pop_cfg(cfgdict, 'sea_object', None)
            sea_path = pop_cfg(cfgdict, 'sea_path', None)
            if sea_object:
                if sea_path:
                    raise ConfigError(f'module {name}: superfluous sea_object property (sea_path is given)')
                sea_path = sea_object
            rel_paths = get_cfg(cfgdict, 'rel_paths', None)
            if rel_paths is None:
                sea_object, *rel_paths = sea_path.split('/', 1)
                if not rel_paths:
                    rel_paths = None
            else:
                if '/' in sea_path:
                    raise ConfigError(f'module {name}: superfluous rel_paths property (sea_path is given)')
                sea_object = sea_path
            # rel_paths:
            # a list of sub nodes to look for parameters
            # '.' denotes the main path
            # Readable: the main value is taken from the first subpath
            # Writable:
            #  - read the target value: <sicsobj> target
            #  - write target value: command from first subpath
            # Drivable:
            #  - write target value: run <sea_object> <target>
            if not get_cfg(cfgdict, 'description'):
                cfgdict['description'] = '%s@%s%s' % (
                    name, json_file, '' if rel_paths is None else f' (rel_paths={rel_paths})')

            descr = seaconfig.load_json(json_file)[sea_object]

            # filter by relative paths
            if rel_paths:
                result = {k: [] for k in rel_paths}
            else:
                result = {True: []}
            is_running = None
            for paramdesc in descr['params']:
                path = paramdesc['path']
                pathlist = path.split('/')
                if pathlist[-1] == 'is_running' and issubclass(cls, Drivable):
                    # take this independent of visibility
                    is_running = paramdesc
                    continue
                if paramdesc.get('visibility', 1) > visibility_level:
                    continue
                if rel_paths is None:
                    result[True].append(paramdesc)
                else:
                    cls.paramFilter(result, paramdesc)
            cfgdict.pop('rel_paths', None)
            params = []
            for key, plist in result.items():
                params.extend(plist)
            if is_running:  # take this at end
                params.append(is_running)
            # this needs some care in the configuration: the main value must be the first!
            main_value = params[0]
            if issubclass(cls, Readable):
                if 'key' in main_value:
                    raise ProgrammingError(f'main_value {main_value!r}')
                main_value['key'] = 'value'
            else:
                params.pop(0)
            base = descr['base']
            if issubclass(cls, SeaWritable):  # and not SeaDrivable!
                paramdesc = params[0]
                if paramdesc.get('key') != 'value':
                    raise ProgrammingError(f"key of first parameter of {name} must be 'value'")
                params.append(paramdesc.copy())  # copy value?
                if paramdesc.get('readonly', True):
                    raise ConfigError(f"{sea_object}/{paramdesc['path']} is not writable")
                paramdesc['key'] = 'target'
                paramdesc['readonly'] = False

            extra_module_set = set(pop_cfg(cfgdict, 'extra_modules', ()))
        path2param = {}
        attributes = {'sea_object': sea_object, 'path2param': path2param}

        # some guesses about visibility (may be overriden in *_cfg.py):
        if sea_object in ('table', 'cc'):
            attributes['visibility'] = 2
        elif base.count('/') > 1:
            attributes['visibility'] = 2
        # check for ambiguous names. candidates are either the last item
        # of the path or the full path (underscore separated)
        duplicates = {k: [k] for k in cls.accessibles}
        for paramdesc in params:
            path = paramdesc['path']
            if path:
                pathlist = path.split('/')
                if 'key' not in paramdesc:
                    pname = pathlist[-1]
                    duplicates.setdefault(pname, pathlist)
        for paramdesc in params:
            path = paramdesc['path']
            readonly = paramdesc.get('readonly', True)
            dt = get_datatype(paramdesc)
            kwds = {'description': paramdesc.get('description', path),
                    'datatype': dt,
                    'visibility': paramdesc.get('visibility', 1),
                    'needscfg': False, 'readonly': readonly}
            if kwds['datatype'] is None:
                kwds.update(visibility=3, default='', datatype=StringType())
            pathlist = path.split('/') if path else []
            key = paramdesc.get('key')  # None, 'value' or 'target'
            if key is None:
                if len(pathlist) > 0:
                    if len(pathlist) == 1:
                        if issubclass(cls, Readable):
                            kwds['group'] = 'more_'
                    else:
                        kwds['group'] = pathlist[-2] + '_'
                    # take short name if unique
                    if duplicates[pathlist[-1]] == pathlist:
                        key = pathlist[-1]
                    else:
                        key = '_'.join(pathlist)
                if key == 'is_running':
                    kwds['export'] = False
            if key == 'target' and kwds.get('group') == 'more':
                kwds.pop('group')
            prev = cls.accessibles.get(key)
            if key in cls.accessibles:
                if key == 'target':
                    kwds['readonly'] = False
                if key == 'status':
                    # special case: status from sea is a string, not the status tuple
                    pobj = prev.copy()
                else:
                    pobj = Parameter(**kwds)
            else:
                pobj = Parameter(**kwds)
            datatype = pobj.datatype
            if issubclass(cls, SeaWritable) and key == 'target':
                kwds['readonly'] = False
                attributes['target'] = pobj = Parameter(**kwds)
            if prev:
                merged_properties = prev.propertyValues.copy()
                pobj.updateProperties(merged_properties)
                pobj.merge(merged_properties)

            if key in ('value', 'target'):
                unit = get_cfg(cfgdict, 'unit')
                if unit is not None:
                    pcfg = cfgdict.get(key, None)
                    if not isinstance(pcfg, dict):
                        cfgdict[key] = pcfg = {} if pcfg is None else {'value': pcfg}
                    pcfg['unit'] = unit

            hdbpath = '/'.join([base] + pathlist)
            if key in extra_module_set:
                extra_modules[name + '.' + key] = sea_object, base, paramdesc
                continue  # skip this parameter
            if key is not None:
                path2param.setdefault(hdbpath, []).append((name, key))
                attributes[key] = pobj

            if key != 'status' and key is not None:
                rfunc = getattr(cls, f'read_{key}', None)
                # do not override existing read method
                if rfunc is None:

                    def rfunc(self, cmd=f'hval {base}/{path}'):
                        reply = self.io.query(cmd, True)
                        try:
                            reply = float(reply)
                        except ValueError:
                            pass
                        # an updateEvent will be handled before above returns
                        return reply

                    rfunc.poll = False
                    attributes['read_' + key] = rfunc

            if not readonly and key:

                def wfunc(self, value, datatype=datatype, command=paramdesc['cmd']):
                    value = datatype.export_value(value)
                    if isinstance(value, bool):
                        value = int(value)
                        # TODO: check if more has to be done for valid tcl data (strings?)
                    cmd = f'{command} {value}'
                    self.io.query(cmd)
                    return Done

                attributes['write_' + (key or 'target')] = wfunc

        # create standard parameters like value and status, if not yet there
        for pname, pobj in cls.accessibles.items():
            if pname == 'pollinterval':
                pobj.export = False
                attributes[pname] = pobj
                pobj.__set_name__(cls, pname)
            elif pname not in attributes and isinstance(pobj, Parameter):
                pobj.needscfg = False
                attributes[pname] = pobj
                pobj.__set_name__(cls, pname)

        classname = f'{cls.__name__}_{name}'
        try:
            newcls = type(classname, (cls,), attributes)
        except Exception as e:
            raise
        # newcls = type(classname, (cls,), attributes)
        result = Module.__new__(newcls)
        return result

    @classmethod
    def paramFilter(cls, result, paramdesc):
        sub = paramdesc['path'].split('/', 1)
        sublist = result.get(sub[0] or '.')
        if sublist is None:
            sublist = result.get('.')
            if sublist is None:
                return False
            if len(sub) > 1 or 'kids' in paramdesc:
                # avoid params from subtrees
                return False
            # this is a top parameter without kids
        sublist.append(paramdesc)
        return True

    def updateEvent(self, module, parameter, value, timestamp, readerror):
        upd = getattr(self, 'update_' + parameter, None)
        if upd:
            upd(value, timestamp, readerror)
            return
        self.announceUpdate(parameter, value, readerror, timestamp)

    def initModule(self):
        self.io.register_obj(self, self.sea_object)
        super().initModule()


class SeaReadable(SeaModule, Readable):
    _readerror = None
    _status = IDLE, ''

    unit = Property('physical unit', StringType(isUTF8=True), default='')
    status = Parameter(datatype=StatusType(Readable, 'DISABLED'))

    def update_value(self, value, timestamp, readerror):
        # make sure status is always ERROR when reading value fails
        self._readerror = readerror
        if readerror:
            self.read_status()  # forced ERROR status
            self.announceUpdate('value', value, readerror, timestamp)
        else:  # order is important
            self.value = value  # includes announceUpdate
            self.read_status()  # send event for ordinary self._status

    def update_status(self, value, timestamp, readerror):
        # value is the sea status, which is a string, not the SECoP status!
        if 'disable' in value.lower():
            self._status = DISABLED, value
        elif value == '':
            self._status = IDLE, ''
        else:
            self._status = ERROR, value
        self.read_status()

    def read_status(self):
        if self._readerror:
            if 'disable' in str(self._readerror).lower():
                return DISABLED, str(self._readerror)
            return ERROR, f'{self._readerror.name} - {self._readerror}'
        return self._status

    def doPoll(self):
        self.read_status()


class SeaWritable(SeaReadable, Writable):
    def read_value(self):
        return self.target

    def update_target(self, value, timestamp, readerror):
        self.target = value
        if not readerror:
            self.value = value


class SeaDrivable(SeaReadable, Drivable):
    _is_driving = False
    _is_running = 0  # SEA is_running flag
    _bad_limit = 5
    _bad_start = 0  # is set to start time when is_running is not immediately true

    status = Parameter(datatype=StatusType(Drivable, 'DISABLED'))

    def write_target(self, value):
        self._is_driving = True
        if not self._is_running:
            self._is_running = None
        self._bad_start = 0
        self.io.query(f'run {self.sea_object} {value}')
        if self._is_running:
            self.read_status()
        if not self.isDriving():
            self.log.warn('status is not set after run')
            self._bad_start = time.time()
            self.status = BUSY, 'changed target'
        return value

    def update_is_running(self, value, timestamp, readerror):
        if readerror:
            self.log.warn('error in update_is_running %r', readerror)
        else:
            self._is_running = value
            try:
                self.read_status()
            except Exception as e:
                self.log.info('read_status failed %r', e)

    def read_status(self):
        status = super().read_status()
        if status[0] >= ERROR:
            if self._is_driving:
                return ERROR, 'BUSY + ' + status[1]
            return status
        if self._is_driving:
            if time.time() < self._bad_start + self._bad_limit:
                if self._is_running:
                    self.log.warn('is_running flag delayed by %.2g sec', time.time() - self._bad_start)
                    self._bad_start = 0
                return BUSY, 'waiting for is_running'
            if self._is_running:
                self._bad_start = 0
                return BUSY, 'driving'
            if self._is_running is None:
                self.log.warn('miss is_running update within delay')
            self._is_driving = False
            if self._bad_start:
                self.log.warn('started, but not running')
        if self._bad_start:
            return IDLE, f'started, but not running'
        return IDLE, ''

    @Command()
    def stop(self):
        """propagate to SEA

        - on stdsct drivables this will call the halt script
        - on EaseDriv this will set the stopped state
        """
        self.io.query(f'{self.sea_object} is_running 0')


class LscDrivable(SeaDrivable):
    def __new__(cls, name, logger, cfgdict, srv):
        sensor_path = pop_cfg(cfgdict, 'sensor_path', 'tm')
        set_path = pop_cfg(cfgdict, 'set_path', 'set')
        cfgdict['rel_paths'] = ['.', sensor_path, set_path , 'dblctrl']
        mobj = super().__new__(cls, name, logger, cfgdict, srv)
        mobj._sensor_path = sensor_path

        def ufunc(value, timestamp, readerror, self=mobj):
            if not self.dblctrl:
                self.update_value(value, timestamp, readerror)
            self.announceUpdate(self._sensor_path, value, readerror, timestamp)

        setattr(mobj, f'update_{sensor_path}', ufunc)
        mobj._read_value_error = False
        return mobj

    @classmethod
    def paramFilter(cls, result, paramdesc):
        if super().paramFilter(result, paramdesc):
            return True
        pathlist = paramdesc['path'].split('/')
        if len(pathlist) == 1 and paramdesc.get('kids', 0) == 0:
            result['.'].append(paramdesc)
            return True
        return False

    def update_value(self, value, timestamp, readerror):
        self._read_value_error = readerror
        if self.dblctrl and not readerror:
            super().update_value(value, timestamp, readerror)

    def read_value(self):
        if self.io.syncio:
            if self.dblctrl and not self._read_value_error:
                reply = self.io.query('hval tt', True)
            else:
                reply = self.io.query(f'hval tt/{self._sensor_path}', True)
            return float(reply)
        return self.value
