main.py 24.5 KB
Newer Older
1
import colorsys
Stavros Korokithakis's avatar
Stavros Korokithakis committed
2
import json
3
import logging
4
import os
5
import socket
6
import struct
7

8
from future.utils import raise_from
9

10
from .decorator import decorator
11
from .enums import BulbType, ControlType, PowerMode
12 13 14
from .flow import Flow
from .utils import _clamp

15 16 17 18 19 20
if os.name == "nt":
    import win32api as fcntl
else:
    import fcntl


21 22 23 24
try:
    from urllib.parse import urlparse
except ImportError:
    from urlparse import urlparse
Stavros Korokithakis's avatar
Stavros Korokithakis committed
25

26
_LOGGER = logging.getLogger(__name__)
27

Stavros Korokithakis's avatar
Stavros Korokithakis committed
28
_MODEL_SPECS = {
Stavros Korokithakis's avatar
Stavros Korokithakis committed
29 30 31 32 33 34 35 36 37 38 39
    "mono": {"color_temp": {"min": 2700, "max": 2700}},
    "mono1": {"color_temp": {"min": 2700, "max": 2700}},
    "color": {"color_temp": {"min": 1700, "max": 6500}},
    "color1": {"color_temp": {"min": 1700, "max": 6500}},
    "strip1": {"color_temp": {"min": 1700, "max": 6500}},
    "bslamp1": {"color_temp": {"min": 1700, "max": 6500}},
    "ceiling1": {"color_temp": {"min": 2700, "max": 6500}},
    "ceiling2": {"color_temp": {"min": 2700, "max": 6500}},
    "ceiling3": {"color_temp": {"min": 2700, "max": 6000}},
    "ceiling4": {"color_temp": {"min": 2700, "max": 6500}},
    "color2": {"color_temp": {"min": 2700, "max": 6500}},
40
}
41

Stavros Korokithakis's avatar
Stavros Korokithakis committed
42

Stavros Korokithakis's avatar
Stavros Korokithakis committed
43 44
@decorator
def _command(f, *args, **kw):
45
    """A decorator that wraps a function and enables effects."""
Stavros Korokithakis's avatar
Stavros Korokithakis committed
46 47 48
    self = args[0]
    effect = kw.get("effect", self.effect)
    duration = kw.get("duration", self.duration)
Jakub's avatar
Jakub committed
49
    power_mode = kw.get("power_mode", self.power_mode)
50
    control_type = kw.get("type", ControlType.Main)
Stavros Korokithakis's avatar
Stavros Korokithakis committed
51

52
    method, params, kwargs = f(*args, **kw)
53
    
54
    # Prepend the control for different bulbs
55
    if control_type == ControlType.Ambient:
56
        method = "bg_" + method
57

Alexis Paques's avatar
Alexis Paques committed
58 59 60 61 62 63 64 65 66 67 68 69 70 71
    if method in [
        "set_ct_abx",
        "set_rgb",
        "set_hsv",
        "set_bright",
        "set_power",
        "toggle",
        "bg_set_ct_abx",
        "bg_set_rgb",
        "bg_set_hsv",
        "bg_set_bright",
        "bg_set_power",
        "bg_toggle",
    ]:
72
        if self._music_mode:
73 74 75 76
            # Mapping calls to their properties.
            # Used to keep music mode cache up to date.
            action_property_map = {
                "set_ct_abx": ["ct"],
77
                "bg_set_ct_abx": ["bg_ct"],
78
                "set_rgb": ["rgb"],
79
                "bg_set_rgb": ["bg_rgb"],
80
                "set_hsv": ["hue", "sat"],
81
                "bg_set_hsv": ["bg_hue", "bg_sat"],
82
                "set_bright": ["bright"],
83
                "bg_set_bright": ["bg_bright"],
Stavros Korokithakis's avatar
Stavros Korokithakis committed
84
                "set_power": ["power"],
85
                "bg_set_power": ["bg_power"],
86
            }
87
            # Handle toggling separately, as it depends on a previous power state.
88 89
            if method == "toggle":
                self._last_properties["power"] = "on" if self._last_properties["power"] == "off" else "off"
90
            if method == "bg_toggle":
91
                self._last_properties["bg_power"] = "on" if self._last_properties["bg_power"] == "off" else "off"
92 93 94 95 96 97 98 99
            # dev_toggle toggle both lights depending on the MAIN light power status.
            if method == "dev_toggle":
                if self._last_properties["power"] == "off":
                    self._last_properties["power"] = "on"
                    self._last_properties["bg_power"] = "on"
                else:
                    self._last_properties["power"] == "off"
                    self._last_properties["bg_power"] = "off"
100
            elif method in action_property_map:
101
                set_prop = action_property_map[method]
Teemu R.'s avatar
Teemu R. committed
102
                update_props = {set_prop[prop]: params[prop] for prop in range(len(set_prop))}
103 104
                _LOGGER.debug("Music mode cache update: %s", update_props)
                self._last_properties.update(update_props)
Stavros Korokithakis's avatar
Stavros Korokithakis committed
105 106
        # Add the effect parameters.
        params += [effect, duration]
Jakub's avatar
Jakub committed
107
        # Add power_mode parameter.
108
        if method == "set_power" and params[0] == "on" and power_mode.value != PowerMode.LAST:
Jakub's avatar
Jakub committed
109
            params += [power_mode.value]
110 111 112
        # TODO: Check the logic on set_power
        if method == "bg_set_power" and params[0] == "on" and power_mode.value != PowerMode.LAST:
            params += [power_mode.value]
Stavros Korokithakis's avatar
Stavros Korokithakis committed
113

Stavros Korokithakis's avatar
Stavros Korokithakis committed
114 115 116
    result = self.send_command(method, params).get("result", [])
    if result:
        return result[0]
Stavros Korokithakis's avatar
Stavros Korokithakis committed
117

118

119 120 121 122 123 124 125
def get_ip_address(ifname):
    """
    Returns the IPv4 address of the requested interface (thanks Martin Konecny, https://stackoverflow.com/a/24196955)

    :param string interface: The interface to get the IPv4 address of.

    :returns: The interface's IPv4 address.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
126

127 128
    """
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
Stavros Korokithakis's avatar
Stavros Korokithakis committed
129 130 131
    return socket.inet_ntoa(
        fcntl.ioctl(s.fileno(), 0x8915, struct.pack("256s", bytes(ifname[:15], "utf-8")))[20:24]
    )  # SIOCGIFADDR
132

133

134
def discover_bulbs(timeout=2, interface=False):
135 136 137 138 139 140 141
    """
    Discover all the bulbs in the local network.

    :param int timeout: How many seconds to wait for replies. Discovery will
                        always take exactly this long to run, as it can't know
                        when all the bulbs have finished responding.

142 143 144 145 146
    :param string interface: The interface that should be used for multicast packets.
                             Note: it *has* to have a valid IPv4 address. IPv6-only
                             interfaces are not supported (at the moment).
                             The default one will be used if this is not specified.

147 148 149
    :returns: A list of dictionaries, containing the ip, port and capabilities
              of each of the bulbs in the network.
    """
150
    msg = "\r\n".join(["M-SEARCH * HTTP/1.1", "HOST: 239.255.255.250:1982", 'MAN: "ssdp:discover"', "ST: wifi_bulb"])
151 152 153

    # Set up UDP socket
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
154
    s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 32)
155 156
    if interface:
        s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(get_ip_address(interface)))
157
    s.settimeout(timeout)
Stavros Korokithakis's avatar
Stavros Korokithakis committed
158
    s.sendto(msg.encode(), ("239.255.255.250", 1982))
159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181

    bulbs = []
    bulb_ips = set()
    while True:
        try:
            data, addr = s.recvfrom(65507)
        except socket.timeout:
            break

        capabilities = dict([x.strip("\r").split(": ") for x in data.decode().split("\n") if ":" in x])
        parsed_url = urlparse(capabilities["Location"])

        bulb_ip = (parsed_url.hostname, parsed_url.port)
        if bulb_ip in bulb_ips:
            continue

        capabilities = {key: value for key, value in capabilities.items() if key.islower()}
        bulbs.append({"ip": bulb_ip[0], "port": bulb_ip[1], "capabilities": capabilities})
        bulb_ips.add(bulb_ip)

    return bulbs


182 183
class BulbException(Exception):
    """
184 185
    A generic yeelight exception.

186 187
    This exception is raised when bulb informs about errors, e.g., when trying
    to issue unsupported commands to the bulb.
188
    """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
189

190 191 192
    pass


Stavros Korokithakis's avatar
Stavros Korokithakis committed
193
class Bulb(object):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
194 195 196
    def __init__(
        self, ip, port=55443, effect="smooth", duration=300, auto_on=False, power_mode=PowerMode.LAST, model=None
    ):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
197 198 199 200 201 202 203 204
        """
        The main controller class of a physical YeeLight bulb.

        :param str ip:       The IP of the bulb.
        :param int port:     The port to connect to on the bulb.
        :param str effect:   The type of effect. Can be "smooth" or "sudden".
        :param int duration: The duration of the effect, in milliseconds. The
                             minimum is 30. This is ignored for sudden effects.
205 206 207 208 209 210 211 212 213 214
        :param bool auto_on: Whether to call :py:meth:`ensure_on()
                             <yeelight.Bulb.ensure_on>` to turn the bulb on
                             automatically before each operation, if it is off.
                             This renews the properties of the bulb before each
                             message, costing you one extra message per command.
                             Turn this off and do your own checking with
                             :py:meth:`get_properties()
                             <yeelight.Bulb.get_properties()>` or run
                             :py:meth:`ensure_on() <yeelight.Bulb.ensure_on>`
                             yourself if you're worried about rate-limiting.
Jakub's avatar
Jakub committed
215 216
        :param yeelight.enums.PowerMode power_mode:
                             The mode for the light set when powering on.
217 218 219 220
        :param str model:    The model name of the yeelight (e.g. "color",
                             "mono", etc). The setting is used to enable model
                             specific features (e.g. a particular color
                             temperature range).
Jakub's avatar
Jakub committed
221

Stavros Korokithakis's avatar
Stavros Korokithakis committed
222
        """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
223 224 225 226 227 228
        self._ip = ip
        self._port = port

        self.effect = effect
        self.duration = duration
        self.auto_on = auto_on
Jakub's avatar
Jakub committed
229
        self.power_mode = power_mode
230
        self.model = model
Stavros Korokithakis's avatar
Stavros Korokithakis committed
231

232
        self.__cmd_id = 0  # The last command id we used.
233
        self._last_properties = {}  # The last set of properties we've seen.
234 235
        self._music_mode = False  # Whether we're currently in music mode.
        self.__socket = None  # The socket we use to communicate.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
236 237 238

    @property
    def _cmd_id(self):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
239 240 241 242 243
        """
        Return the next command ID and increment the counter.

        :rtype: int
        """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
244 245 246 247 248
        self.__cmd_id += 1
        return self.__cmd_id - 1

    @property
    def _socket(self):
249
        """Return, optionally creating, the communication socket."""
Stavros Korokithakis's avatar
Stavros Korokithakis committed
250 251
        if self.__socket is None:
            self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
252
            self.__socket.settimeout(5)
Stavros Korokithakis's avatar
Stavros Korokithakis committed
253 254 255
            self.__socket.connect((self._ip, self._port))
        return self.__socket

256 257 258
    def ensure_on(self):
        """Turn the bulb on if it is off."""
        if self._music_mode is True or self.auto_on is False:
259 260
            return

261
        self.get_properties()
Stavros Korokithakis's avatar
Stavros Korokithakis committed
262 263

        if self._last_properties["power"] != "on":
264 265 266 267 268 269 270 271 272 273 274 275
            self.turn_on()

    @property
    def last_properties(self):
        """
        The last properties we've seen the bulb have.

        This might potentially be out of date, as there's no background listener
        for the bulb's notifications. To update it, call
        :py:meth:`get_properties <yeelight.Bulb.get_properties()>`.
        """
        return self._last_properties
Stavros Korokithakis's avatar
Stavros Korokithakis committed
276

277 278 279
    @property
    def bulb_type(self):
        """
280 281
        The type of bulb we're communicating with.

282
        Returns a :py:class:`BulbType <yeelight.enums.BulbType>` describing the bulb
283
        type.
284 285 286

        When trying to access before properties are known, the bulb type is unknown.

287
        :rtype: yeelight.enums.BulbType
288 289
        :return: The bulb's type.
        """
290
        if not self._last_properties or any(name not in self.last_properties for name in ["ct", "rgb"]):
291
            return BulbType.Unknown
Stavros Korokithakis's avatar
Stavros Korokithakis committed
292
        if self.last_properties["rgb"] is None and self.last_properties["ct"]:
293 294 295 296
            if self.last_properties["bg_power"] is not None:
                return BulbType.WhiteTempMood
            else:
                return BulbType.WhiteTemp
Stavros Korokithakis's avatar
Stavros Korokithakis committed
297 298 299
        if all(
            name in self.last_properties and self.last_properties[name] is None for name in ["ct", "rgb", "hue", "sat"]
        ):
300 301 302 303
            return BulbType.White
        else:
            return BulbType.Color

304 305 306
    @property
    def music_mode(self):
        """
307
        Return whether the music mode is active.
308 309 310 311 312 313

        :rtype: bool
        :return: True if music mode is on, False otherwise.
        """
        return self._music_mode

314
    def get_properties(
Stavros Korokithakis's avatar
Stavros Korokithakis committed
315 316 317 318 319 320 321 322 323 324 325 326
        self,
        requested_properties=[
            "power",
            "bright",
            "ct",
            "rgb",
            "hue",
            "sat",
            "color_mode",
            "flowing",
            "delayoff",
            "music_on",
327
            "name",
328
            "bg_power",
329 330 331 332 333
            "bg_flowing",
            "bg_ct",
            "bg_bright",
            "bg_hue",
            "bg_sat",
334
            "bg_rgb",
335 336
            "nl_br",
            "active_mode",
Stavros Korokithakis's avatar
Stavros Korokithakis committed
337
        ],
338
    ):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
339
        """
340 341 342
        Retrieve and return the properties of the bulb.

        This method also updates ``last_properties`` when it is called.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
343

344 345 346 347
        The ``current_brightness`` property is calculated by the library (i.e. not returned
        by the bulb), and indicates the current brightness of the lamp, aware of night light
        mode. It is 0 if the lamp is off, and None if it is unknown.

348
        :param list requested_properties: The list of properties to request from the bulb.
349
                                          By default, this does not include ``flow_params``.
350

Stavros Korokithakis's avatar
Stavros Korokithakis committed
351 352
        :returns: A dictionary of param: value items.
        :rtype: dict
Stavros Korokithakis's avatar
Stavros Korokithakis committed
353
        """
354 355 356
        # When we are in music mode, the bulb does not respond to queries
        # therefore we need to keep the state up-to-date ourselves
        if self._music_mode:
357 358
            return self._last_properties

Stavros Korokithakis's avatar
Stavros Korokithakis committed
359 360
        response = self.send_command("get_prop", requested_properties)
        properties = response["result"]
361 362
        properties = [x if x else None for x in properties]

Stavros Korokithakis's avatar
Stavros Korokithakis committed
363
        self._last_properties = dict(zip(requested_properties, properties))
364 365 366

        if self._last_properties.get("power") == "off":
            cb = "0"
367 368
        if self._last_properties.get("bg_power") == "off":
            cb = "0"
369 370 371 372 373 374 375
        elif self._last_properties.get("active_mode") == "1":
            # Nightlight mode.
            cb = self._last_properties.get("nl_br")
        else:
            cb = self._last_properties.get("bright")
        self._last_properties["current_brightness"] = cb

Stavros Korokithakis's avatar
Stavros Korokithakis committed
376 377 378 379 380
        return self._last_properties

    def send_command(self, method, params=None):
        """
        Send a command to the bulb.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
381 382 383 384

        :param str method:  The name of the method to send.
        :param list params: The list of parameters for the method.

385
        :raises BulbException: When the bulb indicates an error condition.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
386
        :returns: The response from the bulb.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
387
        """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
388
        command = {"id": self._cmd_id, "method": method, "params": params}
Stavros Korokithakis's avatar
Stavros Korokithakis committed
389

390 391
        _LOGGER.debug("%s > %s", self, command)

392 393
        try:
            self._socket.send((json.dumps(command) + "\r\n").encode("utf8"))
394
        except socket.error as ex:
395 396 397 398
            # Some error occurred, remove this socket in hopes that we can later
            # create a new one.
            self.__socket.close()
            self.__socket = None
Stavros Korokithakis's avatar
Stavros Korokithakis committed
399
            raise_from(BulbException("A socket error occurred when sending the command."), ex)
400 401 402 403

        if self._music_mode:
            # We're in music mode, nothing else will happen.
            return {"result": ["ok"]}
Stavros Korokithakis's avatar
Stavros Korokithakis committed
404 405 406 407 408

        # The bulb will send us updates on its state in addition to responses,
        # so we want to make sure that we read until we see an actual response.
        response = None
        while response is None:
409 410 411
            try:
                data = self._socket.recv(16 * 1024)
            except socket.error:
412
                # An error occured, let's close and abort...
413 414
                self.__socket.close()
                self.__socket = None
415 416 417
                response = {"error": "Bulb closed the connection."}
                break

418 419 420 421 422 423
            for line in data.split(b"\r\n"):
                if not line:
                    continue

                try:
                    line = json.loads(line.decode("utf8"))
424
                    _LOGGER.debug("%s < %s", self, line)
425 426 427
                except ValueError:
                    line = {"result": ["invalid command"]}

Stavros Korokithakis's avatar
Stavros Korokithakis committed
428 429
                if line.get("method") != "props":
                    # This is probably the response we want.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
430
                    response = line
431 432
                else:
                    self._last_properties.update(line["params"])
Stavros Korokithakis's avatar
Stavros Korokithakis committed
433

434 435 436
        if "error" in response:
            raise BulbException(response["error"])

Stavros Korokithakis's avatar
Stavros Korokithakis committed
437 438
        return response

Stavros Korokithakis's avatar
Stavros Korokithakis committed
439
    @_command
440
    def set_color_temp(self, degrees, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
441 442
        """
        Set the bulb's color temperature.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
443

444 445
        :param int degrees: The degrees to set the color temperature to
                            (1700-6500).
Stavros Korokithakis's avatar
Stavros Korokithakis committed
446
        """
447
        self.ensure_on()
Stavros Korokithakis's avatar
Stavros Korokithakis committed
448

449
        degrees = _clamp(degrees, 1700, 6500)
450
        return "set_ct_abx", [degrees], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
451

Stavros Korokithakis's avatar
Stavros Korokithakis committed
452
    @_command
453
    def set_rgb(self, red, green, blue, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
454 455
        """
        Set the bulb's RGB value.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
456 457 458 459

        :param int red: The red value to set (0-255).
        :param int green: The green value to set (0-255).
        :param int blue: The blue value to set (0-255).
Stavros Korokithakis's avatar
Stavros Korokithakis committed
460
        """
461
        self.ensure_on()
Stavros Korokithakis's avatar
Stavros Korokithakis committed
462

463 464 465
        red = _clamp(red, 0, 255)
        green = _clamp(green, 0, 255)
        blue = _clamp(blue, 0, 255)
466
        return "set_rgb", [red * 65536 + green * 256 + blue], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
467

468
    @_command
469
    def set_adjust(self, action, prop, **kwargs):
470 471 472 473 474 475 476 477 478 479 480 481 482 483
        """
        Adjust a parameter.

        I don't know what this is good for. I don't know how to use it, or why.
        I'm just including it here for completeness, and because it was easy,
        but it won't get any particular love.

        :param str action: The direction of adjustment. Can be "increase",
                           "decrease" or "circle".
        :param str prop:   The property to adjust. Can be "bright" for
                           brightness, "ct" for color temperature and "color"
                           for color. The only action for "color" can be
                           "circle". Why? Who knows.
        """
484
        return "set_adjust", [action, prop], kwargs
485

Stavros Korokithakis's avatar
Stavros Korokithakis committed
486
    @_command
487
    def set_hsv(self, hue, saturation, value=None, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
488 489
        """
        Set the bulb's HSV value.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
490 491 492

        :param int hue:        The hue to set (0-359).
        :param int saturation: The saturation to set (0-100).
493 494 495
        :param int value:      The value to set (0-100). If omitted, the bulb's
                               brightness will remain the same as before the
                               change.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
496
        """
497
        self.ensure_on()
Stavros Korokithakis's avatar
Stavros Korokithakis committed
498

499
        # We fake this using flow so we can add the `value` parameter.
500 501
        hue = _clamp(hue, 0, 359)
        saturation = _clamp(saturation, 0, 100)
502 503 504

        if value is None:
            # If no value was passed, use ``set_hsv`` to preserve luminance.
505
            return "set_hsv", [hue, saturation], kwargs
506 507
        else:
            # Otherwise, use flow.
508
            value = _clamp(value, 0, 100)
509 510 511 512 513 514

            if kwargs.get("effect", self.effect) == "sudden":
                duration = 50
            else:
                duration = kwargs.get("duration", self.duration)

515 516
            hue = _clamp(hue, 0, 359) / 359.0
            saturation = _clamp(saturation, 0, 100) / 100.0
517 518
            red, green, blue = [int(round(col * 255)) for col in colorsys.hsv_to_rgb(hue, saturation, 1)]
            rgb = red * 65536 + green * 256 + blue
519
            return "start_cf", [1, 1, "%s, 1, %s, %s" % (duration, rgb, value)], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
520

Stavros Korokithakis's avatar
Stavros Korokithakis committed
521
    @_command
522
    def set_brightness(self, brightness, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
523 524
        """
        Set the bulb's brightness.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
525 526

        :param int brightness: The brightness value to set (1-100).
Stavros Korokithakis's avatar
Stavros Korokithakis committed
527
        """
528
        self.ensure_on()
Stavros Korokithakis's avatar
Stavros Korokithakis committed
529

530
        brightness = _clamp(brightness, 1, 100)
531
        return "set_bright", [brightness], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
532

Stavros Korokithakis's avatar
Stavros Korokithakis committed
533
    @_command
534
    def turn_on(self, **kwargs):
535
        """Turn the bulb on."""
536
        return "set_power", ["on"], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
537

Stavros Korokithakis's avatar
Stavros Korokithakis committed
538
    @_command
539
    def turn_off(self, **kwargs):
540
        """Turn the bulb off."""
541
        return "set_power", ["off"], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
542

Stavros Korokithakis's avatar
Stavros Korokithakis committed
543
    @_command
544
    def toggle(self, **kwargs):
545
        """Toggle the bulb on or off."""
546 547 548 549 550 551
        return "toggle", [], kwargs

    @_command
    def dev_toggle(self, **kwargs):
        """Toggle the main light and the ambient on or off."""
        return "dev_toggle", [], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
552

Stavros Korokithakis's avatar
Stavros Korokithakis committed
553
    @_command
554
    def set_default(self, **kwargs):
555 556 557 558 559 560
        """
        Set the bulb's current state as the default, which is what the bulb will be set to on power on.

        If you get a "general error" setting this, yet the bulb reports as supporting `set_default` during
        discovery, disable "auto save settings" in the YeeLight app.
        """
561
        return "set_default", [], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
562 563

    @_command
564
    def set_name(self, name, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
565 566 567 568 569
        """
        Set the bulb's name.

        :param str name: The string you want to set as the bulb's name.
        """
570
        return "set_name", [name], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
571

Stavros Korokithakis's avatar
Stavros Korokithakis committed
572
    @_command
573
    def start_flow(self, flow, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
574 575 576 577 578 579 580 581
        """
        Start a flow.

        :param yeelight.Flow flow: The Flow instance to start.
        """
        if not isinstance(flow, Flow):
            raise ValueError("Argument is not a Flow instance.")

582
        self.ensure_on()
Stavros Korokithakis's avatar
Stavros Korokithakis committed
583

584
        return ("start_cf", [flow.count * len(flow.transitions), flow.action.value, flow.expression], kwargs)
Stavros Korokithakis's avatar
Stavros Korokithakis committed
585 586

    @_command
587
    def stop_flow(self, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
588
        """Stop a flow."""
589
        return "stop_cf", [], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
590

591
    def start_music(self, port=0):
592 593 594 595 596 597 598 599 600 601 602
        """
        Start music mode.

        Music mode essentially upgrades the existing connection to a reverse one
        (the bulb connects to the library), removing all limits and allowing you
        to send commands without being rate-limited.

        Starting music mode will start a new listening socket, tell the bulb to
        connect to that, and then close the old connection. If the bulb cannot
        connect to the host machine for any reason, bad things will happen (such
        as library freezes).
603 604 605

        :param int port: The port to listen on. If none is specified, a random
                         port will be chosen.
606 607 608 609
        """
        if self._music_mode:
            raise AssertionError("Already in music mode, please stop music mode first.")

610 611 612 613
        # Force populating the cache in case we are being called directly
        # without ever fetching properties beforehand
        self.get_properties()

614 615 616
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        # Reuse sockets so we don't hit "address already in use" errors.
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
617
        s.bind(("", port))
618
        host, port = s.getsockname()
619 620 621
        s.listen(3)

        local_ip = self._socket.getsockname()[0]
622
        self.send_command("set_music", [1, local_ip, port])
623 624 625 626 627 628 629 630 631 632
        s.settimeout(5)
        conn, _ = s.accept()
        s.close()  # Close the listening socket.
        self.__socket.close()
        self.__socket = conn
        self._music_mode = True

        return "ok"

    @_command
633
    def stop_music(self, **kwargs):
634 635 636 637 638 639 640 641 642 643
        """
        Stop music mode.

        Stopping music mode will close the previous connection. Calling
        ``stop_music`` more than once, or while not in music mode, is safe.
        """
        if self.__socket:
            self.__socket.close()
            self.__socket = None
        self._music_mode = False
644
        return "set_music", [0], kwargs
645

Stavros Korokithakis's avatar
Stavros Korokithakis committed
646
    @_command
647
    def cron_add(self, event_type, value, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
648 649 650
        """
        Add an event to cron.

Stavros Korokithakis's avatar
Stavros Korokithakis committed
651 652 653 654
        Example::

        >>> bulb.cron_add(CronType.off, 10)

Stavros Korokithakis's avatar
Stavros Korokithakis committed
655 656 657
        :param yeelight.enums.CronType event_type: The type of event. Currently,
                                                   only ``CronType.off``.
        """
658
        return "cron_add", [event_type.value, value], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
659 660

    @_command
661
    def cron_get(self, event_type, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
662 663 664 665 666 667
        """
        Retrieve an event from cron.

        :param yeelight.enums.CronType event_type: The type of event. Currently,
                                                   only ``CronType.off``.
        """
668
        return "cron_get", [event_type.value], kwargs
Stavros Korokithakis's avatar
Stavros Korokithakis committed
669 670

    @_command
671
    def cron_del(self, event_type, **kwargs):
Stavros Korokithakis's avatar
Stavros Korokithakis committed
672
        """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
673
        Remove an event from cron.
Stavros Korokithakis's avatar
Stavros Korokithakis committed
674 675 676 677

        :param yeelight.enums.CronType event_type: The type of event. Currently,
                                                   only ``CronType.off``.
        """
678
        return "cron_del", [event_type.value], kwargs
679 680

    def __repr__(self):
681
        return "Bulb<{ip}:{port}, type={type}>".format(ip=self._ip, port=self._port, type=self.bulb_type)
Jakub's avatar
Jakub committed
682 683 684 685 686 687 688 689 690 691

    def set_power_mode(self, mode):
        """
        Set the light power mode.

        If the light is off it will be turned on.

        :param yeelight.enums.PowerMode mode: The mode to swith to.
        """
        return self.turn_on(power_mode=mode)
692

Stavros Korokithakis's avatar
Stavros Korokithakis committed
693
    def get_model_specs(self, **kwargs):
694
        """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
695
        Return the specifications (e.g. color temperature min/max) of the bulb.
696
        """
Stavros Korokithakis's avatar
Stavros Korokithakis committed
697 698
        if self.model is not None and self.model in _MODEL_SPECS:
            return _MODEL_SPECS[self.model]
699 700 701

        _LOGGER.debug("Model unknown (%s). Providing a fallback", self.model)
        if self.bulb_type is BulbType.White:
Stavros Korokithakis's avatar
Stavros Korokithakis committed
702
            return _MODEL_SPECS["mono"]
703 704

        if self.bulb_type is BulbType.WhiteTemp:
Stavros Korokithakis's avatar
Stavros Korokithakis committed
705
            return _MODEL_SPECS["ceiling1"]
706

707 708 709
        if self.bulb_type is BulbType.WhiteTempMood:
            return _MODEL_SPECS["ceiling4"]

710
        # BulbType.Color and BulbType.Unknown
Stavros Korokithakis's avatar
Stavros Korokithakis committed
711
        return _MODEL_SPECS["color"]