Skip to content

Commit

Permalink
Merge pull request #1 from aderesh/feature/brightness
Browse files Browse the repository at this point in the history
add support to change brightness on supported devices
  • Loading branch information
aderesh authored May 25, 2024
2 parents 9db9ecf + 2f2d16c commit d96270d
Show file tree
Hide file tree
Showing 8 changed files with 200 additions and 38 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,18 @@ Granted the config properly refers to the TP-Link devices in the network, use re
controlling and monitoring. Example below.

Controlling the devices via MQTT via publish:
- turning on/off
```shell script
$ MQTT=192.168.1.250 && \
mosquitto_pub -h $MQTT -t /kitchen/light_switch -m off
```

- setting brightness to 50%
```shell script
$ MQTT=192.168.1.250 && \
mosquitto_pub -h $MQTT -t /kitchen/light_switch/brightness -m 50
```

Subscribe to see changes to devices, regardless on how they were controlled:
```shell script
$ MQTT=192.168.1.250 && \
Expand Down
2 changes: 1 addition & 1 deletion Vagrantfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ $test_mqtt2kasa = <<SCRIPT
cd tplink-smarthome-simulator
npm install
# Add secondary ips to satisfy simulator.js
for x in {201..203}; do
for x in {201..204}; do
sudo ip a add 192.168.123.${x}/32 dev eth1
done
cp -v /vagrant/mqtt2kasa/tests/simulator.js.vagrant ./test/simulator.js
Expand Down
7 changes: 7 additions & 0 deletions data/config.yaml.vagrant
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ locations:
# alias: Mock HS110 thing3
# this device has emeter capabilities
emeter_poll_interval: 888
dimmer:
topic: /dimmer
host: 192.168.123.204
# alias: Mock HS220 thing4
# brightness can be changed on this device
emeter_poll_interval: 888

4 changes: 4 additions & 0 deletions mqtt2kasa/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ def __init__(self, **attrs):
expected_attrs = "name", "state"
super().__init__(expected_attrs, attrs)

class KasaBrightnessEvent(BaseEvent):
def __init__(self, **attrs):
expected_attrs = "name", "brightness"
super().__init__(expected_attrs, attrs)

class KasaEmeterEvent(BaseEvent):
def __init__(self, **attrs):
Expand Down
103 changes: 89 additions & 14 deletions mqtt2kasa/kasa_wrapper.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

from mqtt2kasa import log
from mqtt2kasa.config import Cfg
from mqtt2kasa.events import KasaStateEvent, KasaEmeterEvent
from mqtt2kasa.events import KasaStateEvent, KasaBrightnessEvent, KasaEmeterEvent

logger = log.getLogger()

Expand All @@ -30,6 +30,7 @@ def __init__(self, name: str, topic: str, config: dict):
self.recv_q = asyncio.Queue(maxsize=4)
self.throttler = Throttler(rate_limit=4, period=60)
self.curr_state = None
self.curr_brightness = None
self._device = None
assert self.host or self.alias

Expand Down Expand Up @@ -82,6 +83,35 @@ async def is_on(self) -> Optional[bool]:
logger.error(f"{self.host} unable to fetch is_on: {e}")
# implicit return None

@property
async def is_dimmable(self) -> Optional[bool]:
try:
device = await self._get_device()
await device.update()
return device.is_dimmable
except SmartDeviceException as e:
logger.error(f"{self.host} unable to fetch is_dimmable: {e}")
# implicit return None

@property
async def brightness(self) -> Optional[int]:
try:
device = await self._get_device()
await device.update()
return device.brightness
except SmartDeviceException as e:
logger.error(f"{self.host} unable to fetch brightness: {e}")
# implicit return None

async def set_brightness(self, brightness):
async with self.throttler:
try:
device = await self._get_device()
await device.set_brightness(brightness)
self.curr_brightness = brightness
except SmartDeviceException as e:
logger.error(f"{self.host} unable to set brightness: {e}")

async def turn_on(self):
async with self.throttler:
try:
Expand Down Expand Up @@ -189,6 +219,23 @@ async def handle_kasa_poller(kasa: Kasa, main_events_q: asyncio.Queue):
)
)
kasa.curr_state = new_state

is_dimmable = await kasa.is_dimmable
if is_dimmable:
new_brightness = await kasa.brightness
if kasa.curr_brightness != new_brightness or fails:
if new_brightness is None:
fails += 1
logger.error(f"Polling {kasa.name} ({kasa.host}) failed {fails} times")
else:
fails = 0
await main_events_q.put(
KasaBrightnessEvent(
name=kasa.name, brightness=new_brightness
)
)
kasa.curr_brightness = new_brightness

await _sleep_with_jitter(kasa.poll_interval)


Expand Down Expand Up @@ -226,24 +273,52 @@ async def _sleep_with_jitter(interval):


async def handle_kasa_requests(kasa: Kasa):
handlers = {
"KasaStateEvent": handle_kasa_request_state,
"KasaBrightnessEvent": handle_kasa_request_brightness,
}

while True:
if not kasa.started:
logger.debug(f"{kasa.name} waiting to get started by poller")
await asyncio.sleep(3)
continue

kasa_state_event = await kasa.recv_q.get()
wanted_state = kasa_state_event.state
if wanted_state != kasa.curr_state:
logger.info(
f"{kasa.name} changing state to {kasa.state_name(wanted_state)}"
)
if wanted_state:
await kasa.turn_on()
else:
await kasa.turn_off()
kasa_event = await kasa.recv_q.get()
logger.debug(f"Handling {kasa_event.event}...")
handler = handlers.get(kasa_event.event)
if handler:
await handler(kasa, kasa_event)
else:
logger.debug(
f"{kasa.name} state unchanged as {kasa.state_name(wanted_state)}"
)
logger.error(f"No handler found for {kasa_event.event}")

kasa.recv_q.task_done()

async def handle_kasa_request_state(kasa: Kasa, event: KasaStateEvent):
wanted_state = event.state
if wanted_state != kasa.curr_state:
logger.info(
f"{kasa.name} changing state to {kasa.state_name(wanted_state)}"
)
if wanted_state:
await kasa.turn_on()
else:
await kasa.turn_off()
else:
logger.debug(
f"{kasa.name} state unchanged as {kasa.state_name(wanted_state)}"
)

async def handle_kasa_request_brightness(kasa: Kasa, event: KasaBrightnessEvent):
wanted_brightness = event.brightness
if wanted_brightness != kasa.curr_brightness:
logger.info(
f"{kasa.name} changing brightness to {wanted_brightness}"
)

await kasa.set_brightness(wanted_brightness)

else:
logger.debug(
f"{kasa.name} brightness unchanged as {wanted_brightness}"
)
89 changes: 68 additions & 21 deletions mqtt2kasa/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from mqtt2kasa import log
from mqtt2kasa.config import Cfg
from mqtt2kasa.events import KasaStateEvent, KasaEmeterEvent, MqttMsgEvent
from mqtt2kasa.events import KasaStateEvent, KasaBrightnessEvent, KasaEmeterEvent, MqttMsgEvent
from mqtt2kasa.kasa_wrapper import (
Kasa,
handle_kasa_poller,
Expand All @@ -25,6 +25,7 @@
handle_mqtt_messages,
)

BRIGHTNESS_TOPIC_SUFFIX = "/brightness"

class RunState:
def __init__(self):
Expand All @@ -50,6 +51,24 @@ async def handle_main_event_kasa(
)
await mqtt_send_q.put(MqttMsgEvent(topic=kasa.topic, payload=payload))

async def handle_brightness_event_kasa(
kasa_state: KasaBrightnessEvent, run_state: RunState, mqtt_send_q: asyncio.Queue
):
kasa = run_state.kasas.get(kasa_state.name)
if not kasa:
logger.warning(
f"Unable to find device with name {kasa_state.name}. Ignoring kasa event"
)
return
payload = kasa_state.brightness
brightness_topic = f"{kasa.topic}{BRIGHTNESS_TOPIC_SUFFIX}"
logger.info(
f"Kasa event requesting mqtt for {kasa_state.name} to publish"
f" {brightness_topic} as {payload}"
)

await mqtt_send_q.put(MqttMsgEvent(topic=brightness_topic, payload=payload))


async def handle_emeter_event_kasa(
kasa_emeter: KasaEmeterEvent, run_state: RunState, mqtt_send_q: asyncio.Queue
Expand Down Expand Up @@ -97,35 +116,58 @@ async def handle_main_event_mqtt(
if not mqtt_msg.payload:
logger.debug(f"No payload for topic {mqtt_msg.topic}. Ignoring mqtt event")
return
try:
translated, new_state = kasa.state_parse(mqtt_msg.payload)
if translated:
await mqtt_send_q.put(
MqttMsgEvent(topic=mqtt_msg.topic, payload=translated)

if mqtt_msg.topic == kasa.topic:
try:
translated, new_state = kasa.state_parse(mqtt_msg.payload)
if translated:
await mqtt_send_q.put(
MqttMsgEvent(topic=mqtt_msg.topic, payload=translated)
)
return
except ValueError as e:
logger.warning(f"Unexpected payload for topic {mqtt_msg.topic}: {e}")
return

try:
kasa.recv_q.put_nowait(KasaStateEvent(name=name, state=new_state))
except asyncio.queues.QueueFull:
logger.warning(
f"Device {name} is too busy to take request to be set as "
f"{kasa.state_name(new_state)}"
)
return
except ValueError as e:
logger.warning(f"Unexpected payload for topic {mqtt_msg.topic}: {e}")
msg = f"Mqtt event causing device {name} to be set as {kasa.state_name(new_state)}"
if kasa.state_name(new_state) != mqtt_msg.payload:
msg += f" ({mqtt_msg.payload})"
logger.info(msg)
return
try:
kasa.recv_q.put_nowait(KasaStateEvent(name=name, state=new_state))
except asyncio.queues.QueueFull:
logger.warning(
f"Device {name} is too busy to take request to be set as "
f"{kasa.state_name(new_state)}"
)
return
msg = f"Mqtt event causing device {name} to be set as {kasa.state_name(new_state)}"
if kasa.state_name(new_state) != mqtt_msg.payload:
msg += f" ({mqtt_msg.payload})"
logger.info(msg)

if mqtt_msg.topic.endswith(BRIGHTNESS_TOPIC_SUFFIX):
try:
new_brightness = int(mqtt_msg.payload)
except ValueError as e:
# TODO AD add test
logger.warning(f"Unexpected payload for topic {mqtt_msg.topic}: {e}")
return

try:
kasa.recv_q.put_nowait(KasaBrightnessEvent(name=name, brightness=new_brightness))
except asyncio.queues.QueueFull:
logger.warning(
f"Device {name} is too busy to take request to set '{mqtt_msg.topic}' as "
f"{new_brightness}"
)
return
logger.info(f"Mqtt event causing device {name}({mqtt_msg.topic}) to be set as {new_brightness}")
return

async def handle_main_events(
run_state: RunState, mqtt_send_q: asyncio.Queue, main_events_q: asyncio.Queue
):
handlers = {
"KasaStateEvent": handle_main_event_kasa,
"KasaBrightnessEvent": handle_brightness_event_kasa,
"KasaEmeterEvent": handle_emeter_event_kasa,
"MqttMsgEvent": handle_main_event_mqtt,
}
Expand Down Expand Up @@ -197,9 +239,14 @@ async def main_loop():
f"Topic {topic} assigned to more than one device: "
f"{name} and {run_state.topics[topic]}"
)

kasa = Kasa(name, topic, config)
run_state.topics[topic] = name
await client.subscribe(topic)
run_state.kasas[name] = Kasa(name, topic, config)
if await kasa.is_dimmable:
run_state.topics[f"{topic}{BRIGHTNESS_TOPIC_SUFFIX}"] = name
await client.subscribe(f"{topic}{BRIGHTNESS_TOPIC_SUFFIX}")
run_state.kasas[name] = kasa

for kasa in run_state.kasas.values():
tasks.add(asyncio.create_task(handle_kasa_poller(kasa, main_events_q)))
Expand Down
18 changes: 16 additions & 2 deletions mqtt2kasa/tests/basic_test.sh.vagrant
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,17 @@ get_simulator_lines () {

# restart service to trigger discovery
sudo systemctl restart mqtt2kasa
sleep 10
sleep 20
echo TEST: Check discovery
get_log_lines 15
get_log_lines 20
grep --quiet -E 'Discovered 192\.168\.123\.201 .*thing1' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }
grep --quiet -E 'Discovered 192\.168\.123\.202 .*thing2' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }
grep --quiet -E 'Discovered 192\.168\.123\.203 .* thing3' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }
grep --quiet -E 'Discovered 192\.168\.123\.204 .* thing4' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }

echo TEST: EMeter
grep --quiet -E 'bar has no emeter' ${TMP_OUTPUT} || \
Expand Down Expand Up @@ -77,6 +79,18 @@ grep --quiet -E '192\.168\.123\.203' ${TMP_OUTPUT} || \
grep --quiet -E '"err_code":0' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }

echo TEST: Check brightness
mosquitto_pub -h ${MQTT_BROKER} -t /dimmer/brightness -m "77"
get_log_lines
grep --quiet -E 'Mqtt event causing device dimmer\(\/dimmer\/brightness\) to be set as 77' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }
get_simulator_lines
grep --quiet -E 'set_brightness.*"brightness": 77' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }
grep --quiet -E '192\.168\.123\.204' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }
grep --quiet -E 'set_brightness.*"err_code":0' ${TMP_OUTPUT} || \
{ echo "FAILED in $0 line ${LINENO}" >&2; exit ${LINENO}; }

echo 'PASSED: Happy happy, joy joy!'
rm -f ${TMP_OUTPUT}
Expand Down
8 changes: 8 additions & 0 deletions mqtt2kasa/tests/simulator.js.vagrant
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ devices.push(
data: { alias: 'Mock HS110 thing3', mac: '00:03:03:03:03:03', deviceId: 'thing3' },
})
);
devices.push(
new Device({
port: 9999,
address: '192.168.123.204',
model: 'hs220',
data: { alias: 'Mock HS220 thing4', mac: '00:04:04:04:04:04', deviceId: 'thing4' },
})
);

devices.forEach((d) => {
d.start();
Expand Down

0 comments on commit d96270d

Please sign in to comment.