class Shtime:

    _tzinfo = None
    _timezone = None
    _utctz = None
    _starttime = None
    _tz = ''
    holidays = None
    public_holidays = None


    def __init__(self, smarthome):
        self._sh = smarthome
        self.logger = logging.getLogger(__name__)

        global _shtime_instance
        if _shtime_instance is not None:
            import inspect
            curframe = inspect.currentframe()
            calframe = inspect.getouterframes(curframe, 4)
            self.logger.critical(self.translate("A second 'shtime' object has been created. There should only be ONE instance of class 'Shtime'!!! Called from: {callframe1} ({callframe3})").format(callframe1=calframe[1][1], callframe3=calframe[1][3]))

        _shtime_instance = self

        self._starttime = datetime.datetime.now()
        self.log_msg = ""   # is overwritten in _initialize_holidays() if no error occurs

        # set default timezone to UTC
#        global TZ
        self._tz = 'UTC'
        os.environ['TZ'] = self._tz
        self.set_tzinfo(dateutil.tz.gettz('UTC'))


    # -----------------------------------------------------------------------------------------------------
    #   Following (static) method of the class Shtime implement the API for date and time handling in shNG
    # -----------------------------------------------------------------------------------------------------

    @staticmethod
    def get_instance():
        """
        Returns the instance of the Shtime class, to be used to access the shtime-API

       .. code-block:: python

           from lib.shtime import Shtime
           shtime = Shtime.get_instance()

           # to access a method (eg. to get timezone info):
           shtime.tzinfo()


        :return: shinfo instance
        :rtype: object or None
        """
        if _shtime_instance == None:
            return None
        else:
            return _shtime_instance


    def translate(self, txt, vars=None):
        """
        Returns translated text

        :param txt: text to translate
        :type txt: str

        :return: translated text
        :rtype: str
        """
        txt = str(txt)

        return lib_translate(txt, vars, additional_translations='lib/shtime')


    def set_tz(self, tz):
        """
        set timezone info from name of timezone

        :param tz: Name of the timezone (like 'Europe/Berlin')
        :type: tz: str
        """
        tzinfo = dateutil.tz.gettz(tz)
        self.logger.info(f"set_tz: tz={tz} -> tzinfo={tzinfo}")
        if tzinfo is not None:
#            TZ = tzinfo
            self._tz = tz
            os.environ['TZ'] = self._tz
#             self._tzinfo = TZ
            self.set_tzinfo(tzinfo)
            self._timezone = pytz.timezone(tz)
        else:
            self.logger.warning(self.translate("Problem parsing timezone '{tz}' - Using UTC").format(tz=tz))
            self._timezone = pytz.timezone("UTC")
        self.logger.info(f"self.set_tz: -> self._timezone={self._timezone}")
        self.logger.info(f"self.set_tz: -> self._tzinfo={self._tzinfo}")
        return


    def set_tzinfo(self, tzinfo):
        """
        Set the timezone info

        :param tzinfo:
        """
        self._tzinfo = tzinfo
        return


    #################################################################
    # Time Methods
    #################################################################
    def now(self):
        """
        Returns the actual time in a timezone aware format

        :return: Actual time for the local timezone
        :rtype: datetime.datetime
        """

        if self._tzinfo is None:
            self._tzinfo = dateutil.tz.gettz('UTC')
        # tz aware 'localtime'
        return datetime.datetime.now(self._tzinfo)


    def tz(self):
        """
        Returns the the actual local timezone

        :return: Name of the timezone (like 'Europe/Berlin', or 'UTC' if not set)
        :rtype: str
        """

        return self._tz


    def tzinfo(self):
        """
        Returns the info about the actual local timezone

        :return: Timezone info
        :rtype: dateutil.tz.tz.tzfile
        """

        return self._tzinfo


    def tzname(self):
        """
        Returns the name about the actual local timezone (e.g. CET)

        :return: timezone string (like: 'CET')
        :rtype: str
        """

        return datetime.datetime.now(tzlocal()).tzname()


    def tznameST(self):
        """
        Returns the name for Standard Time in the local timezone (e.g. CET)

        :return: Timezone info (like: 'CET')
        :rtype: str
        """

        jan = datetime.datetime.fromtimestamp(datetime.datetime.timestamp(datetime.datetime(2020, 1, 1)), tzlocal())
        return jan.strftime("%Z")


    def tznameDST(self):
        """
        Returns the name for Daylight Saving Time (DST) in the local timezone (e.g. CEST)

        :return: Timezone info (like: 'CEST')
        :rtype: str
        """

        jul = datetime.datetime.fromtimestamp(datetime.datetime.timestamp(datetime.datetime(2020, 7, 1)), tzlocal())
        return jul.strftime("%Z")


    def utcnow(self):
        """
        Returns the actual time in GMT

        :return: Actual time in GMT
        :rtype: datetime.datetime
        """

        # tz aware utc time
        if self._utctz is None:
            self._utctz = dateutil.tz.gettz('UTC')
        return datetime.datetime.now(self._utctz)


    def utcinfo(self):
        """
        Returns the info about the GMT timezone

        :return: Timezone info
        :rtype: dateutil.tz.tz.tzfile
        """

        return self._utctz


    def runtime(self):
        """
        Returns the uptime of SmartHomeNG

        :return: Uptime in days, hours, minutes and seconds
        :rtype: datetime.timedelta
        """

        return datetime.datetime.now() - self._starttime


    def runtime_as_dict(self):
        """
        Returns the uptime of SmartHomeNG as a dict of integers

        :return: {days, hours, minutes, seconds}
        :rtype: dict
        """

        # return SmarthomeNG runtime
        rt = str(self.runtime())
        daytest = rt.split(' ')
        if len(daytest) == 3:
            days = int(daytest[0])
            hours, minutes, seconds = [float(val) for val in str(daytest[2]).split(':')]
        else:
            days = 0
            hours, minutes, seconds = [float(val) for val in str(daytest[0]).split(':')]
        total_seconds = days * 24 * 3600 + hours * 3600 + minutes * 60 + seconds

        return {'days': days, 'hours': hours, 'minutes': minutes, 'seconds': seconds, 'total_seconds': total_seconds}


    # -----------------------------------------------------------------------------------------------------
    #   Following methods implement some time handling
    # -----------------------------------------------------------------------------------------------------

    def _build_timediff_resulttype(self, delta, resulttype):
        if resulttype == 's':
            return delta.days * 24 * 3600 + delta.seconds
        if resulttype == 'm':
            return delta.days * 24 * 60 + delta.seconds / 60
        if resulttype == 'h':
            return delta.days * 24 + delta.seconds / 3600
        if resulttype == 'd':
            return delta.days + delta.seconds / (3600 * 24)
        if resulttype == 'im':
            return delta.days * 24 * 60 + delta.seconds // 60
        if resulttype == 'ih':
            return delta.days * 24 + delta.seconds // 3600
        if resulttype == 'id':
            return delta.days
        if resulttype == 'dhms':
            return delta.days, delta.seconds // 3600, (delta.seconds // 60 - (delta.seconds // 3600) * 60), (
                    delta.seconds % 60)
        if resulttype == 'dhms2':
            return delta.days, delta.seconds // 3600, (delta.seconds // 60), (delta.seconds % 60)
        if resulttype == 'ds':
            return delta.days, delta.seconds
        self.logger.error("_build_timediff_resulttype: Called with invalid resulttype parameter: {resulttype}".format(resulttype=resulttype))
        return -1


    def time_since(self, dt, resulttype='s'):
        """
        Calculates the time that has elapsed since the given datetime parameter

        :param dt: point in time (in the past)
        :param resulttype: type in which the result should be returned (s, m, h, d, im, ih, id, dhms, ds)
        :type: dt: str | datetime.datetime | datetime.date | int | float
        :type resulttype: str

        :return: Elapsed time
        :rtype: int|float|tuple
        """
        dt = self.datetime_transform(dt)
        if type(dt) is datetime.datetime:
            delta = self.now() - dt
            if delta.days < 0:
                self.logger.error("time_since: "+self.translate("Called with point in time that is later than now: {dt}").format(dt=dt))
                return (0, 0)
            return self._build_timediff_resulttype(delta, resulttype)
        else:
            self.logger.error("time_since: "+self.translate("Called with parameter that is not of type 'datetime': {dt}").format(dt=dt))
            return -1


    def time_until(self, dt, resulttype='s'):
        """
        Calculates the time that will elapse from now to the given datetime parameter

        :param dt: point in time (in the past)
        :param resulttype: type in which the result should be returned (s, m, h, d, im, ih, id, dhms, ds)
        :type: dt: str|datetime.datetime|datetime.date|int|float
        :type resulttype: str

        :return: Elapsed time
        :rtype: int|float|tuple
        """
        dt = self.datetime_transform(dt)
        if type(dt) is datetime:
            delta = dt - self.now()
            if delta.days < 0:
                self.logger.error("time_until: "+self.translate("Called with point in time that is earlier than now: {dt}").format(dt=dt))
                return (0, 0)
            return self._build_timediff_resulttype(delta, resulttype)
        else:
            self.logger.error("time_since: "+self.translate("Called with parameter that is not of type 'datetime': {dt}").format(dt=dt))
            return -1


    def time_diff(self, dt1, dt2, resulttype='s'):
        """
        Calculates the time between the two given datetime parameters

        :param dt1: first point in time
        :param dt2: second point in time
        :param resulttype: type in which the result should be returned (s, m, h, d, im, ih, id, dhms, ds)
        :type: dt1: str|datetime.datetime|datetime.date|int|float
        :type: dt2: str|datetime.datetime|datetime.date|int|float
        :type resulttype: str

        :return: Elapsed time
        :rtype: int|float|tuple
        """
        dt1 = self.datetime_transform(dt1)
        dt2 = self.datetime_transform(dt2)
        if type(dt1) is datetime.datetime and type(dt2) is datetime.datetime:
            delta = dt2 - dt1
            if delta.days < 0:
                delta = dt1 - dt2
            return self._build_timediff_resulttype(delta, resulttype)
        else:
            self.logger.error("time_since: "+self.translate("Called with parameter that is not of type 'datetime': {dt1}, {dt2}").format(dt1=dt2, dt2=dt2))
            return -1


    def seconds_to_displaystring(self, sec):
        """
        Convert number of seconds to time display-string

        :param sec: Number of seconds to convert
        :type sec: int

        :return: Display-string (in the form x days, y hours, z minutes, s seconds)
        :rtype: str
        """
        min = sec // 60
        sec = sec - min * 60
        std = min // 60
        min = min - std * 60
        days = std // 24
        std = std - days * 24

        result = ''
        if days == 1:
            result += str(days) + ' ' + self.translate('Tag')
        elif days > 0:
            result += str(days) + ' ' + self.translate('Tage')

        if result and std > 0:
            result += ', '
        if std == 1:
            result += str(std) + ' ' + self.translate('Stunde')
        elif std > 0:
            result += str(std) + ' ' + self.translate('Stunden')

        if result and min > 0:
            result += ', '
        if min == 1:
            result += str(min) + ' ' + self.translate('Minute')
        elif min > 0:
            result += str(min) + ' ' + self.translate('Minuten')

        if result and sec > 0:
            result += ', '
        if sec == 1:
            result += str(sec) + ' ' + self.translate('Sekunde')
        elif sec > 0:
            result += str(sec) + ' ' + self.translate('Sekunden')
        return result


    # -----------------------------------------------------------------------------------------------------
    #   Following methods implement some date handling
    # -----------------------------------------------------------------------------------------------------

    def datetime_transform(self, key):
        """
        Converts Date/Time parameter from various formats to a datetime.datetime format

        :param key: date/time
        :type key: str|datetime.datetime|datetime.date|int|float

        :return: Date/Time
        :rtype: datetime.datetime
        """
        if isinstance(key, datetime.datetime):
            key = key
        elif isinstance(key, datetime.date):
            key = datetime.datetime(key.year, key.month, key.day, 0, 0, 0)
        elif isinstance(key, int) or isinstance(key, float):
            key = datetime.datetime.utcfromtimestamp(key)
        elif isinstance(key, str):
            dayfirst = True
            yearfirst = False
            if key.count('/') > 1:
                dayfirst = False
            if key.count('-') > 1:
                yearfirst = True
                dayfirst = False
            try:
                key = dateutil.parser.parse(key, dayfirst=dayfirst, yearfirst=yearfirst)
            except (ValueError, OverflowError):
                raise ValueError(self.translate("Cannot parse datetime from string '{key}'").format(key=key))
        else:
            raise TypeError(self.translate("Cannot convert type '{key}' to datetime").format(key=type(key)))
        if isinstance(key, datetime.datetime) and key.tzinfo is None:
            key = self._timezone.localize(key)
        return key


    def date_transform(self, key):
        """
        Converts Date parameter from various formats to a datetime.date format

        :param key: date/time
        :type key: str|datetime.datetime|datetime.date|int|float

        :return: Date
        :rtype: datetime.date
        """
        try:
            key = self.datetime_transform(key).date()
        except (ValueError, OverflowError):
            raise ValueError(self.translate("Cannot parse date from string '{key}'").format(key=key))
        return key


    def beginning_of_week(self, week=None, year=None, offset=0):
        """
        Calculates the date of the beginning of a given week

        If no week and no year are specified, the beginning of the current week is calculated

        :param week: calender week to use for calculation
        :param year: year to use for calculation
        :param offset: negative number for previous weeks, positive for future ones
        :type week: int
        :type year: int
        :type offset: int

        :return: date the monday of given calender week
        :rtype: datetime.date
        """
        month = self.current_month()
        if week is None and year is None:
            week = self.calendar_week(self.today())
            year = self.current_year()
            if month == 1 and week > 50:
                year -= 1
        else:
            if week is None:
                self.logger.error("beginning_of_week: "+self.translate("Week not specified"))
                return self.today()
            if year is None:
                year = self.current_year()
                if month == 1 and week > 50:
                    year -= 1
        self.logger.debug(self.translate("Calculating beginning of week based on year {year}, week {week} and offset {offset}").format(year=year, week=week, offset=offset))
        week_beginning = datetime.datetime.strptime('{year}-{week}-1'.format(year=year, week=week), "%G-%V-%u") + dateutil.relativedelta.relativedelta(weeks=offset)

        return week_beginning.date()

    def beginning_of_month(self, month=None, year=None, offset=0):
        """
        Calculates the date of the beginning of a given month

        This method is used to make code more readable


        If no month is specified, the current month is used
        If no year is specified, the current year is used

        :param month: month to use for calculation
        :param year: year to use for calculation
        :param offset: negative number for previous months, positive for future ones
        :type month: int
        :type year: int
        :type offset: int

        :return: date the first day of given month
        :rtype: datetime.date
        """
        if month is None:
            month = self.current_month()
        if year is None:
            year = self.current_year()
        month_beginning = datetime.date(year, month, 1) + dateutil.relativedelta.relativedelta(months=offset)

        return month_beginning


    def beginning_of_year(self, year=None, offset=0):
        """
         Calculates the date of the beginning of a given year

         This method is used to make code more readable

         If no year is specified, the current year is used

         :param year: year to use for calculation
         :param offset: negative number for previous years, positive for future ones
         :type year: int
         :type offset: int

         :return: date the first day of given year
         :rtype: datetime.date
        """
        year_beginning = self.beginning_of_month(1, year) + dateutil.relativedelta.relativedelta(years=offset)
        return year_beginning


    def today(self, offset=0):
        """
        Return today's date

        :param offset: negative number for previous days, positive for future ones
        :type offset: int

        :return: date of today
        :rtype: datetime.date
        """
        return (datetime.datetime.now() + datetime.timedelta(days=offset)).date()


    def tomorrow(self):
        """
        Return tomorrow's date

        :return: date of tomorrow
        :rtype: datetime.date
        """
        return self.today() + datetime.timedelta(days=1)


    def yesterday(self):
        """
        Return yesterday's date

        :return: date of yesterday
        :rtype: datetime.date
        """
        return self.today() + datetime.timedelta(days=-1)


    def current_year(self, offset=0):
        """
        Return the current year

        :param offset: negative number for previous years, positive for future ones
        :type offset: int

        :return: year
        :rtype: int
        """
        return (self.today() + dateutil.relativedelta.relativedelta(years=offset)).year


    def current_month(self, offset=0):
        """
        Return the current month

        :param offset: negative number for previous months, positive for future ones
        :type offset: int

        :return: month
        :rtype: int
        """
        return (self.today() + dateutil.relativedelta.relativedelta(months=offset)).month


    def current_day(self, offset=0):
        """
        Return the current day

        :param offset: negative number for previous days, positive for future ones
        :type offset: int

        :return: day
        :rtype: int
        """
        return (self.today() + datetime.timedelta(days=offset)).day


    def length_of_year(self, year=None, offset=0):
        """
        Returns the length of a given year

        :param year: year to use for calculation
        :param offset: negative number for previous months, positive for future ones
        :type year: int
        :type offset: int

        :return: Length of year in days
        :rtype: int
        """
        if year is None:
            year = self.current_year()
        year += offset
        leap_year = True if year % 4 == 0 and (year % 100 != 0 or year % 400 == 0) else False
        return 365 if leap_year is False else 366


    def length_of_month(self, month=None, year=None, offset=0):
        """
        Returns the length of a given month for a given year

        :param month: month to use for calculation
        :param year: year to use for calculation
        :param offset: negative number for previous months, positive for future ones
        :type month: int
        :type year: int
        :type offset: int

        :return: Length of month in days
        :rtype: int
        """
        if month is None:
            month = self.current_month()
        if year is None:
            year = self.current_year()
        offset_dt = datetime.datetime(year, month, 1) + dateutil.relativedelta.relativedelta(months=offset)
        month = offset_dt.month
        year = offset_dt.year

        next_month = month
        next_year = year
        if next_month == 12:
            next_year += 1
            next_month = 0
        debug_month = "" if offset == 0 else " (offset {offset})".format(offset=offset)
        self.logger.debug(self.translate("Calculating length of month based on year {year}, month {month}{debug_month}").format(year=year, month=month, debug_month=debug_month))
        return (datetime.datetime(next_year, next_month+1, 1) - datetime.datetime(year, month, 1)).days


    def day_of_year(self, date=None, offset=0):
        """
        Calculate which day of the year the given date is

        :param date: date
        :param offset: negative number for previous days, positive for future ones
        :type date: str|datetime.datetime|datetime.date|int|float
        :type offset: int

        :return: day of year
        :rtype: int
        """
        if date:
            date = self.date_transform(date)
        else:
            date = self.today()
        date = date + datetime.timedelta(days=offset)
        return (date - datetime.date(date.year, 1, 1)).days + 1


    def weekday(self, date=None, offset=0):
        """
        Returns the ISO weekday of a given date (or of today, if date is None)

        Return the day of the week as an integer, where Monday is 1 and Sunday is 7. (ISO weekday)

        :param date: date
        :param offset: negative number for previous days, positive for future ones
        :type date: str|datetime.datetime|datetime.date|int|float
        :type offset: int

        :return: weekday (1=Monday .... 7=Sunday)
        :rtype: int
        """

        if date:
            weekday = self.date_transform(date)
            weekday = weekday + datetime.timedelta(days=offset)
        else:
            weekday = self.today() + datetime.timedelta(days=offset)
        return weekday.isoweekday()


    def calendar_week(self, date=None, offset=0):
        """
        Returns the calendar week (according to ISO)

        :param date: date
        :param offset: negative number for previous weeks, positive for future ones
        :type date: str|datetime.datetime|datetime.date|int|float
        :type offset: int

        :return: week (ISO)
        :rtype: int
        """
        if date:
            cal_week = self.date_transform(date) + dateutil.relativedelta.relativedelta(weeks=offset)
        else:
            cal_week = self.today() + dateutil.relativedelta.relativedelta(weeks=offset)
        return cal_week.isocalendar()[1]


    def weekday_name(self, date=None, offset=0):
        """
        Returns the name of the weekday for a given date

        :param date: date
        :param offset: negative number for previous days, positive for future ones
        :type date: str|datetime.datetime|datetime.date|int|float
        :type offset: int

        :return: weekday name
        :rtype: str
        """
        if date:
            dt = self.date_transform(date)
        else:
            dt = self.today()
        dt = dt + datetime.timedelta(days=offset)

        wday = self.weekday(dt)
        if wday == 1:
            day = "Montag"
        elif wday == 2:
            day = "Dienstag"
        elif wday == 3:
            day = "Mittwoch"
        elif wday == 4:
            day = "Donnerstag"
        elif wday == 5:
            day = "Freitag"
        elif wday == 6:
            day = "Samstag"
        elif wday == 7:
            day = "Sonntag"
        else:
            day = "?"

        return self.translate(day)


    def _get_nth_dow_in_month(self, dow, dow_week, year, month):
        """
        get nth day of week for given month and year

        :param dow:  day of week (1-7)
        :param dow_week: n for nth week (1-4)
        :param year: year to look into
        :param month: month to look into

        :return: date
        """
        day_1st = datetime.date(year, month, 1)
        dow_1st = self.weekday(datetime.date(year, month, 1))
        week = int(dow_week)

        if dow_1st <= dow:
            d_diff = dow - dow_1st
        else:
            d_diff = 7 - dow_1st + dow
        d_diff += (week - 1) * 7
        date = day_1st + datetime.timedelta(days=d_diff)
        self.logger.debug('dow_1st: d_diff {} -> {}'.format(d_diff, date))
        return date


    def _get_last_dow_in_month(self, dow, year, month):
        """
        get last day of week for given month and year

        :param dow: day of week (1-7)
        :param year: year to look into
        :param month: month to look into

        :return: date
        """
        day_last = datetime.date(year, month + 1, 1) + datetime.timedelta(days=-1)
        dow_last = self.weekday(datetime.date(year, month + 1, 1) + datetime.timedelta(days=-1))

        if dow_last >= dow:
            d_diff = dow_last - dow
        else:
            d_diff = dow_last + 7 - dow
        date = day_last - datetime.timedelta(days=d_diff)
        self.logger.debug('dow_last: d_diff {} -> {}'.format(d_diff, date))
        return date


    # -----------------------------------------------------------------------------------------------------
    #   Following methods implement some holiday handling
    # -----------------------------------------------------------------------------------------------------


    def _add_holiday_by_date(self, cust_date, gen_for_years):
        """
        Add a custom holiday for given day and month (and optionally year)

        :param cust_date:

        """
        cust_dict = {}
        self.logger.info(self.translate('custom holiday')+' (date): {}'.format(cust_date))

        for year in gen_for_years:
            d = datetime.date(year, cust_date['month'], cust_date['day'])

            cust_dict[d] = cust_date.get('name', '')

        self.holidays.append(cust_dict)
        return


    def _add_holiday_by_dow(self, cust_date, gen_for_years):
        """
        Add a custom holiday for given day-of-week

        :param cust_date:

        """
        cust_dict = {}
        self.logger.info(self.translate('custom holiday')+' (dow): {}'.format(cust_date))
        month = cust_date.get('month', None)
        try:
            dow_week = int(cust_date.get('dow_week', 0))
            if dow_week < 1:
                return
        except ValueError:
            if str(cust_date.get('dow_week', None)).lower() == 'last':
                dow_week = str(cust_date.get('dow_week', None)).lower()
            else:
                return

        try:
            dow_start_week = int(cust_date.get('dow_start_week', dow_week))
        except ValueError:
            dow_start_week = dow_week

        for year in gen_for_years:
            if month is None:
                # get every nth day-of-week in a year
                date = self._get_nth_dow_in_month(cust_date.get('dow', None), dow_start_week, year, 1)
                while date.year == year:
                    cust_dict[date] = cust_date.get('name', '')
                    date = date + datetime.timedelta(7*dow_week)
            else:
                # get a day-of-week in a given month
                if str(cust_date.get('dow_week', None)).lower() == 'last':
                    date = self._get_last_dow_in_month(cust_date.get('dow', None), year, month)
                else:
                    date = self._get_nth_dow_in_month(cust_date.get('dow', None), cust_date.get('dow_week', None), year, month)

                cust_dict[date] = cust_date.get('name', '')

        self.holidays.append(cust_dict)
        return


    def _add_custom_holidays(self):
        """
        Add custom holidays from etc/holidays.yaml to the initialized list of holidays

        :return: Number of valid custom holiday definitions
        """
        if self.holidays is None:
            self.logger.info("_add_custom_holidays: "+self.translate("Holidays are not initialized, cannot add custom holidays"))
            return 0

        custom = self.config.get('custom', [])
        if custom is None:
            custom = []
        count = 0
        if len(custom) > 0:
            for entry in custom:
                if isinstance(entry, str):
                    cust_date = json.loads(entry)
                else:
                    cust_date = entry
                # generate for range of years or a given year
                if cust_date.get('year', None) is None:
                    gen_for_years = self.years
                else:
                    gen_for_years = [cust_date['year']]

                # {'day': 2, 'month': 12, 'name': "Martin's Geburtstag"}
                if cust_date.get('month', None) and cust_date.get('day', None):
                    # generate holiday(s) for a given date (day/month)
                    self._add_holiday_by_date(cust_date, gen_for_years)
                    count += 1
                elif cust_date.get('dow', None) and cust_date.get('dow_week', None) and (0 < cust_date.get('dow', None) < 8):
                    # generate holiday(s) for a given weekday (dow/dowweek/month)
                    self._add_holiday_by_dow(cust_date, gen_for_years)
                    count += 1

        return count


    def add_custom_holiday(self, cust_date):
        """
        Add a custom holiday from etc/holidays.yaml to the initialized list of holidays

        :param cust_date: Dictionary with holiday definition (see: /etc/holidays.yaml.default)
        :type cust_date: dict
        """
        if self.holidays is None:
            self.logger.info("add_custom_holiday: "+self.translate("Holidays are not initialized, cannot add custom holidays"))
            return

        # generate for range of years or a given year
        if cust_date.get('year', None) is None:
            gen_for_years = self.years
        else:
            gen_for_years = [cust_date['year']]

        # {'day': 2, 'month': 12, 'name': "Martin's Geburtstag"}
        if cust_date.get('month', None) and cust_date.get('day', None):
            # generate holiday(s) for a given date (day/month)
            self._add_holiday_by_date(cust_date, gen_for_years)
        elif cust_date.get('dow', None) and cust_date.get('dow_week', None) and (0 < cust_date.get('dow', None) < 8):
            # generate holiday(s) for a given weekday (dow/dowweek/month)
            self._add_holiday_by_dow(cust_date, gen_for_years)

        log_msg = self.translate("Custom holiday definitions defined during runtime: {cust_date}")
        self.logger.warning(log_msg.format(cust_date=cust_date))

        return


    def add_custom_holiday_range(self, from_date, to_date=None, holiday_name=''):
        """
        Add a range of dates to the custom holidays

        :param from_date: First date to add
        :param to_date: Last date to add
        :param holiday_name: Name of the holidays
        :type from_date: str|datetime.datetime|datetime.date|int|float
        :type to_date: str|datetime.datetime|datetime.date|int|float
        :type holiday_name: str
        """
        from_date = self.date_transform(from_date)
        if to_date is None:
            to_date = from_date
        else:
            to_date = self.date_transform(to_date)

        num_days = (to_date-from_date).days + 1
        holiday_list = [from_date+datetime.timedelta(days=x) for x in range(num_days)]

        cust_dict = {}
        for holiday in holiday_list:
            cust_dict[holiday] = holiday_name
        self.holidays.append(cust_dict)
        return

# {"dow": 5, "dow_week": "last", "month": 7, "name": "Sysadmin day"}

    def _initialize_holidays(self):
        """
        Initialize the holidays according to etc/holidays.yaml for the current year and the two years to come
        """

        if self.holidays is None:
            self._etc_dir = self._sh._etc_dir
            conf_filename = os.path.join(self._sh._etc_dir, 'holidays'+YAML_FILE)
            self.config = shyaml.yaml_load(conf_filename)
            location = self.config.get('location', None)

            # prepopulate holidays for following years
            this_year=self.today().year
            self.years=[this_year, this_year+1, this_year+2]

            if location:
                country=location.get('country', 'DE')
                prov=location.get('province', None)
                state=location.get('state', None)
                try:
                    self.holidays = holidays.CountryHoliday(country, years=self.years, prov=prov, state=state)
                except KeyError as e:
                    self.logger.error("Error initializing self.holidays: {}".format(e))
                try:
                    self.public_holidays = holidays.CountryHoliday(country, years=self.years, prov=prov, state=state)
                except KeyError as e:
                    self.logger.error("Error initializing self.public_holidays: {}".format(e))
            else:
                self.holidays = holidays.CountryHoliday('US', years=self.years, prov=None, state=None)
                self.public_holidays = holidays.CountryHoliday('US', years=self.years, prov=None, state=None)

            if self.holidays is not None:
                c_logtext = self.translate('not defined')
                c_logcount = ''
                count = self._add_custom_holidays()
                if count > 0:
                    c_logcount = ' ' + str(count)
                    c_logtext = self.translate('defined')
                defined_state = ''
                if self.holidays.state is not None:
                    defined_state = ", state'" + self.holidays.state + "'"
                self.log_msg = self.translate("Using holidays for country '{country}', province '{province}'{state},{count} custom holiday(s) {defined}")
                self.log_msg = self.log_msg.format(country=self.holidays.country, province=self.holidays.prov, state=defined_state, count=c_logcount, defined=c_logtext)
                self.logger.info(self.log_msg)

                self.logger.info(self.translate('Defined holidays') + ':')
                for ft in sorted(self.holidays):
                    self.logger.info(' - {}: {}'.format(ft, self.holidays[ft]))

        return


    def is_weekend(self, date=None):
        """
        Returns True, if the date is on a weekend

        Note: Easter sunday is not considered a holiday (since it is a sunday already)!

        :param date: date for which the weekday should be returned. If not specified, today is used
        :type date: str|datetime.datetime|datetime.date|int|float

        :return: True, if date is on a weekend
        :rtype: bool
        """

        if date:
            dt = self.date_transform(date)
        else:
            dt = self.today()

        self._initialize_holidays()

        return self.weekday(dt) in [6,7]


    def is_holiday(self, date=None):
        """
        Returns True, if the date is a holiday (custom or public)

        Note: Easter sunday is not concidered a holiday (since it is a sunday already)!

        :param date: date for which the weekday should be returned. If not specified, today is used
        :type date: str|datetime.datetime|datetime.date|int|float

        :return: True, if date is on a holiday
        :rtype: bool
        """

        if date:
            dt = self.date_transform(date)
        else:
            dt = self.today()

        self._initialize_holidays()

        return (dt in self.holidays)


    def is_public_holiday(self, date=None):
        """
        Returns True, if the date is a public holiday

        Note: Easter sunday is not concidered a public holiday (since it is a sunday already)!

        :param date: date for which the weekday should be returned. If not specified, today is used
        :type date: str|datetime.datetime|datetime.date|int|float

        :return: True, if date is on a holiday
        :rtype: bool
        """

        if date:
            dt = self.date_transform(date)
        else:
            dt = self.today()

        self._initialize_holidays()

        return (dt in self.public_holidays)


    def holiday_name(self, date=None, as_list=False):
        """
        Returns the name of the holiday, if date is a holiday

        If there are multiple holidays on that date, all are returned

        :param date: date for which the holiday-name should be returned. If not specified, today is used
        :param as_list: If True, result is a list and not a str (comma delimited)
        :type date: str|datetime.datetime|datetime.date|int|float

        :return: name of the holiday(s)
        :rtype: str|list
        """
        if date:
            dt = self.date_transform(date)
        else:
            dt = self.today()

        self._initialize_holidays()

        if as_list:
            if self.holidays.get_list(dt):
                return self.holidays.get(dt)
        else:
            if self.holidays.get(dt):
                return self.holidays.get(dt)
        return ''


    def holiday_list(self, year=None):
        """
        Returns a list with the defined holidays

        :param year: Year for which the holiday list sould be returned
        :type year: int

        :return: List with holiday information
        :rtpye: list
        """
        hl = []
        for h in self.holidays:
            if year is None or h.year == year:
                hl.append({h, self.holidays[h]})
        return hl


    def public_holiday_list(self, year=None):
        """
        Returns a list with the defined public holidays

        :param year: Year for which the holiday list sould be returned
        :type year: int

        :return: List with holiday information
        :rtpye: list
        """
        hl = []
        for h in self.public_holidays:
            if year is None or h.year == year:
                hl.append({h, self.public_holidays[h]})
        return hl class Plugins():
    """
    Plugin loader Class. Parses config file and creates a worker thread for each plugin

    :param smarthome: Instance of the smarthome master-object
    :param configfile: Basename of the plugin configuration file
    :type samrthome: object
    :type configfile: str

    """
    _plugins = []
    _threads = []

    def __init__(self, smarthome, configfile):
        self._sh = smarthome
        self._configfile = configfile

        global _plugins_instance
        if _plugins_instance is not None:
            import inspect
            curframe = inspect.currentframe()
            calframe = inspect.getouterframes(curframe, 4)
            logger.critical("A second 'plugins' object has been created. There should only be ONE instance of class 'Plugins'!!! Called from: {} ({})".format(calframe[1][1], calframe[1][3]))

        _plugins_instance = self

        # until Backend plugin is modified
        if os.path.isfile(configfile+ YAML_FILE):
            self._plugin_conf_filename = configfile + YAML_FILE
        else:
            self._plugin_conf_filename = configfile + CONF_FILE
        smarthome._plugin_conf = self._plugin_conf_filename

        # read plugin configuration (from etc/plugin.yaml)
        _conf = lib.config.parse_basename(configfile, configtype='plugin')
        if _conf == {}:
            return

        logger.info('Load plugins')

        # for every section (plugin) in the plugin.yaml file
        for plugin in _conf:
            logger.debug("Plugins, section: {}".format(plugin))
            plugin_name, self.meta = self._get_pluginname_and_metadata(plugin, _conf[plugin])
            self._sh.shng_status['details'] = plugin_name   # Namen des Plugins übertragen
            #self._sh.shng_status['details'] = plugin        # Namen der Plugin Section übertragen

            # test if plugin defines item attributes
            item_attributes = self.meta.itemdefinitions
            if item_attributes is not None:
                attribute_keys = list(item_attributes.keys())
                for attribute_name in attribute_keys:
                    self._sh.items.add_plugin_attribute(plugin_name, attribute_name, item_attributes[attribute_name])

            # test if plugin defines item attribute prefixes (e.g. stateengine)
            item_attribute_prefixes = self.meta.itemprefixdefinitions
            if item_attribute_prefixes is not None:
                attribute_prefixes_keys = list(item_attribute_prefixes.keys())
                for attribute_prefix in attribute_prefixes_keys:
                    #logger.warning(f"plugin_name={plugin_name}, attribute_prefix={attribute_prefix}, item_attribute_prefixes[attribute_prefix]={item_attribute_prefixes[attribute_prefix]}")
                    self._sh.items.add_plugin_attribute_prefix(plugin_name, attribute_prefix, item_attribute_prefixes[attribute_prefix])

            # Test if plugin defines item structs
            item_structs = self.meta.itemstructs
            if item_structs is not None:
                struct_keys = list(item_structs.keys())
                for struct_name in struct_keys:
                    self._sh.items.add_struct_definition(plugin_name, struct_name, item_structs[struct_name])

            # Test if plugin is disabled
            if str(_conf[plugin].get('plugin_enabled', None)).lower() == 'false':
                logger.info("Section {} (plugin_name {}) is disabled - Plugin not loaded".format(plugin, _conf[plugin].get('plugin_name', None)))
            elif self.meta.test_shngcompatibility() and self.meta.test_pythoncompatibility():
                classname, classpath = self._get_classname_and_classpath(_conf[plugin], plugin_name)
                if (classname == '') and (classpath == ''):
                    logger.error("Plugins, section {}: plugin_name is not defined".format(plugin))
                elif classname == '':
                    logger.error("Plugins, section {}: class_name is not defined".format(plugin))
                elif classpath == '':
                    logger.error("Plugins, section {}: class_path is not defined".format(plugin))
                else:
                    args = self._get_conf_args(_conf[plugin])
#                    logger.warning("Plugin '{}' from section '{}': classname = {}, classpath = {}".format( str(classpath).split('.')[1], plugin, classname, classpath ) )
                    instance = self._get_instancename(_conf[plugin]).lower()
                    dummy = self._test_duplicate_pluginconfiguration(plugin, classname, instance)
                    try:
                        plugin_thread = PluginWrapper(smarthome, plugin, classname, classpath, args, instance, self.meta, self._configfile)
                        if plugin_thread._init_complete == True:
                            try:
                                self._plugins.append(plugin_thread.plugin)
                                self._threads.append(plugin_thread)
                                if instance == '':
                                    logger.info("Initialized plugin '{}' from section '{}'".format( str(classpath).split('.')[1], plugin ) )
                                else:
                                    logger.info("Initialized plugin '{}' instance '{}' from section '{}'".format( str(classpath).split('.')[1], instance, plugin ) )
                            except:
                                logger.warning("Plugin '{}' from section '{}' not loaded".format( str(classpath).split('.')[1], plugin ) )
                    except Exception as e:
                        logger.exception("Plugin '{}' from section '{}' exception: {}".format(str(classpath).split('.')[1], plugin, e))

        logger.info('Load of plugins finished')
        del(_conf)  # clean up


        # Tests für logic-Parameter Metadaten
        self.logic_parameters = {}

        for i in range(0, len(self._plugins) - 1):

            if self._plugins[i]._metadata.logic_parameters is not None:
                for param in self._plugins[i]._metadata.logic_parameters:
                    logger.debug("Plugins.__init__: Plugin '{}' logic_param '{}' = {}".format(self._plugins[i]._shortname, param, json.loads(json.dumps(self._plugins[i]._metadata.logic_parameters[param]))))
                    self.logic_parameters[param] = json.loads(json.dumps(self._plugins[i]._metadata.logic_parameters[param]))
                    self.logic_parameters[param]['plugin'] = self._plugins[i]._shortname

        return


    def get_logic_parameters(self):
        """
        Returns the list of all logic parameter definitions of all configured/loaded plugins

        :return:
        """
        paramdict = collections.OrderedDict(sorted(self.logic_parameters.items()))
        for p in paramdict:
            logger.debug("Plugins.get_logic_parameters(): {} = {}".format(p, paramdict[p]))

        return paramdict


    def _get_pluginname_and_metadata(self, plg_section, plg_conf):
        """
        Return the actual plugin name and the metadata instance

        :param plg_conf: loaded section of the plugin.yaml for the actual plugin
        :type plg_conf: dict

        :return: plugin_name and metadata_instance
        :rtype: string, object
        """
        plugin_name = plg_conf.get('plugin_name','').lower()
        plugin_version = plg_conf.get('plugin_version','').lower()
        if plugin_version != '':
            plugin_version = '._pv_' + plugin_version.replace('.','_')
        if plugin_name != '':
            meta = Metadata(self._sh, (plugin_name+plugin_version).replace('.',os.sep), 'plugin')
        else:
            classpath = plg_conf.get(KEY_CLASS_PATH,'')
            if classpath != '':
                plugin_name = classpath.split('.')[len(classpath.split('.'))-1].lower()
                if plugin_name.startswith('_pv'):
                    plugin_name = classpath.split('.')[len(classpath.split('.'))-2].lower()
                logger.debug("Plugins __init__: pluginname = '{}', classpath '{}'".format(plugin_name, classpath))
                meta = Metadata(self._sh, plugin_name, 'plugin', (classpath+plugin_version).replace('.',os.sep))
            else:
                logger.error("Plugin configuration section '{}': Neither 'plugin_name' nor '{}' are defined.".format( plg_section, KEY_CLASS_PATH ))
                meta = Metadata(self._sh, plugin_name, 'plugin', classpath)
        return (plugin_name+plugin_version, meta)


    def _get_conf_args(self, plg_conf):
        """
        Return the parameters/values for the actual plugin as args-dict

        :param plg_conf: loaded section of the plugin.yaml for the actual plugin
        :type plg_conf: dict

        :return: args = specified parameters and their values
        :rtype: dict
        """
        args = {}
        for arg in plg_conf:
            if arg != KEY_CLASS_NAME and arg != KEY_CLASS_PATH and arg != KEY_INSTANCE:
                value = plg_conf[arg]
                if isinstance(value, str):
                    value = "'{0}'".format(value)
                args[arg] = value
        return args


    def _get_classname_and_classpath(self, plg_conf, plugin_name):
        """
        Returns the classname and the classpath for the actual plugin

        :param plg_conf: loaded section of the plugin.yaml for the actual plugin
        :param plugin_name: Plugin name (to be used, for building classpass, if it is not specified in the configuration
        :type plg_conf: dict
        :type plugin_name: str

        :return: classname, classpass
        :rtype: str, str
        """
        classname = plg_conf.get(KEY_CLASS_NAME,'')
        plugin_version = ''
        if plugin_name == '':
            plugin_version = plg_conf.get('plugin_version','').lower()
            if plugin_version != '':
                plugin_version = '._pv_' + plugin_version.replace('.','_')

        if classname == '':
            classname = self.meta.get_string('classname')
        try:
            classpath = plg_conf[KEY_CLASS_PATH]
        except:
            if plugin_name == '':
                classpath = ''
            else:
                classpath = 'plugins.' + plugin_name
#        logger.warning("_get_classname_and_classpath: plugin_name = {}, classpath = {}, classname = {}".format(plugin_name, classpath, classname))
        return (classname, classpath+plugin_version)


    def _get_instancename(self, plg_conf):
        """
        Returns the instancename for the actual plugin

        :param plg_conf: loaded section of the plugin.yaml for the actual plugin
        :type plg_conf: dict

        :return: instance name
        :rtype: str
        """
        instance = ''
        if KEY_INSTANCE in plg_conf:
            instance = plg_conf[KEY_INSTANCE].strip()
            if instance == 'default':
                instance = ''
        return instance


    def _test_duplicate_pluginconfiguration(self, plugin, classname, instance):
        """
        Returns True, if a plugin instance of the classname is already loaded by another configuration section

        :param plugin: Name of the configuration
        :param classname: Name of the class to check
        :type plugin: str
        :type classname: str

        :return: True, if plugin is already loaded
        :rtype: bool
        """
        # give a warning if either a classic plugin uses the same class twice
        # or if a SmartPlugin uses the same class and instance twice (due to a copy & paste error)
        duplicate = False
        for p in self._plugins:
            if isinstance(p, SmartPlugin):
                if p.get_instance_name() == instance:
                    for t in self._threads:
                        if t.plugin == p:
                            if t.plugin.__class__.__name__ == classname:
                                duplicate = True
                                prev_plugin = t._name
                                logger.warning("Plugin section '{}' uses same class '{}' and instance '{}' as plugin section '{}'".format(plugin, p.__class__.__name__, 'default' if instance == '' else instance, prev_plugin))
                                break

            elif p.__class__.__name__ == classname:
                logger.warning("Multiple classic plugin instances of class '{}' detected".format(classname))
        return duplicate



    def __iter__(self):
        for plugin in self._plugins:
            yield plugin

    # ------------------------------------------------------------------------------------
    #   Following (static) methods of the class Plugins implement the API for plugins in shNG
    # ------------------------------------------------------------------------------------

    @staticmethod
    def get_instance():
        """
        Returns the instance of the Plugins class, to be used to access the plugin-api

        Use it the following way to access the api:

        .. code-block:: python

            from lib.plugin import Plugins
            plugins = Plugins.get_instance()

            # to access a method (eg. xxx()):
            plugins.xxx()


        :return: logics instance
        :rtype: object of None
        """
        if _plugins_instance == None:
            return None
        else:
            return _plugins_instance


    def return_plugin(self, configname):
        """
        Returns (the object of) one loaded smartplugin with given configname

        :param name: name of the plugin to get
        :type name: str

        :return: object of the plugin
        :rtype: object
        """
        for plugin in self._plugins:
            try:
                if plugin.get_configname() == configname:
                    return plugin
            except:
                pass
        return None
#        return self._plugins[name]


    def return_plugins(self):
        """
        Returns a list with the instances of all loaded plugins

        :return: list of plugin names
        :rtype: list
        """

        for plugin in self._plugins:
            yield plugin


    def _get_plugin_conf_filename(self):
        """
        Returns the name of the logic configuration file
        """
        return self._plugin_conf_filename


    class PyObject(ctypes.Structure):
        _fields_ = [("refcnt", ctypes.c_long)]


    def unload_plugin(self, configname):
        """
        Unloads (the object of) one loaded plugin with given configname

        :param name: name of the plugin to unload
        :type name: str

        :return: success or failure
        :rtype: bool
        """
        logger.info("unload_plugin -------------------------------------------------")

        myplugin = self.return_plugin(configname)
        mythread = self.get_pluginthread(configname)
        if myplugin.alive:
            myplugin.stop()

        logger.info("unload_plugin: configname = {}, myplugin = {}".format(configname, myplugin))

        logger.info("Plugins._plugins ({}) = {}".format(len(self._plugins), self._plugins))
        logger.info("Plugins._threads ({}) = {}".format(len(self._threads), self._threads))

        # execute de-initialization code of the plugin
        myplugin.deinit()

        self._threads.remove(mythread)
        self._plugins.remove(myplugin)
        logger.info("Plugins._plugins nach remove ({}) = {}".format(len(self._plugins), self._plugins))
        logger.info("Plugins._threads nach remove ({}) = {}".format(len(self._threads), self._threads))

        myplugin_address = id(myplugin)
        logger.info("myplugin sizeof       = {}".format(sys.getsizeof(myplugin)))
        logger.info("myplugin refcnt       = {}".format(self.PyObject.from_address(myplugin_address).refcnt))
        logger.info("myplugin referrer     = {}".format(gc.get_referrers(myplugin)))
        logger.info("myplugin referrer cnt = {}".format(len(gc.get_referrers(myplugin))))
        for r in gc.get_referrers(myplugin):
            logger.info("myplugin referrer     = {} / {} / {}".format(r, namestr(r, globals()), namestr(r, locals())))
        gc.collect()
        logger.info("myplugin referrer cnt2= {}".format(len(gc.get_referrers(myplugin))))

        del mythread
        del myplugin
        logger.warning("myplugin refcnt nach del    = {}".format(self.PyObject.from_address(myplugin_address).refcnt))
        try:
            logger.info("myplugin referrer cnt = {}".format(len(gc.get_referrers(myplugin))))
            for r in gc.get_referrers(myplugin):
                logger.info("myplugin referrer     = {}".format(r))
        except:
            pass

        logger.info("Plugins._plugins nach del ({}) = {}".format(len(self._plugins), self._plugins))
        logger.info("Plugins._threads nach del ({}) = {}".format(len(self._threads), self._threads))
        return False



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

    def start(self):
        logger.info('Start plugins')
        for plugin in self._threads:
            try:
                instance = plugin.get_implementation().get_instance_name()
                if instance != '':
                    instance = ", instance '"+instance+"'"
                logger.debug("Starting plugin '{}'{}".format(plugin.get_implementation().get_shortname(), instance))
            except:
                logger.debug("Starting classic-plugin from section '{}'".format(plugin.name))
            plugin.start()
        logger.info('Start of plugins finished')


    def stop(self):
        logger.info('Stop plugins')
        for plugin in self._threads:
            try:
                instance = plugin.get_implementation().get_instance_name()
                if instance != '':
                    instance = ", instance '"+instance+"'"
                logger.debug("Stopping plugin '{}'{}".format(plugin.get_implementation().get_shortname(), instance))
            except:
                logger.debug("Stopping classic-plugin from section '{}'".format(plugin.name))
            try:
                plugin.stop()
            except:
                logger.warning("Error while stopping plugin '{}'{}'".format(plugin.get_implementation().get_shortname(), instance))
        logger.info('Stop of plugins finished')


    def get_pluginthread(self, configname):
        """
        Returns one plugin with given name

        :return: Thread object for the given plugin name
        :rtype: object
        """
        for thread in self._threads:
            if thread.name == configname:
               return thread
        return None ITEM_DEFAULTS= __defaults = {'num': 0, 'str': '', 'bool': False, 'list': [], 'dict': {}, 'foo': None, 'scene': 0} FOO = 'foo' KEY_ENFORCE_UPDATES = 'enforce_updates' KEY_ENFORCE_CHANGE = 'enforce_change' KEY_CACHE = 'cache' KEY_CYCLE = 'cycle' KEY_CRONTAB = 'crontab' KEY_EVAL = 'eval' KEY_EVAL_TRIGGER = 'eval_trigger' KEY_TRIGGER = 'trigger' KEY_CONDITION = 'trigger_condition' KEY_NAME = 'name' KEY_TYPE = 'type' KEY_STRUCT = 'struct' KEY_REMARK = 'remark' KEY_INSTANCE = 'instance' KEY_VALUE = 'value' KEY_INITVALUE = 'initial_value' PLUGIN_PARSE_ITEM = 'parse_item' KEY_AUTOTIMER = 'autotimer' KEY_ON_UPDATE = 'on_update' KEY_ON_CHANGE = 'on_change' KEY_LOG_CHANGE =  'log_change' KEY_LOG_LEVEL =   'log_level' KEY_LOG_TEXT =    'log_text' KEY_LOG_MAPPING = 'log_mapping' KEY_LOG_RULES =   'log_rules' KEY_THRESHOLD = 'threshold' KEY_ATTRIB_COMPAT     = 'assign_compatibility'	# name of key in smarthome.yaml ATTRIB_COMPAT_V12     = 'compat_1.2' ATTRIB_COMPAT_LATEST  = 'latest' class Utils(object):

    @staticmethod
    def is_mac(mac):
        """
        Validates a MAC address

        :param mac: MAC address
        :type string: str

        :return: True if value is a MAC
        :rtype: bool
        """

        mac = str(mac)
        # notation without separators
        if len(mac) == 12:
            for c in mac:
                # each digit is hex
                if c not in '0123456789abcdefABCDEF':
                    return False
            return True

        # notation with separators -> 12 digits + 5 separators
        if len(mac) != 17:
            return False
        octets = re.split('[: -]', mac)
        # 6 groups...
        if len(octets) != 6:
            return False
        for o in octets:
            # ... of 2 digits each
            if len(o) != 2:
                return False
        # and each digit is hex
        for c in ''.join(octets):
            if c not in '0123456789abcdefABCDEF':
                return False
        return True

    @staticmethod
    def is_ip(string):
        """
        FUTURE: Checks if a string is a valid ip-address (v4 or v6)
        ACTUAL: redirects to ipv4 only check for backwards compatibility

        :param string: String to check
        :type string: str

        :return: True if an ip, false otherwise.
        :rtype: bool
        """

        return Utils.is_ipv4(string)
        # later: return (Utils.is_ipv4(string) or Utils.is_ipv6(string))

    @staticmethod
    def is_ipv4(string):
        """
        Checks if a string is a valid ip-address (v4)

        :param string: String to check
        :type string: str

        :return: True if an ip, false otherwise.
        :rtype: bool
        """

        try:
            ipaddress.IPv4Address(string)
            return True
        except ipaddress.AddressValueError:
            return False

    @staticmethod
    def is_ipv6(string):
        """
        Checks if a string is a valid ip-address (v6)

        :param string: String to check
        :type string: str

        :return: True if an ipv6, false otherwise.
        :rtype: bool
        """

        try:
            ipaddress.IPv6Address(string)
            return True
        except ipaddress.AddressValueError:
            return False

    @staticmethod
    def is_hostname(string):
        """
        Checks if a string is a valid hostname

        The hostname has is checked to have a valid format

        :param string: String to check
        :type string: str

        :return: True if a hostname, false otherwise.
        :rtype: bool
        """

        try:
            return bool(re.match("^(([a-zA-Z]|[a-zA-Z][a-zA-Z0-9-]*[a-zA-Z0-9])\\.)*([A-Za-z]|[A-Za-z][A-Za-z0-9\-]*[A-Za-z0-9])$", string))
        except TypeError:
            return False

    @staticmethod
    def get_local_ipv4_address():
        """
        Get local ipv4 address of the interface with the default gateway.
        Return '127.0.0.1' if no suitable interface is found
        NOTE: if more than one IP addresses are available and no external
        gateway is configured, thie method returns one of the configured
        addresses, but not deterministically.

        :return: IPv4 address as a string
        :rtype: string
        """
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(('10.255.255.255', 1))
            IP = s.getsockname()[0]
        except:
            IP = '127.0.0.1'
        finally:
            if 's' in locals():
                s.close()
        return IP

    @staticmethod
    def get_local_ipv6_address():
        """
        Get local ipv6 address of the interface with the default gateway.
        Return '::1' if no suitable interface is found

        :return: IPv6 address as a string
        :rtype: string
        """
        try:
            s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
            s.connect(('fda2:ffff:ffff:ffff:ffff:ffff:ffff:ffff', 1))
            IP = s.getsockname()[0]
        except:
            IP = '::1'
        finally:
            if 's' in locals():
                s.close()
        return IP


    @staticmethod
    def is_knx_groupaddress(groupaddress):
        """
        Checks if the passed string is a valid knx goup address

        The checked format is:
           main group (0-31 = 5 bits)
           middle group (0-7 = 3 bits)
           subgroup (0-255 = 8 bits)

        :param groupaddress: String to check
        :type groupaddress: str

        :return: True if a groupaddress can be recognized, false otherwise.
        :rtype: bool
        """
        if groupaddress == '':
            return True

        g = groupaddress.split('/')
        if len(g) != 3:
            return False
        if not(Utils.is_int(g[0]) and Utils.is_int(g[1]) and Utils.is_int(g[2])):
            return False
        if (int(g[0]) < 0) or (int(g[0]) > 31):
            return False
        if (int(g[1]) < 0) or (int(g[1]) > 7):
            return False
        if (int(g[2]) < 0) or (int(g[2]) > 255):
            return False
        return True


    @staticmethod
    def is_timeframe(string):
        """
        Checks if a string is a timeframe. A timeframe consists of a
        number and an optional unit identifier (e.g. 2h, 30m, ...).
        Unit identifiers are: i for minutes, h for hours, d for days,
        w for weeks, m for months, y for years. If omitted milliseconds
        are assumed.

        :param string: String to check.
        :type string: str

        :return: True if a timeframe can be recognized, false otherwise.
        :rtype: bool
        """

        try:
            return bool(TIMEFRAME_REGEX.search(string))
        except TypeError:
            return False

    @staticmethod
    def to_timeframe(value):
        """
        Converts a timeframe value to milliseconds. See is_timeframe() method.
        The special value 'now' is supported for the current time.

        :param value : value to convert
        :type value: str, int, ...

        :return: True if cant be converted and is true, False otherwise.
        :rtype: bool
        """

        minute = 60 * 1000
        hour = 60 * minute
        day = 24 * hour
        week = 7 * day
        month = 30 * day
        year = 365 * day
        frames = {'i': minute, 'h': hour, 'd': day, 'w': week, 'm': month, 'y': year}

        if value == 'now':
            value = '0'

        if not Utils.is_timeframe(value):
            raise Exception('Invalid value for boolean conversion: ' + value)

        amount, unit = TIMEFRAME_REGEX.match(value).groups()
        if unit in frames:
            return int(float(amount) * frames[unit])
        else:
            return int(amount)

    @staticmethod
    def is_int(string):
        """
        Checks if a string is a integer.

        :param string: String to check.
        :type string: str

        :return: True if a cast to int works, false otherwise.
        :rtype: bool
        """

        try:
            int(string)
            return True
        except ValueError:
            return False
        except TypeError:
            return False

    @staticmethod
    def is_float(string):
        """
        Checks if a string is a float.

        :param string: String to check.
        :type string: str

        :return: True if a cast to float works, false otherwise.
        :rtype: bool
        """

        try:
            float(string)
            return True
        except ValueError:
            return False
        except TypeError:
            return False

    @staticmethod
    def to_bool(value, default='exception'):
        """
        Converts a value to boolean.

        Raises exception if value is a string and can't be converted and if no default value is given
        Case is ignored. These string values are allowed:
        - True: 'True', "1", "true", "yes", "y", "t", "on"
        - False: "", "0", "faLse", "no", "n", "f", "off"
        Non-string values are passed to bool constructor.

        :param value : value to convert
        :param default: optional, value to return if value can not be parsed, if default is not set this method throws an exception
        :type value: str, object, int, ...
        :type value: str, object, int, ...

        :return: True if cant be converted and is true, False otherwise.
        :rtype: bool
        """
        # -> should it be possible to cast strings: 0 -> False and non-0 -> True (analog to integer values)?
        if isinstance(value, str):
            if value.lower() in ("yes", "y", "true", "t", "1", "on"):
                return True
            if value.lower() in ("no", "n", "false", "f", "0", "off", ""):
                return False
            if default == 'exception':
                raise Exception('Invalid value for boolean conversion: ' + value)
            else:
                return default
        return bool(value)

    @staticmethod
    def create_hash(plaintext):
        """
        Create hash (currently sha512) for given plaintext value

        :param plaintext: plaintext
        :type plaintext: str

        :return: hash of plaintext, lowercase letters
        :rtype: str
        """

        hashfunc = hashlib.sha512()
        hashfunc.update(plaintext.encode())
        return "".join(format(b, "02x") for b in hashfunc.digest())

    @staticmethod
    def is_hash(value):
        """
        Check if value is a valid hash (currently sha512) value

        :param value: value to check
        :type value: str

        :return: True: given value can be a sha512 hash, False: given value can not be a sha512 hash
        :rtype: bool
        """

        # a valid sha512 hash is a 128 charcter long string value
        if value is None or not isinstance(value, str) or len(value) != 128:
            return False

        # and its a hexedecimal value
        try:
            int(value, 16)
            return True
        except ValueError:
            return False

    @staticmethod
    def check_hashed_password(pwd_to_check, hashed_pwd):
        """
        Check if given plaintext password matches the hashed password
        An empty password is always rejected

        :param pwd_to_check: plaintext password to check
        :type pwd_to_check: str
        :param hashed_pwd: hashed password
        :type hashed_pwd: str

        :return: True: password matches, False: password does not match
        :rtype: bool
        """

        if pwd_to_check is None or pwd_to_check == '':
            # No password given -> return "not matching"
            return False

        # todo: check pwd_to_check for minimum length? <- msinn: not here, password policy at a central point

        return Utils.create_hash(pwd_to_check) == hashed_pwd.lower()

    @staticmethod
    def strip_quotes(string):
        """
        If a string contains quotes as first and last character, this function
        returns the string without quotes, otherwise the string is returned unchanged

        :param string: string to check for quotes
        :type string: str

        :return: string with quotes stripped
        :rtype: str
        """
        if type(string) is str:
            string = string.strip()
            if len(string) >= 2:
                if string[0] in ['"', "'"]:        # check if string starts with ' or "
                    if string[-1] == string[0]:    # and end with it
                        if string.count(string[0]) == 2:  # if they are the only one
                            string = string[1:-1]  # remove them
        return string


    @staticmethod
    def string_to_list(string):
        """
        Convert a string to a list

        If the parameter is not of type str, the parameter gest returned unchanged

        If parameter string is
        - a list, it gets returned unchanged
        - a simple datatype other than string, it gets returned unchanged
        - an empty string, it gets returned unchanged
        - a non-empty string, it gets converted to a list of len=1
        - format [<str>,<str>], it gets converted to a list

        :param string: string to convert
        :type string: str

        :return: list of unchanged value
        """
        if isinstance(string, list):
            return string
        if not isinstance(string, str):
            return string
        if len(string) == 0:
            return string
        if string[0] != '[':
            return [string]
        hl = Utils.strip_square_brackets(string).split(',')
        rl = []
        for e in hl:
            er = e.strip()
            if Utils.strip_quotes(er) != er:
                er = Utils.strip_quotes(er)
            else:
                if er.find('.') != -1:
                    er=float(er)
                else:
                    er=int(er)
            rl.append(er)
        return rl


    @staticmethod
    def strip_square_brackets(string):
        """
        If a string contains square brackets as first and last character, this function
        returns the string without the brackets, otherwise the string is returned unchanged

        :param string: string to check for square brackets
        :type string: str

        :return: string with square brackets stripped
        :rtype: str
        """
        if type(string) is str:
            string = string.strip()
            if len(string) >= 2:
                if string[0] == '[':           # check if string starts with [
                    if string[-1] == ']':      # and end with ]
                        string = string[1:-1]  # remove them
        return string

    @staticmethod
    def strip_quotes_fromlist(string):
        """
        If a string representation of a list contains quotes as first and last character of
        a list entry, this function returns the string representation without the qoutes,
        otherwise the string is returned unchanged

        :param string: string representation of a list to check for quotes
        :type string: str

        :return: string representation with square quotes stripped
        :rtype: str
        """
        if type(string) is str:
            string = string.strip()
            if len(string) >= 2:
                string2 = Utils.strip_square_brackets(string)
                if string2 != string:
                    str_list = string2.split(',')
                    string3 = ''
                    for s in str_list:
                        if string3 != '':
                            string3 += ', '
                        string3 += Utils.strip_quotes(s)
                    string = string3
        return string


    @staticmethod
    def get_type(var):
        """
        returns the type of the passed variable

        :param var: Variable to get the type of
        :return: type of the var
        :rtype: str
        """

        return str(type(var))[8:-2]


    @staticmethod
    def execute_subprocess(commandline, wait=True):
        """
        Executes a subprocess via a shell and returns the output written to stdout by this process as a string
        """
        # call date command
        p = subprocess.Popen(commandline, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True)

        # Talk with date command i.e. read data from stdout and stderr. Store this info in tuple ##
        # Interact with process: Send data to stdin. Read data from stdout and stderr, until end-of-file is reached.
        # Wait for process to terminate. The optional input argument should be a string to be sent to the child process, or None, if no data should be sent to the child.
        (result, err) = p.communicate()
    #    logger.warning("get_process_info: commandline='{}', result='{}', err='{}'".format(command, result, err))

    #    print("result="+str(result))
    #    print("err="+str(err))
        if wait:
            # Wait for date to terminate. Get return returncode
            p.wait()
        return (str(result, encoding='utf-8', errors='strict'), str(err, encoding='utf-8', errors='strict')) class Property:
    """
    Inner class Property of item class.

    This class encapsulates all properties that are publicly available

    An instance of this class is created in the __init__ method of the item class
    """

    def __init__(self, parent):
        self._item = parent
        self.logger = logging.getLogger(__name__)

    def _ro_error(self):
        prop = inspect.stack()[1][3]
        self.logger.error("Cannot set readonly property '{}' of item '{}'".format(prop, self._item._path))
        return

    def _type_error(self, err):
        prop = inspect.stack()[1][3]
        self.logger.error("Cannot set property '{}' of item '{}' to a {} value".format(prop, self._item._path, err))
        return

    def _cast_warning(self, value):
        prop = inspect.stack()[1][3]
        self.logger.warning("Casting value '{}' to required type before assigning it to property '{}' of item '{}'".format(value, prop, self._item._path))
        return

    @property
    def attributes(self):
        """
        Read-Only Property: attributes. List of plugin-specific attribute names

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return list(self._item.conf.keys())

    def init_dynamic_properties(self):
        """
        Initialize dynamic properties to get the values of plugin-specific attributes
        """
        for confattr in self._item.conf.keys():
            setattr(self, confattr, self.get_config_attribute(confattr))
        return

    def get_config_attribute(self, attr):
        return self._item.conf.get(attr, '')

    @property
    def defined_in(self):
        """
        Read-Only Property: defined_in . The filename in which the item was defined

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._filename

    @defined_in.setter
    def defined_in(self, value):
        self._ro_error()
        return

    @property
    def enforce_updates(self):
        """
        Property: enforce_updates

        Available in SmartHomeNG v1.6 and above

        :param value: enforce_update state of the item
        :type value: bool

        :return: enforce_update state of the item
        :rtype: bool
        """
        return self._item._enforce_updates

    @enforce_updates.setter
    def enforce_updates(self, value):

        if isinstance(value, bool):
            self._item._enforce_updates = value
            return
        else:
            self._type_error('non-boolean')
            return

    @property
    def enforce_change(self):
        """
        Property: enforce_change

        Available in SmartHomeNG v1.6 and above

        :param value: enforce_change state of the item
        :type value: bool

        :return: enforce_change state of the item
        :rtype: bool
        """
        return self._item._enforce_change

    @enforce_change.setter
    def enforce_change(self, value):

        if isinstance(value, bool):
            self._item._enforce_change = value
            return
        else:
            self._type_error('non-boolean')
            return

    @property
    def eval(self):
        """
        Property: eval expression

        Available in SmartHomeNG v1.6 and above

        :param value: eval expression of the item
        :type value: str

        :return: eval expression of the item
        :rtype: str
        """
        if self._item._eval:
            return self._item._eval
        return ''

    @eval.setter
    def eval(self, value):

        if isinstance(value, str):
            if value == '':
                self._item._eval = None
            else:
                self._item._eval = value
            return
        else:
            self._type_error('non-non-string')
            return

    @property
    def eval_unexpanded(self):
        """
        Property: eval expression

        Available in SmartHomeNG v1.6 and above

        :param value: eval expression of the item
        :type value: str

        :return: eval expression of the item
        :rtype: str
        """
        if self._item._eval:
            return self._item._eval
        return ''

    @eval_unexpanded.setter
    def eval_unexpanded(self, value):

        if isinstance(value, str):
            self._item._lock.acquire()
            self._item._process_eval(value)
            self._item._lock.release()
            return
        else:
            self._type_error('non-non-string')
            return

    @property
    def last_change(self):
        """
        Read-Only Property: last_change

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_change()

    @last_change.setter
    def last_change(self, value):
        self._ro_error()
        return

    @property
    def last_change_age(self):
        """
        Read-Only Property: last_change_age

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_change_age()

    @last_change_age.setter
    def last_change_age(self, value):
        self._ro_error()
        return

    @property
    def last_change_by(self):
        """
        Read-Only Property: last_change_by

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_change_by()

    @last_change_by.setter
    def last_change_by(self, value):
        self._ro_error()
        return

    @property
    def last_update(self):
        """
        Read-Only Property: last_update

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_update()

    @last_update.setter
    def last_update(self, value):
        self._ro_error()
        return

    @property
    def last_update_age(self):
        """
        Read-Only Property: last_update_age

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_update_age()

    @last_update_age.setter
    def last_update_age(self, value):
        self._ro_error()
        return

    @property
    def last_update_by(self):
        """
        Read-Only Property: last_update_by

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_update_by()

    @last_update_by.setter
    def last_update_by(self, value):
        self._ro_error()
        return

    @property
    def last_trigger(self):
        """
        Read-Only Property: last_trigger

        Available in SmartHomeNG v1.9.1 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_trigger()

    @last_trigger.setter
    def last_trigger(self, value):
        self._ro_error()
        return

    @property
    def last_trigger_age(self):
        """
        Read-Only Property: last_trigger_age

        Available in SmartHomeNG v1.9.1 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_trigger_age()

    @last_trigger_age.setter
    def last_trigger_age(self, value):
        self._ro_error()
        return

    @property
    def last_trigger_by(self):
        """
        Read-Only Property: last_trigger_by

        Available in SmartHomeNG v1.9.1 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_trigger_by()

    @last_trigger_by.setter
    def last_trigger_by(self, value):
        self._ro_error()
        return

    @property
    def last_value(self):
        """
        Read-Only Property: last_value

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_last_value()

    @last_value.setter
    def last_value(self, value):
        self._ro_error()
        return

    @property
    def name(self):
        """
        Property: name

        Available in SmartHomeNG v1.6 and above

        :param value: name of the item
        :type value: str

        :return: name of the item
        :rtype: str
        """
        return self._item._name

    @name.setter
    def name(self, value):

        if not isinstance(value, str):
            self._cast_warning(value)
            value = '{}'.format(value)
        if value == '':
            self._item._name = self._item._path
        else:
            self._item._name = value
        return

    @property
    def on_change(self):
        """
        Read-Only Property: on_update

        Available in SmartHomeNG v1.6 and above

        :return: path of on_update definitions
        :rtype: str
        """
        return self._item._build_on_xx_list(self._item._on_change_dest_var, self._item._on_change)

    @on_change.setter
    def on_change(self, value):
        self._ro_error()
        return

    @property
    def on_change_unexpanded(self):
        """
        Read-Only Property: on_update

        Available in SmartHomeNG v1.6 and above

        :return: path of on_update definitions
        :rtype: str
        """
        return self._item._build_on_xx_list(self._item._on_change_dest_var_unexp, self._item._on_change_unexpanded)

    @on_change_unexpanded.setter
    def on_change_unexpanded(self, value):
        if isinstance(value, str):
            value = [value]
        if isinstance(value, list):
            if value == [] or self._checkstrtype(value):
                self._item._lock.acquire()
                self._item._process_on_xx_list('on_change', value)
                self._item._lock.release()
            else:
                self._type_error('list containing non-string')
                return
            return
        else:
            self._type_error('non-list')
            return

    @property
    def on_update(self):
        """
        Read-Only Property: on_update

        Available in SmartHomeNG v1.6 and above

        :return: path of on_update definitions
        :rtype: str
        """
        return self._item._build_on_xx_list(self._item._on_update_dest_var, self._item._on_update)

    @on_update.setter
    def on_update(self, value):
        self._ro_error()
        return

    @property
    def on_update_unexpanded(self):
        """
        Read-Only Property: on_update

        Available in SmartHomeNG v1.6 and above

        :return: path of on_update definitions
        :rtype: str
        """
        return self._item._build_on_xx_list(self._item._on_update_dest_var_unexp, self._item._on_update_unexpanded)

    @on_update_unexpanded.setter
    def on_update_unexpanded(self, value):
        if isinstance(value, str):
            value = [value]
        if isinstance(value, list):
            if value == [] or self._checkstrtype(value):
                self._item._lock.acquire()
                self._item._process_on_xx_list('on_update', value)
                self._item._lock.release()
            else:
                self._type_error('list containing non-string')
                return
            return
        else:
            self._type_error('non-list')
            return

    @property
    def path(self):
        """
        Read-Only Property: path

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._path

    @path.setter
    def path(self, value):
        self._ro_error()
        return

    @property
    def prev_change(self):
        """
        Read-Only Property: prev_change

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_change()

    @prev_change.setter
    def prev_change(self, value):
        self._ro_error()
        return

    @property
    def prev_change_age(self):
        """
        Read-Only Property: prev_change_age

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_change_age()

    @prev_change_age.setter
    def prev_change_age(self, value):
        self._ro_error()
        return

    @property
    def prev_change_by(self):
        """
        Read-Only Property: prev_change_by

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_change_by()

    @prev_change_by.setter
    def prev_change_by(self, value):
        self._ro_error()
        return

    @property
    def prev_update(self):
        """
        Read-Only Property: prev_update

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_update()

    @prev_update.setter
    def prev_update(self, value):
        self._ro_error()
        return

    @property
    def prev_update_age(self):
        """
        Read-Only Property: prev_update_age

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_update_age()

    @prev_update_age.setter
    def prev_update_age(self, value):
        self._ro_error()
        return

    @property
    def prev_update_by(self):
        """
        Read-Only Property: prev_update_by

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_update_by()

    @prev_update_by.setter
    def prev_update_by(self, value):
        self._ro_error()
        return

    @property
    def prev_trigger(self):
        """
        Read-Only Property: prev_trigger

        Available in SmartHomeNG v1.9.1 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_trigger()

    @prev_trigger.setter
    def prev_trigger(self, value):
        self._ro_error()
        return

    @property
    def prev_trigger_age(self):
        """
        Read-Only Property: prev_trigger_age

        Available in SmartHomeNG v1.9.1 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_trigger_age()

    @prev_trigger_age.setter
    def prev_trigger_age(self, value):
        self._ro_error()
        return

    @property
    def prev_trigger_by(self):
        """
        Read-Only Property: prev_trigger_by

        Available in SmartHomeNG v1.9.1 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_trigger_by()

    @prev_trigger_by.setter
    def prev_trigger_by(self, value):
        self._ro_error()
        return

    @property
    def prev_value(self):
        """
        Read-Only Property: prev_value

        Available in SmartHomeNG v1.6 and above

        :return: path of the item
        :rtype: str
        """
        return self._item._get_prev_value()

    @prev_value.setter
    def prev_value(self, value):
        self._ro_error()
        return

    @property
    def trigger(self):
        """
        Property: Triggers of the item

        Available in SmartHomeNG v1.6 and above

        :param value: list of triggers
        :type value: list

        :return: [] if not defined or a list of triggers
        :rtype: list of str
        """
        if self._item._trigger:
            return self._item._trigger
        return []

    def _checkstrtype(self, obj):
        return bool(obj) and all(isinstance(elem, str) for elem in obj)

    @trigger.setter
    def trigger(self, value):

        if isinstance(value, list):
            if value == []:
                self._item._trigger = False
                self._item._trigger_unexpanded = []
            else:
                if self._checkstrtype(value):
                    self._item._trigger = value
                    self._item._trigger_unexpanded = value
                else:
                    self._type_error('list containing non-string')
                    return
            return
        else:
            self._type_error('non-list')
            return

    @property
    def trigger_unexpanded(self):
        """
        Property: Triggers of the item

        Available in SmartHomeNG v1.6 and above

        :param value: list of triggers
        :type value: list

        :return: [] if not defined or a list of triggers
        :rtype: list of str
        """
        if self._item._trigger:
            return self._item._trigger_unexpanded
        return []

    @trigger_unexpanded.setter
    def trigger_unexpanded(self, value):
        if isinstance(value, str):
            value = [value]
        if isinstance(value, list):
            if value == [] or self._checkstrtype(value):
                self._item._lock.acquire()
                self._item._process_trigger_list('trigger', value)
                self._item._lock.release()
            else:
                self._type_error('list containing non-string')
                return
            return
        else:
            self._type_error('non-list')
            return

    @property
    def type(self):
        """
        Read-Only Property: type

        Available in SmartHomeNG v1.6 and above

        :return: type of the item
        :rtype: str
        """
        return self._item._type

    @type.setter
    def type(self, value):
        self._ro_error()
        return

    @property
    def value(self):
        """
        Property: value

        Available in SmartHomeNG v1.6 and above

        :param value: value of the item
        :type value: <type of the item>

        :return: value of the item
        :rtype: <type of the item>
        """
        return copy.deepcopy(self._item._value)

    @value.setter
    def value(self, value):

        self._item(value, caller='Property', source='Assign')
        return
import logging
import datetime
import dateutil.parser
import os
import copy
import json
import threading
import ast
import time             # for calls to time in eval
import math             # for calls to math in eval
                    import math
                    import lib.userfunctions as uf
                import math
                import lib.userfunctions as uf
        import math
        import lib.userfunctions as uf
from math import *
from lib.shtime import Shtime
from lib.plugin import Plugins
from lib.constants import (ITEM_DEFAULTS, FOO, KEY_ENFORCE_UPDATES, KEY_ENFORCE_CHANGE, KEY_CACHE, KEY_CYCLE, KEY_CRONTAB, KEY_EVAL,
                           KEY_EVAL_TRIGGER, KEY_TRIGGER, KEY_CONDITION, KEY_NAME, KEY_TYPE, KEY_STRUCT, KEY_REMARK, KEY_INSTANCE,
                           KEY_VALUE, KEY_INITVALUE, PLUGIN_PARSE_ITEM, KEY_AUTOTIMER, KEY_ON_UPDATE, KEY_ON_CHANGE,
                           KEY_LOG_CHANGE, KEY_LOG_LEVEL, KEY_LOG_TEXT, KEY_LOG_MAPPING, KEY_LOG_RULES,
                           KEY_THRESHOLD, KEY_ATTRIB_COMPAT, ATTRIB_COMPAT_V12, ATTRIB_COMPAT_LATEST)
from lib.utils import Utils
from .property import Property
from .helpers import *
        self._threshold = False
        self._threshold_data = [0,0,False]
        self._type = None
        self._struct = None
        self._value = None
        self.__last_value = None
        self.__prev_value = None

        self.property = Property(self)
        # history
        # TODO: create history Arrays for some values (value, last_change, last_update  (usage: multiklick,...)
        # self.__history = [None, None, None, None, None]
        #
        # def getValue(num):
        #    return (str(self.__history[(num - 1)]))
        #
        # def addValue(avalue):
        #    self.__history.append(avalue)
        #    if len(self.__history) > 5:
        #        self.__history.pop(0)
        #

        #  if 'item_change_log' is set in etc/smarthome.yaml, set loglevel for logging every item change to INFO (instead of DEBUG)
        if hasattr(smarthome, '_item_change_log'):
            self._change_logger = logger.info
        else:
            self._change_logger = logger.debug

        #############################################################
        # Initialize attribute assignment compatibility
        #############################################################
        global ATTRIB_COMPAT_DEFAULT
        if ATTRIB_COMPAT_DEFAULT == '':
            if hasattr(smarthome, '_'+KEY_ATTRIB_COMPAT):
                config_attrib = getattr(smarthome,'_'+KEY_ATTRIB_COMPAT)
                if str(config_attrib) in [ATTRIB_COMPAT_V12, ATTRIB_COMPAT_LATEST]:
                    logger.info("Global configuration: '{}' = '{}'.".format(KEY_ATTRIB_COMPAT, str(config_attrib)))
                    ATTRIB_COMPAT_DEFAULT = config_attrib
                else:
                    logger.warning("Global configuration: '{}' has invalid value '{}'.".format(KEY_ATTRIB_COMPAT, str(config_attrib)))
            if ATTRIB_COMPAT_DEFAULT == '':
                ATTRIB_COMPAT_DEFAULT = ATTRIB_COMPAT_DEFAULT_FALLBACK

        #############################################################
        # Item Attributes
        #############################################################
        self._filename = dict(config.items()).get('_filename', None)
        setattr(self, '_type', dict(config.items()).get(KEY_TYPE))
        for attr, value in config.items():
            if not isinstance(value, dict):
                if attr in [KEY_CYCLE, KEY_NAME, KEY_TYPE, KEY_STRUCT, KEY_VALUE, KEY_INITVALUE]:
                    if attr == KEY_INITVALUE:
                        attr = KEY_VALUE
                    setattr(self, '_' + attr, value)
                elif attr in [KEY_EVAL]:
                    self._process_eval(value)
                elif attr in [KEY_CACHE, KEY_ENFORCE_UPDATES, KEY_ENFORCE_CHANGE]:  # cast to bool
                    try:
                        setattr(self, '_' + attr, cast_bool(value))
                    except:
                        logger.warning("Item '{0}': problem parsing '{1}'.".format(self._path, attr))
                        continue
                elif attr in [KEY_CRONTAB]:  # cast to list
                    if isinstance(value, str):
                        value = [value, ]
                    setattr(self, '_' + attr, value)
                elif attr in [KEY_EVAL_TRIGGER] or (self._use_conditional_triggers and attr in [KEY_TRIGGER]):  # cast to list
                    self._process_trigger_list(attr, value)
                elif (attr in [KEY_CONDITION]) and self._use_conditional_triggers:  # cast to list
                    if isinstance(value, list):
                        cond_list = []
                        for cond in value:
                            cond_list.append(dict(cond))
                        self._trigger_condition = self._build_trigger_condition_eval(cond_list)
                        self._trigger_condition_raw = cond_list
                    else:
                        logger.warning("Item __init__: {}: Invalid trigger_condition specified! Must be a list".format(self._path))
                elif attr in [KEY_ON_CHANGE, KEY_ON_UPDATE]:
                    self._process_on_xx_list(attr, value)

                elif attr in [KEY_LOG_LEVEL]:
                    if value != '':
                        level = value.upper()
                        level_name = level
                        if Utils.is_int(level):
                            level = int(level)
                            level_name = logging.getLevelName(level)
                        if logging.getLevelName(level) == 'Level ' + str(level):
                            logger.warning(f"Item {self._path}: Invalid loglevel '{value}' defined in attribute '{KEY_LOG_LEVEL}' - Level 'INFO' will be used instead")
                            setattr(self, '_log_level_name', 'INFO')
                            setattr(self, '_log_level', logging.getLevelName('INFO'))
                        else:
                            setattr(self, '_log_level_name', level_name)
                            setattr(self, '_log_level', logging.getLevelName(level_name))
                elif attr in [KEY_LOG_CHANGE]:
                    if value != '':
                        setattr(self, '_log_change', value)
                        self._log_change_logger = logging.getLogger('items.'+value)
                        # set level to make logger appear in internal list of loggers (if not configured by logging.yaml)
                        if self._log_change_logger.level == 0:
                            if self._log_level == 'DEBUG':
                                self._log_change_logger.setLevel('DEBUG')
                            else:
                                self._log_change_logger.setLevel('INFO')
                        if self._log_level is None:
                            setattr(self, '_log_level_name', 'INFO')
                            setattr(self, '_log_level', logging.getLevelName('INFO'))
                elif attr in [KEY_LOG_MAPPING]:
                    if value != '':
                        try:
                            value_dict = ast.literal_eval(value)
                            setattr(self, '_log_mapping', value_dict)
                        except Exception as e:
                            logger.warning(f"Item {self._path}: Invalid data for attribute '{KEY_LOG_MAPPING}': {value} - Exception: {e}")
                elif attr in [KEY_LOG_RULES]:
                    if value != '':
                        try:
                            value_dict = ast.literal_eval(value)
                            setattr(self, '_log_rules', value_dict)
                        except Exception as e:

                            logger.warning(f"Item {self._path}: Invalid data for attribute '{KEY_LOG_RULES}': {value} - Exception: {e}")