Skip to content

Commit

Permalink
Merge branch 'ws'
Browse files Browse the repository at this point in the history
  • Loading branch information
vladsternbach committed Oct 24, 2023
2 parents ddca8b6 + 6618732 commit 330832d
Show file tree
Hide file tree
Showing 7 changed files with 105 additions and 124 deletions.
28 changes: 10 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
# Python library for Yeelight BLE lamps

This library allows controlling Yeelight bluetooth-enabled [Bedside Lamp](http://www.yeelight.com/en_US/product/yeelight-ctd) and [Candela](https://www.yeelight.com/en_US/product/gingko) devices.

Note: this library is a fork and contains modifications of the original library to allow running it as systemd service using redis pubsub
Note: this library is a fork and contains modifications of the original library to allow running it as systemd service daemon that communicates over websockets

It is intended to run on RPI and was tested with Yeelight Candela lights only.

Expand All @@ -13,7 +12,8 @@ Currently supported features (Candelas support only On/Off and Brightness):
* Temperature
* Brightness
* Sleep, wakeup & scheduling (partially)
# Installation

## Installation
```
sudo pip3 install git+https://github.com/vsternbach/yeelightble
```
Expand All @@ -27,13 +27,8 @@ if you get: `Failed to set capabilities on file 'bluepy-helper' (No such file or
sudo setcap 'cap_net_raw,cap_net_admin+eip' /usr/local/lib/python3.7/dist-packages/bluepy/bluepy-helper
```
And then simply try if the scanning works. You can use pass '-dd' as option to the command to see the debug messages from bluepy in case it is not working.
# Service daemon
In case you want to run `yeelightble` as a service daemon, you first need to have redis server installed and running
```
sudo apt install redis-server
sudo systemctl start redis.service
sudo systemctl status redis.service
```

## Service daemon
Running this script will install, enable and run `yeelightble` as a systemd service:
```
curl -sSL https://github.com/vsternbach/yeelightble/raw/master/install-service.sh | sudo sh
Expand All @@ -42,7 +37,7 @@ To see service logs, run:
```
journalctl -u yeelightble -f
```
# CLI
## CLI
Try
```
$ yeelightble --help
Expand All @@ -52,23 +47,20 @@ and
$ yeelightble [command] --help
```
For debugging, you can pass -d/--debug, adding it second time will also print out the debug from bluepy.
## Scan for devices
### Scan for devices
```
$ yeelightble scan
f8:24:41:xx:xx:xx yeelight_ms
f8:24:41:xx:xx:xx XMCTD_XXXX
```

## Reading status & states

### Reading status & states
To avoid passing ```--mac``` for every call, set the following environment variable:

```
export YEELIGHTBT_MAC=AA:BB:CC:11:22:33
export YEELIGHTBLE_MAC=AA:BB:CC:11:22:33
```

```
$ yeelightble
$ yeelightble state
MAC: f8:24:41:xx:xx:xx
Mode: LampMode.White
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
packages=['yeelightble'],

python_requires='>=3.4',
install_requires=['bluepy', 'construct==2.9.52', 'click', 'redis', 'retry'],
install_requires=['bluepy', 'construct==2.9.52', 'click', 'websockets', 'retry'],
entry_points={
'console_scripts': [
'yeelightble=yeelightble.cli:cli',
Expand Down
38 changes: 9 additions & 29 deletions yeelightble/cli.py
Original file line number Diff line number Diff line change
@@ -1,33 +1,17 @@
import asyncio
import logging
import click
import sys
import atexit
import redis
import time
from .proxy import ProxyService
from .message import MessageService, Command

from .btle import BTLEScanner
from .lamp import Lamp
from .server import Server
from .version import __version__

pass_dev = click.make_pass_decorator(Lamp)
logger = logging.getLogger(__name__)


def message_handler(proxy_service: ProxyService, message):
uuid, command = message.get('uuid'), message.get('command', None)
if uuid and command:
command, payload = command.get('type'), command.get('payload', None)
logger.info('message_handler: received message from %s: command=%s and payload=%s' % (uuid, command, payload))
proxy_service.cmd(uuid, Command(command, payload))
else:
logger.warning("message_handler: received invalid message:", message)


def status_cb(data):
click.echo("Got notification: %s" % data)


@click.group(invoke_without_command=True)
@click.option('--mac', envvar="YEELIGHTBLE_MAC", required=False)
@click.option('-d', '--debug', default=False, count=True)
Expand All @@ -36,7 +20,6 @@ def cli(ctx, mac, debug):
""" A tool to interact with Yeelight Candela/Bedside Lamp. Will run as a daemon if no arguments were passed."""
level = logging.DEBUG if debug else logging.INFO
logging.basicConfig(format='%(asctime)s %(levelname)s [%(name)s] %(message)s', level=level)

# if we are scanning, we do not need to connect.
if ctx.invoked_subcommand in ("scan", "daemon"):
return
Expand All @@ -49,20 +32,17 @@ def cli(ctx, mac, debug):
logger.error("mac address is missing, set YEELIGHTBLE_MAC environment variable or pass --mac option")
sys.exit(1)

ctx.obj = Lamp(mac, status_cb)
ctx.obj = Lamp(mac)


@cli.command()
@click.option('--redis-host', envvar="YEELIGHTBLE_REDIS_HOST", default='localhost', show_default=True)
@click.option('--redis-port', envvar="YEELIGHTBLE_REDIS_PORT", default=6379, show_default=True)
def daemon(redis_host, redis_port):
@click.option('--host', envvar="YEELIGHTBLE_HOST", default="0.0.0.0", show_default=True)
@click.option('--port', envvar="YEELIGHTBLE_PORT", default=8765, show_default=True)
def daemon(host, port):
"""Runs yeelightble as a daemon"""
logger.info(f'Starting yeelightble service daemon v{__version__}')
redis_client = redis.Redis(host=redis_host, port=redis_port, decode_responses=True)
message_service = MessageService(redis_client)
proxy_service = ProxyService(message_service)
message_service.subscribe_control(lambda message: message_handler(proxy_service, message))
atexit.register(redis_client.close())
server = Server(host=host, port=port)
asyncio.run(server.start())


@cli.command()
Expand Down
6 changes: 5 additions & 1 deletion yeelightble/lamp.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ def wrapped(self, *args, **kwargs):
return wrapped


def state_cb(data):
logger.info("Got notification: %s" % data)


def pair_cb(data):
data = data.payload
if data.pairing_status == "PairRequest":
Expand All @@ -48,7 +52,7 @@ class Lamp:
NOTIFY_UUID = "8f65073d-9f57-4aaa-afea-397d19d5bbeb"
CONTROL_UUID = "aa7d3f34-2d4f-41e0-807f-52fbf8cf7443"

def __init__(self, mac, status_cb=None, paired_cb=pair_cb, keep_connection=True):
def __init__(self, mac, status_cb=state_cb, paired_cb=pair_cb, keep_connection=True):
self._mac = mac
self._paired_cb = paired_cb
self._status_cb = status_cb
Expand Down
42 changes: 0 additions & 42 deletions yeelightble/message.py

This file was deleted.

33 changes: 0 additions & 33 deletions yeelightble/proxy.py

This file was deleted.

80 changes: 80 additions & 0 deletions yeelightble/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import asyncio
import json
import logging
import signal
import websockets

from .lamp import Lamp

logger = logging.getLogger(__name__)


class Command:
SetColor = 'color'
SetBrightness = 'brightness'
SetOn = 'on'
GetState = 'state'


class Server:
def __init__(self, host: str, port: int):
self.host = host
self.port = port
self.ws = None
self._lamps = {}

async def start(self):
signal.signal(signal.SIGINT, self.stop)
signal.signal(signal.SIGTERM, self.stop)
async with websockets.serve(self.handle_message, self.host, self.port):
await asyncio.Future()

def stop(self, signum, frame):
logger.info(f"Received {signum} signal. Cleaning up and exiting gracefully.")
self.ws = None
asyncio.get_event_loop().stop()

async def handle_message(self, websocket):
self.ws = websocket
try:
async for message in websocket:
try:
message_data = json.loads(message)
logger.debug(f"Received ws message: {message_data}")
uuid, command = message_data.get('uuid'), message_data.get('command', None)
if uuid and command:
command, payload = command.get('type'), command.get('payload', None)
self.process_command(uuid, command, payload)
else:
logger.warning("Received invalid message:", message)
except Exception as e:
logger.error(f"Error processing message: {e}")
except websockets.exceptions.ConnectionClosed:
self.stop(signal.SIGABRT, None)

def process_command(self, uuid, command: Command, payload=None):
logger.debug(f"Process command {command} with payload {payload} for {uuid}")
uuid = uuid.lower()
if uuid not in self._lamps:
self._lamps[uuid] = Lamp(uuid, lambda data: self.send_state(uuid, data))
lamp = self._lamps[uuid]
if command == Command.SetColor:
lamp.set_color(payload)
elif command == Command.SetBrightness:
lamp.set_brightness(payload)
elif command == Command.SetOn:
lamp.set_on_off(payload)
elif command == Command.GetState:
lamp.get_state()
else:
logger.warning(f"Unsupported command: {command}")
return

async def send_state(self, uuid, lamp: Lamp):
logger.debug("Received notification from %s" % uuid)
if self.ws:
logger.debug("Send ws message with state: %s" % (uuid, lamp.state))
await self.ws.send(json.dumps({"uuid": uuid, "state": lamp.state}))
else:
logger.warning("No open websocket")

0 comments on commit 330832d

Please sign in to comment.