"""class SenseMeFan.

This class provides TCP/UDP access to Haiku SenseMe capable fans.
Based on work from Bruce at http://bruce.pennypacker.org/tag/senseme-plugin/
https://github.com/bpennypacker/SenseME-Indigo-Plugin

Source can be found at https://github.com/TomFaulkner/SenseMe
"""
import json
import logging
import math
import re
import socket
import time

from .lib import MWT, BackgroundLoop
from .lib.xml import data_to_xml

LOGGER = logging.getLogger(__name__)

__author__ = "Tom Faulkner"
__url__ = "https://github.com/TomFaulkner/SenseMe/"


class SenseMe:
    """SenseMe device class.

    Suggested use, if not statically defining devices is to call
    senseme.discover(), which will return a list of SenseMe objects rather than
    instantiating directly.

    However, if ip or name is known instantiating based on that without the
    other fields works.

    If SenseMe is instantiated without ip or name a discovery will be done and
    the first device to answer the broadcast will be the device represented by
    this object. Any later answers will be ignored.

    After init it is suggested to start_monitoring() to make whoosh and some
    other queries instant rather than blocking for ten or so seconds.
    """

    PORT = 31415

    def __init__(self, ip="", name="", model="", series="", mac="", **kwargs):
        """Init a SenseMe device.

        :param ip: IP address, if known, is not necessary if name is known or
            only one device exists in the home
        :param name: Device name, as configured/displayed in HaikuHome app.
           Is not necessary, if IP is known or if this is the only device on
           the network.
        :param model: Device model number, isn't actually used at this time,
            but could be used in the future if there is a difference in feature
            sets
        :param series: See comment on model
        :param mac: Could be used to talk to a device if name and ip aren't
            known, is not currently used.
        """
        if not ip or not name:
            # if ip or name are unknown, discover the device
            # if one is known but not the other a specific device will discover
            # if not one device, or none, will discover
            self.discover_single_device()
        else:
            self.ip = ip
            self.name = name
            self.mac = mac
            self.details = ""
            self.model = model
            self.series = series
        self.monitor_frequency = kwargs.get("monitor_frequency", 45)
        self._monitoring = False
        self._all_cache = None

        self._background_monitor = BackgroundLoop(
            self.monitor_frequency, self._get_all_bare
        )
        if kwargs.get("monitor", False):
            self.start_monitor()

    def __repr__(self):
        """Repr Method."""
        return (
            f"SenseMe(name='{self.name}', ip='{self.ip}', "
            f"model='{self.model}', series='{self.series}', "
            f"mac='{self.mac}')"
        )

    def __str__(self):
        """Str Method."""
        return (
            f"SenseMe Device: {self.name}, Series: {self.series}. "
            f"(Speed: {self.speed}. Brightness: {self.brightness})"
        )

    # The following properties are generic to haiku devices

    @property
    def beeper_sound(self):
        """Returns if the audible beeper sound is ON or OFF"""
        return self._query("<%s;DEVICE;BEEPER;GET>" % self.name)

    @beeper_sound.setter
    def beeper_sound(self, mode):
        """
        Sets the audible beeper sound to ON or OFF

        :param mode: valid values are ON and OFF
        """
        mode = mode.upper()
        if mode != "OFF" and mode != "ON":
            LOGGER.debug("%s is an invalid beeper sound setting.  Use ON or OFF" % mode)
        else:
            self._send_command("<%s;DEVICE;BEEPER;%s>" % (self.name, mode))
            self._update_cache("DEVICE;BEEPER", mode)

    @property
    def device_time(self):
        """Return the current time on the device"""
        return self._query("<%s;TIME;VALUE;GET>" % self.name)

    @property
    def firmware_name(self):
        """Return the name of the firmware file running on the SenseMe device"""
        return self._query("<%s;FW;NAME;GET>" % self.name)

    @property
    def firmware_version(self):
        """Return the name of the firmware running on the fan"""
        name = self.firmware_name
        return self._query("<%s;FW;%s;GET>" % (self.name, name))

    @property
    def led_indicators(self):
        """Returns if the fan's indicator LED is ON or OFF"""
        return self._query("<%s;DEVICE;INDICATORS;GET>" % self.name)

    @led_indicators.setter
    def led_indicators(self, mode):
        """
        Sets the fan's indicator LED to ON or OFF

        :param mode: valid values are ON and OFF
        """
        mode = mode.upper()
        if mode != "OFF" and mode != "ON":
            LOGGER.debug("%s is an led indicator setting.  Use ON or OFF" % mode)
        else:
            self._send_command("<%s;DEVICE;INDICATORS;%s>" % (self.name, mode))
            self._update_cache("DEVICE;INDICATORS", mode)

    @property
    def network_ap_status(self):
        """Returns if the wireless access point is enabled on the device"""
        return self._query("<%s;NW;AP;GET;STATUS>" % self.name)

    @property
    def network_dhcp_state(self):
        """Returns if the device is running a local dhcp service"""
        return self._query("<%s;NW;DHCP;GET>" % self.name)

    @property
    def network_parameters(self):
        """
        Return a string of all network settings for the device

        The string is of the form IP Address;Subnet Mask;Default Gateway
        """
        raw = self._queryraw("<%s;NW;PARAMS;GET;ACTUAL>" % self.name)

        # grab all between the parens
        raw = raw[raw.find("(") + 1 : raw.find(")")]

        vals = raw.split(";")
        return vals[4], vals[5], vals[6]

    @property
    def network_ssid(self):
        """Return the wireless SSID the device is connected to"""
        return self._query("<%s;NW;SSID;GET>" % self.name)

    @property
    def network_token(self):
        """Return the network token of the device"""
        return self._query("<%s;NW;TOKEN;GET>" % self.name)

    # The following properties are specific to haiku fans
    @property
    def fan_powered_on(self):
        """
        Returns if the fan is on or off

        Power On = True
        Power Off = False
        """
        if self._query("<%s;FAN;PWR;GET>" % self.name) == "ON":
            return True
        else:
            return False

    @fan_powered_on.setter
    def fan_powered_on(self, power_on=True):
        """
        Sets the fan to be on or off

        :param power_on: True=On, False=Off
        """
        if power_on:
            self._send_command("<%s;FAN;PWR;ON>" % self.name)
            self._update_cache("FAN;PWR", "ON")
        else:
            self._send_command("<%s;FAN;PWR;OFF>" % self.name)
            self._update_cache("FAN;PWR", "OFF")

    def fan_toggle(self):
        """Toggle power state of fan."""
        self.fan_powered_on = not self.fan_powered_on

    @property
    def height(self):
        """Returns/sets fan height in centimeters"""
        return int(self._query("<%s;WINTERMODE;HEIGHT;GET>" % self.name))

    @height.setter
    def height(self, val):
        """
        Sets fan height in centimeters

        :param val: The height in centimeters
        """
        if val > 0:
            self._send_command("<%s;WINTERMODE;HEIGHT;SET;%s>" % (self.name, val))
            self._update_cache("WINTERMODE;HEIGHT", str(val))

    @property
    def speed(self):
        """Returns the fan speed."""
        # loop and exception handling due to:
        # https://github.com/TomFaulkner/SenseMe/issues/38
        for _ in range(2):
            speed = self._query("<%s;FAN;SPD;GET;ACTUAL>" % self.name)
            LOGGER.debug(speed)
            try:
                return int(speed)
            except ValueError:
                if speed == "OFF":
                    return 0
        return 0  # return something rather than cause an exception

    @speed.setter
    def speed(self, speed):
        """
        Sets fan speed.

        :param val: Valid values are between 0 and 7.
        """
        if speed > 7:  # max speed is 7, fan corrects to 7
            speed = 7
        elif speed < 0:  # 0 also sets fan to off automatically
            speed = 0
        self._send_command("<%s;FAN;SPD;SET;%s>" % (self.name, speed))
        self._update_cache("FAN;SPD;ACTUAL", str(speed))

    @property
    def min_speed(self):
        """Returns the fan's minimum speed setting."""
        return self._query("<%s;FAN;SPD;GET;MIN>" % self.name)

    @property
    def max_speed(self):
        """Returns the fan's maximum speed setting."""
        return self._query("<%s;FAN;SPD;GET;MAX>" % self.name)

    @property
    def room_settings_fan_speed_limits(self):
        """Returns a tuple of the min and max fan speeds the room is configured to support"""
        raw = self._queryraw("<%s;FAN;BOOKENDS;GET>" % self.name)

        # grab all between the parens
        raw = raw[raw.find("(") + 1 : raw.find(")")]

        vals = raw.split(";")
        return int(vals[3]), int(vals[4])

    @room_settings_fan_speed_limits.setter
    def room_settings_fan_speed_limits(self, speeds):
        """
        Set a tuple of the min and max fan speeds the room is configured to support

        :params speeds: [min,max]
        """
        if speeds[0] >= speeds[1]:
            LOGGER.debug("min speed cannot exceed max speed")
            return

        self._send_command(
            "<%s;FAN;BOOKENDS;SET;%s;%s>" % (self.name, speeds[0], speeds[1])
        )

    def dec_speed(self, decrement=1):
        """ Decreases fan speed by decrement value, default is 1."""
        self.speed -= decrement

    def inc_speed(self, increment=1):
        """Increases fan speed by increment value, default is 1."""
        self.speed += increment

    @property
    def learnmode(self):
        """Returns/sets the fan's wintermode setting."""
        mode = self._query("<%s;LEARN;STATE;GET>" % self.name).upper()
        if mode == "LEARN":
            return "ON"
        else:
            return mode

    @learnmode.setter
    def learnmode(self, mode):
        """
        Returns/sets the fan's wintermode setting.

        :params mode: valid values are OFF and ON
        """
        mode = mode.upper()
        if mode == "ON":
            mode = "LEARN"
        elif mode != "OFF":
            LOGGER.error("%s is an invalid learn mode" % mode)

        self._send_command("<%s;LEARN;STATE;SET;%s>" % (self.name, mode))
        self._update_cache("LEARN;STATE", mode)

    @property
    def learnmode_zerotemp(self):
        """Returns the temperature in fahrenheit that the fan will auto shutoff"""
        temp = self._query("<%s;LEARN;ZEROTEMP;GET>" % self.name)
        return math.ceil(((int(temp) * 9) / 500) + 32)

    @learnmode_zerotemp.setter
    def learnmode_zerotemp(self, temp):
        """
        Sets the temperature in fahrenheit that the fan will auto shutoff

        :params temp: valid values are 50-90
        """
        if temp < 50:
            temp = 50
        elif temp > 90:
            temp = 90

        temp = int((((int(temp) - 32) * 500) / 9))
        self._send_command("<%s;LEARN;ZEROTEMP;SET;%s>" % (self.name, temp))
        self._update_cache("LEARN;ZEROTEMP", temp)

    @property
    def learnmode_minspeed(self):
        """Returns the fan's minimum speed setting in learning mode."""
        return self._query("<%s;LEARN;MINSPEED;GET>" % self.name)

    @learnmode_minspeed.setter
    def learnmode_minspeed(self, speed):
        """
        Sets the fan's minimum speed setting in learning mode.

        :param speed: valid values are 0-7
        """
        if speed > 7:  # max speed is 7, fan corrects to 7
            speed = 7
        elif speed < 0:  # 0 also sets fan to off automatically
            speed = 0

        self._send_command("<%s;LEARN;MINSPEED;SET;%s>" % (self.name, speed))
        self._update_cache("LEARN;MINSPEED", speed)

    @property
    def learnmode_maxspeed(self):
        """Returns the fan's maximum speed setting."""
        return self._query("<%s;LEARN;MAXSPEED;GET>" % self.name)

    @learnmode_maxspeed.setter
    def learnmode_maxspeed(self, speed):
        """
        Sets the fan's minimum speed setting in learning mode.

        :param speed: valid values are 0-7
        """
        if speed > 7:  # max speed is 7, fan corrects to 7
            speed = 7
        elif speed < 0:  # 0 also sets fan to off automatically
            speed = 0

        self._send_command("<%s;LEARN;MAXSPEED;SET;%s>" % (self.name, speed))
        self._update_cache("LEARN;MAXSPEED", speed)

    @property
    def smartsleep_mode(self):
        """Returns the fan's smart sleep mode setting."""
        return self._query("<%s;SLEEP;STATE;GET>" % self.name)

    @smartsleep_mode.setter
    def smartsleep_mode(self, mode):
        """
        Sets the fan's smart sleep mode setting.

        :param mode: valid values are ON and OFF
        """
        mode = mode.upper()
        if mode != "ON" and mode != "OFF":
             LOGGER.error(
                "%s is an invalid sleep mode. Valid values are ON and OFF" % mode
            )

        self._send_command("<%s;SLEEP;STATE;%s>" % (self.name, mode))
        self._update_cache("SLEEP;STATE", mode)

    @property
    def smartsleep_idealtemp(self):
        """Returns the fan's smart sleep ideal temp setting."""
        temp = self._query("<%s;SMARTSLEEP;IDEALTEMP;GET>" % self.name)

        return math.ceil(((int(temp) * 9) / 500) + 32)

    @smartsleep_idealtemp.setter
    def smartsleep_idealtemp(self, temp):
        """
        Sets the fan's smart sleep ideal temp setting.

        :param temp: valid values are 50-90 degrees fahrenheit
        """
        if temp < 50:
            temp = 50
        elif temp > 90:
            temp = 90

        temp = int((((int(temp) - 32) * 500) / 9))
        self._send_command("<%s;SMARTSLEEP;IDEALTEMP;SET;%s>" % (self.name, temp))
        self._update_cache("SMARTSLEEP;IDEALTEMP", temp)

    @property
    def smartsleep_minspeed(self):
        """ Returns the fan's smartsleep minimum speedsetting."""
        return self._query("<%s;SMARTSLEEP;MINSPEED;GET>" % self.name)

    @smartsleep_minspeed.setter
    def smartsleep_minspeed(self, speed):
        """
        Returns/sets the fan's smartsleep minimum speedsetting.

        :params speed: valid values are 0-7
        """
        if speed > 7:  # max speed is 7, fan corrects to 7
            speed = 7
        elif speed < 0:  # 0 also sets fan to off automatically
            speed = 0

        self._send_command("<%s;SMARTSLEEP;MINSPEED;SET;%s>" % (self.name, speed))
        self._update_cache("SMARTSLEEP;MINSPEED", speed)

    @property
    def smartsleep_maxspeed(self):
        """Returns the fan's smart sleep minimum speed setting."""
        return self._query("<%s;SMARTSLEEP;MAXSPEED;GET>" % self.name)

    @smartsleep_maxspeed.setter
    def smartsleep_maxspeed(self, speed):
        """
        Sets the fan's smart sleep maximum speed setting.

        :param speed: valid values are 0-7
        """
        if speed > 7:  # max speed is 7, fan corrects to 7
            speed = 7
        elif speed < 0:  # 0 also sets fan to off automatically
            speed = 0

        self._send_command("<%s;SMARTSLEEP;MAXSPEED;SET;%s>" % (self.name, speed))
        self._update_cache("SMARTSLEEP;MAXSPEED", speed)

    @property
    def smartsleep_wakeup_brightness(self):
        """Returns light brightness at wakeup for sleep mode"""
        result = self._query("<%s;SLEEP;EVENT;OFF;GET>" % self.name)
        if (result == "LIGHT,PWR,OFF") or (result == "OFF"):
            return 0
        else:
            return int(result.replace("LIGHT,LEVEL,", ""))

    @smartsleep_wakeup_brightness.setter
    def smartsleep_wakeup_brightness(self, light):
        """
        Sets light brightness at wakeup for sleep mode

        :param light: Valid values are between 0 and 16.
        """
        if light > 16:
            light = 16
        elif light < 0:
            light = 0
        self._send_command(
            "<%s;SLEEP;EVENT;OFF;SET;LIGHT,LEVEL,%s>" % (self.name, light)
        )
        self._update_cache("SLEEP;EVENT;OFF;LIGHT,LEVEL,", str(light))

    @property
    def fan_direction(self):
        """Returns the direction of the fan"""
        return self._query("<%s;FAN;DIR;GET>" % self.name)

    @fan_direction.setter
    def fan_direction(self, mode):
        """
        Sets the direction of the fan rotation

        :params mode: Valid values are FWD and REV
        """
        mode = mode.upper()

        if mode != "FWD" and mode != "REV":
            LOGGER.error(
                "%s is an invalid direction.  Valid values are FWD and REV" % mode
            )
        else:
            self._send_command("<%s;FAN;DIR;SET;%s>" % (self.name, mode))
            self._update_cache("FAN;DIR", mode)

    @property
    def fan_motionmode(self):
        """Returns the fan motion sensor mode"""
        return self._query("<%s;FAN;AUTO;GET>" % self.name)

    @fan_motionmode.setter
    def fan_motionmode(self, mode):
        """
        Sets the fan motion sensor mode

        :param mode: valid values are ON and OFF
        """
        mode = mode.upper()
        if mode != "OFF" and mode != "ON":
            LOGGER.error(
                "%s is an invalid fan motion mode.  Valid modes are ON and OFF" % mode
            )
        else:
            self._send_command("<%s;FAN;AUTO;SET;%s>" % (self.name, mode))
            self._update_cache("FAN;AUTO", mode)

    @property
    def motionmode_mintimer(self):
        """Returns the minimum timer setting in minutes for the fan and light auto shutoff on no motion."""
        timer = self._query("<%s;SNSROCC;TIMEOUT;GET;MIN>" % self.name)
        return int(int(timer) / 60000)

    @property
    def motionmode_maxtimer(self):
        """Returns the minimum timer setting in minutes for the fan and light auto shutoff on no motion."""
        timer = self._query("<%s;SNSROCC;TIMEOUT;GET;MAX>" % self.name)
        return int(int(timer) / 60000)

    @property
    def motionmode_currenttimer(self):
        """Returns the current timer setting in minutes for the fan and light auto shutoff on no motion."""
        timer = self._query("<%s;SNSROCC;TIMEOUT;GET;CURR>" % self.name)
        return int(int(timer) / 60000)

    @motionmode_currenttimer.setter
    def motionmode_currenttimer(self, timeout):
        """Sets the timout setting in minutes for the fan and light auto shutoff on no motion."""
        self._send_command("<%s;SNSROCC;TIMEOUT;SET;%s>" % (self.name, int(int(timeout)*60000)))
        self._update_cache("SNSROCC;TIMEOUT", timeout)

    @property
    def motionmode_occupied_status(self):
        """Returns  if the room is currently OCCUPIED or UNOCCUPIED based on the motion sensor in the fan."""
        return self._query("<%s;SNSROCC;STATUS;GET>" % self.name)

    @property
    def wintermode(self):
        """Returns the fan's winter mode setting."""
        return self._query("<%s;WINTERMODE;STATE;GET>" % self.name)

    @wintermode.setter
    def wintermode(self, mode):
        """
        Returns/sets the fan's winter mode setting.

        :param mode: valid values are OFF and ON
        """
        mode = mode.upper()
        if mode != "OFF" and mode != "ON":
            LOGGER.error(
                "%s is an invalid winter mode. Valid modes are ON and OFF" % mode
            )
        else:
            self._send_command("<%s;WINTERMODE;STATE;%s>" % (self.name, mode))
            self._update_cache("WINTERMODE;STATE", mode)

    @property
    def smartmode(self):
        """Returns the fan's smart mode setting."""
        return self._query("<%s;SMARTMODE;STATE;GET>" % self.name)

    @smartmode.setter
    def smartmode(self, mode):
        """
        Sets the fan's smartmode setting.

        :param mode: valid values are OFF, COOLING, and HEATING
        """
        mode = mode.upper()
        if mode != "OFF" and mode != "COOLING" and mode != " HEATING":
            LOGGER.error("%s is an invalid smartmode" % mode)

        self._send_command("<%s;SMARTMODE;STATE;SET;%s>" % (self.name, mode))
        self._update_cache("SMARTMODE;ACTUAL", mode)

    @property
    def whoosh(self):
        """Retrieve whoosh mode.

        This can have a ten second delay since there is no known one item
        request to retrieve status
        """
        try:
            if self.get_attribute("FAN;WHOOSH;STATUS") == "ON":
                return True
            else:
                return False
        except KeyError:
            LOGGER.error("FAN;WHOOSH;STATUS wasn't found in dict")
            raise OSError("Fan failed to return whoosh status")

    @whoosh.setter
    def whoosh(self, whoosh_on):
        """Set the whoosh mode.

        This can have a ten second delay since there is no known one item
        request to retrieve status

        :param whoosh_on: valid values are True or False
        """
        if whoosh_on:
            self._send_command("<%s;FAN;WHOOSH;ON>" % self.name)
            self._update_cache("FAN;WHOOSH;STATUS", "ON")
        else:
            self._send_command("<%s;FAN;WHOOSH;OFF>" % self.name)
            self._update_cache("FAN;WHOOSH;STATUS", "OFF")

    # The following properties are specific to haiku fans
    # add-on light modules.  Most of these apply to the
    # standalone light units as well

    @property
    def brightness(self):
        """Returns light brightness."""
        # workaround for https://github.com/TomFaulkner/SenseMe/issues/38
        for _ in range(2):
            result = self._query("<%s;LIGHT;LEVEL;GET;ACTUAL>" % self.name)
            try:
                return int(result)
            except ValueError:
                if result == "OFF":
                    return 0
        return 0  # return something rather than cause an exception

    @brightness.setter
    def brightness(self, light):
        """
        Sets light brightness.

        :param lightL Valid values are between 0 and 16.
        """
        if light > 16:
            light = 16
        elif light < 0:
            light = 0
        self._send_command("<%s;LIGHT;LEVEL;SET;%s>" % (self.name, light))
        self._update_cache("LIGHT;LEVEL;ACTUAL", str(light))

    @property
    def min_brightness(self):
        """Returns the add-on lights minimum brightness setting."""
        brightness = self._query("<%s;LIGHT;LEVEL;GET;MIN>" % self.name)
        LOGGER.debug(brightness)

        return brightness

    @min_brightness.setter
    def min_brightness(self, light):
        """
        Sets the add-on lights minimum brightness setting.

        :param light: valid values are 0-16
        """
        if light > 16:
            light = 16
        elif light < 0:
            light = 0

        self._send_command("<%s;LIGHT;LEVEL;MIN;%s>" % (self.name, light))
        self._update_cache("LIGHT;LEVEL;MIN", light)

    @property
    def max_brightness(self):
        """Returns the add-on lights maximum brightness setting."""
        return int(self._query("<%s;LIGHT;LEVEL;GET;MAX>" % self.name))

    @max_brightness.setter
    def max_brightness(self, light):
        """
        Sets the add-on lights maximum brightness setting.

        :param light: valid values are 0-16
        """
        if light > 16:
            light = 16
        elif light < 0:
            light = 0

        self._send_command("<%s;LIGHT;LEVEL;MAX;%s>" % (self.name, light))
        self._update_cache("LIGHT;LEVEL;MAX", light)

    @property
    def room_settings_brightness_limits(self):
        """Returns a tuple of the min and max light brightnesses the room supports"""
        raw = self._queryraw("<%s;LIGHT;BOOKENDS;GET>" % self.name)

        # grab all between the parens
        raw = raw[raw.find("(") + 1 : raw.find(")")]

        vals = raw.split(";")
        return int(vals[3]), int(vals[4])

    @room_settings_brightness_limits.setter
    def room_settings_brightness_limits(self, limits):
        """
        Sets a tuple of the min and max light brightnesses the room supports

        :params limits: [min,max]
        """
        if limits[0] >= limits[1]:
            LOGGER.debug("minbrightness cannot exceed maxbrightness")
        self._send_command(
            "<%s;LIGHT;BOOKENDS;SET;%s;%s>" % (self.name, limits[0], limits[1])
        )

    def dec_brightness(self, decrement=1):
        """
        Decreases fan speed by decrement value, default is 1.

        :param decrement: number of steps to decrement with the call
        """
        self.brightness -= decrement

    def inc_brightness(self, increment=1):
        """
        Increases brightness by increment value, default is 1.

        :param increment: number of steps to increment with the call
        """
        self.brightness += increment

    @property
    def is_fan_light_installed(self):
        """
         Returns if the optional light module is installed in the fan

         :return True if present or False if not
         """
        mode = self._query("<%s;DEVICE;LIGHT;GET>" % self.name)
        return mode.lower() == "present"

    @property
    def light_motionmode(self):
        """Returns the if the add on light responds to the motion sensor"""
        return self._query("<%s;LIGHT;AUTO;GET>" % self.name)

    @light_motionmode.setter
    def light_motionmode(self, mode):
        """
        Sets the if the add on light responds to the motion sensor

        :params mode: valid values are OFF and ON
        """
        mode = mode.upper()
        if mode != "ON" and mode != "OFF":
            LOGGER.error("%s is an invalid light motion mode" % mode)
        else:
            self._send_command("<%s;LIGHT;AUTO;%s>" % (self.name, mode))
            self._update_cache("LIGHT;AUTO", mode)

    @property
    def light_powered_on(self):
        """Returns True if the lige is on False if its off"""
        if self._query("<%s;LIGHT;PWR;GET>" % self.name) == "ON":
            return True
        return False

    @light_powered_on.setter
    def light_powered_on(self, power_on=True):
        """
        Turns the addon light module on or off

        :param power_on: True equals on, False equals off
        """
        if power_on:
            self._send_command("<%s;LIGHT;PWR;ON>" % self.name)
            self._update_cache("LIGHT;PWR", "ON")
        else:
            self._send_command("<%s;LIGHT;PWR;OFF>" % self.name)
            self._update_cache("LIGHT;PWR", "OFF")

    def light_toggle(self):
        """Toggle power state of light."""
        self.light_powered_on = not self.light_powered_on

    @staticmethod
    def listen(cycles=30):
        """Listen for broadcasts and logs them for debugging purposes.

        Listens for cycles iterations
        """
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.bind(("", 31415))
        for x in range(1, cycles):
            m = sock.recvfrom(1024)
            LOGGER.info(m)

    def _send_command(self, msg):
        sock = socket.socket()
        sock.settimeout(5)

        sock.connect((self.ip, self.PORT))
        sock.send(msg.encode("utf-8"))
        sock.close()

    def _query(self, msg):
        sock = socket.socket()
        sock.settimeout(5)

        sock.connect((self.ip, self.PORT))
        sock.send(msg.encode("utf-8"))

        try:
            status = sock.recv(1048).decode("utf-8")
            LOGGER.info("Status: " + status)
        except socket.timeout:
            LOGGER.error("Socket Timed Out")
        else:
            # TODO: this shouldn't return data OR False, handle this better
            sock.close()
            LOGGER.info(str(status))
            match_obj = re.match("\(.*;([^;]+)\)", status)
            if match_obj:
                return match_obj.group(1)
            else:
                return False

    def _queryraw(self, msg):
        """An alternate version of query that does not do a default regex match on the results"""
        sock = socket.socket()
        sock.settimeout(5)

        sock.connect((self.ip, self.PORT))
        sock.send(msg.encode("utf-8"))

        try:
            status = sock.recv(1048).decode("utf-8")
            LOGGER.info("Status: " + status)
        except socket.timeout:
            LOGGER.error("Socket Timed Out")
        else:
            # TODO: this shouldn't return data OR False, handle this better
            sock.close()
            LOGGER.info(str(status))
            return status

    def send_raw(self, msg):
        """Send a raw command. Device name is not included.

        Return list of results.
        Sometimes multiple results come in at between iterations and they end
        up on the same string

        :param msg: command to send
        :return: list of responses as str
        """
        sock = socket.socket()
        sock.settimeout(5)

        sock.connect((self.ip, self.PORT))
        sock.send(msg.encode("utf-8"))

        messages = []
        timeout_occurred = False
        while True:
            try:
                recv = sock.recv(1048).decode("utf-8")
                LOGGER.info("Status: " + recv)
                messages.append(recv)
            except socket.timeout:
                LOGGER.info("Socket Timed Out")
                # most likely this means no more data, give it one more iter
                if timeout_occurred:
                    break
                else:
                    timeout_occurred = True
            else:
                LOGGER.info(str(recv))
        sock.close()
        return messages

    def _update_cache(self, attribute, value):
        """Update an attribute in the cache with a new value.

        Allows the cache to keep up with changes made by _send_command().
        Looks for attribute changes that affect other attributes and
        updates them too.

        :param attribute: cache attribute to update
        :param value: new attribute value
        """
        if self._monitoring and self._all_cache:
            # update cache attribute with new value
            self._all_cache[attribute] = value
            # check for attribute changes that affect other attributes
            # this list is not exhaustive and there may be other attributes
            # with the propensity to affect it's neighbors
            if attribute == "FAN;PWR":
                # changes to fan power also affects fan speed and whoosh
                if value == "OFF":
                    self._all_cache["FAN;SPD;ACTUAL"] = "0"
                    self._all_cache["FAN;WHOOSH;STATUS"] = "OFF"
            elif attribute == "FAN;SPD;ACTUAL":
                # changes to fan speed also affects fan power and whoosh status
                if int(value) == 0:
                    self._all_cache["FAN;PWR"] = "OFF"
                    self._all_cache["FAN;WHOOSH;STATUS"] = "OFF"
                else:
                    self._all_cache["FAN;PWR"] = "ON"
            elif attribute == "LIGHT;PWR":
                # changes to light power also affects light brightness
                if value == "OFF":
                    self._all_cache["LIGHT;LEVEL;ACTUAL"] = "0"
            elif attribute == "LIGHT;LEVEL;ACTUAL":
                # changes to light brightness also changes light power
                if int(value) > 0:
                    self._all_cache["LIGHT;PWR"] = "ON"
                else:
                    self._all_cache["LIGHT;PWR"] = "OFF"

    @MWT(timeout=45)
    def _get_all_request(self):
        """Get all parameters from device, returns as a list."""
        results = self.send_raw("<%s;GETALL>" % self.name)
        # sometimes this gets two sections in one string:
        # join list to str, clean up (), and split back to a list
        results = "||".join(results).replace(")(", ")||(")
        return results.replace("(", "").replace(")", "").split("||")

    def _get_all(self):
        """Get all parameters from the fan <%s;GETALL>.

        This, due to Haiku's API not returning all parameters, but it gets most
        of them.

        Method is marked internal, but could be useful for troubleshooting.
        Suggested way to get to this data is to use the get_attribute method.
        Requesting the desired parameter.

        This data is cached for 30 seconds to avoid the ten seconds it takes to
        run and to reduce requests sent to the fan.

        :return: List of [almost] all fan data.
        """
        # if monitor running, send cache, if not do request
        if self._monitoring and self._all_cache:
            return self._all_cache
        else:
            return self._get_all_bare()

    def _get_all_bare(self):
        res_dict = {}
        results = self._get_all_request()
        for result in results:
            # remove device name i.e Living Room Fan
            _, result = result.split(";", 1)

            # handle these manually due to multiple values in result
            # FAN and LIGHT both have BOOKENDS attributes
            if "BOOKENDS" in result:
                device, low, high = result.rsplit(";", 2)
                res_dict[device] = (low, high)
            elif "NW;PARAMS;ACTUAL" in result:
                # ip, subnet, gateway
                res_dict["NW;PARAMS;ACTUAL"] = (result.rsplit(";", 3))[1:]
            else:
                category, value = result.rsplit(";", 1)
                res_dict[category] = value
        self._all_cache = res_dict
        return res_dict

    def get_attribute(self, attribute):
        """Given a string in the format NW;PARAMS;ACTUAL return parameter value.

        There is a 30 second cache on the GETALL that this pulls from to speed
        things up and to avoid hammering the fan with requests.

        Raises KeyError if key doesn't exist

        See KNOWN_ATTRIBUTES for full list of known attributes

        Anything handled specifically in a property is better retrieved that
        way as it returns within a second, as where this will usually take ten
        seconds if not cached.

        Example:
          get_attribute('NW;PARAMS;ACTUAL')
          ['192.168.1.50', '255.255.255.0', '192.168.1.1']
        :param attribute: The attribute you seek
        :return: The value you find
        """
        if attribute == "SNSROCC;STATUS":  # doesn't get retrieved in get_all
            return self._query("<%s;SNSROCC;STATUS;GET>" % self.name)
        else:
            response_dict = self._get_all()
        return response_dict[attribute]

    def _get_all_nested(self):
        def nest(existing, keys, value):
            key, *keys = keys
            if keys:
                if key not in existing:
                    existing[key] = {}
                nest(existing[key], keys, value)
            else:
                existing[key] = value

        results = self._get_all_request()
        # fix double results
        extra_rows = []
        for result in results:
            if ")(" in result:
                halves = result.split(")(")
                # result = result.replace(halves[1], ')')
                extra_rows.append(halves[1])
        results.extend(extra_rows)
        cleaned = [x.replace("(", "").replace(")", "") for x in results]

        for idx, result in enumerate(cleaned):
            if "BOOKENDS" in result:
                device, low, high = result.rsplit(";", 2)
                cleaned[idx] = "{};{},{}".format(device, low, high)
            elif "NW;PARAMS;ACTUAL" in result:
                nw_params_actual, ip, sn, gw = result.rsplit(";", 3)
                cleaned[idx] = "{};{},{},{}".format(nw_params_actual, ip, sn, gw)

        data = [x.split(";")[1:] for x in cleaned]
        d = {}
        for *keys, value in data:
            nest(d, keys, value)
        return d

    @property
    def json(self):
        """Export all fan details to json."""
        return json.dumps(self._get_all_nested())

    @property
    def xml(self):
        """Export all fan details to xml."""
        return data_to_xml(self._get_all_nested()).decode()

    @property
    def dict(self):
        """Export all fan details as dict."""
        return self._get_all_nested()

    @property
    def flat_dict(self):
        """Export all fan details as a flat dict."""
        return self._get_all()

    @staticmethod
    def _parse_values(line):
        if len(line.rsplit(";", 1)) > 1:
            k, v = line.rsplit(";", 1)
            return k, v

    def start_monitor(self):
        """Start the monitor.

        Starts a monitor that gets all attributes from the fan every
        monitor_frequency seconds.

        Using this makes all queries, after first monitor iteration, instant.
        """
        if not self._monitoring:
            self._monitoring = True
            self._background_monitor.start()

    def stop_monitor(self):
        """Stop the monitor."""
        self._monitoring = False
        self._background_monitor.stop()


def discover(devices_to_find=6, time_to_wait=5):
    """Discover SenseMe devices.

    :return: List of discovered SenseMe devices.
    """
    port = 31415

    data = "<ALL;DEVICE;ID;GET>".encode("utf-8")
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.bind(("", port))
    s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    LOGGER.debug("Sending broadcast.")
    s.sendto(data, ("<broadcast>", port))
    LOGGER.debug("Listening...")
    devices = []
    start_time = time.time()
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.bind(("", port))
        s.settimeout(2)
        while True:
            try:
                message = s.recvfrom(1024)
            except OSError:
                # timeout occurred
                message = b""
            if message:
                LOGGER.info("Received a message")
                message_decoded = message[0].decode("utf-8")
                res = re.match("\((.*);DEVICE;ID;(.*);(.*),(.*)\)", message_decoded)
                # TODO: Parse this properly rather than regex
                name = res.group(1)
                mac = res.group(2)
                model = res.group(3)
                series = res.group(4)
                ip = message[1][0]
                devices.append(
                    SenseMe(ip=ip, name=name, model=model, series=series, mac=mac)
                )

            time.sleep(0.5)
            if (
                start_time + time_to_wait < time.time()
                or len(devices) >= devices_to_find
            ):
                LOGGER.debug("time_to_wait exceeded or devices_to_find met")
                break
        return devices
    except OSError:
        # couldn't get port
        raise OSError("Couldn't get port 31415")
    finally:
        s.close()

    @staticmethod
    def listen(cycles=30):
        """Listen for broadcasts and logs them for debugging purposes.

        Listens for cycles iterations
        """
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.bind(("", 31415))
        for x in range(1, cycles):
            m = sock.recvfrom(1024)
            LOGGER.info(m)

    def discover_single_device(self):
        """Discover a single device.

        Called during __init__ if the device name or IP address is missing.

        This function will discover only the first device to respond if both
        name and IP were not provided on instantiation. If there is only one
        device in the home this will work well. Otherwise, use the discover
        function of the module rather than this one.
        """
        data = "<ALL;DEVICE;ID;GET>".encode("utf-8")
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.bind(("", 0))
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        LOGGER.debug("Sending broadcast.")
        s.sendto(data, ("<broadcast>", self.PORT))

        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        LOGGER.debug("Listening...")
        try:
            s.bind(("", self.PORT))
        except OSError as e:
            # Address already in use
            LOGGER.exception(
                "Port is in use or could not be opened." "Is another instance running?"
            )
            raise OSError
        else:
            try:
                m = s.recvfrom(1024)
                LOGGER.info(m)
                if not m:
                    LOGGER.error("Didn't receive response.")
                else:
                    self.details = m[0].decode("utf-8")
                    res = re.match("\((.*);DEVICE;ID;(.*);(.*),(.*)\)", self.details)
                    # TODO: Parse this properly rather than regex
                    self.name = res.group(1)
                    self.mac = res.group(2)
                    self.model = res.group(3)
                    self.series = res.group(4)
                    self.ip = m[1][0]

                    LOGGER.info(self.name, self.mac, self.model, self.series)
            except OSError as e:
                LOGGER.critical("No device was found.\n%s" % e)
                raise OSError