Skip to content

Commit b9e24ba

Browse files
committed
Added MacroPad RPC Home Assistant code
1 parent c1cb589 commit b9e24ba

3 files changed

Lines changed: 331 additions & 0 deletions

File tree

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2021 Melissa LeBlanc-Williams for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: Unlicense
4+
"""
5+
Home Assistant Remote Procedure Call for MacroPad.
6+
"""
7+
import time
8+
import displayio
9+
import terminalio
10+
from adafruit_display_shapes.rect import Rect
11+
from rpc import RpcClient, RpcError
12+
from adafruit_display_text import label
13+
from adafruit_macropad import MacroPad
14+
from secrets import secrets
15+
16+
macropad = MacroPad()
17+
rpc = RpcClient()
18+
19+
COMMAND_TOPIC = "macropad/peripheral"
20+
SUBSCRIBE_TOPICS = ("stat/demoswitch/POWER", "stat/office-light/POWER")
21+
ENCODER_ITEM = 0
22+
KEY_LABELS = ("Demo", "Office")
23+
UPDATE_DELAY = 0.25
24+
NEOPIXEL_COLORS = {
25+
"OFF": 0xff0000,
26+
"ON": 0x00ff00,
27+
}
28+
29+
class MqttError(Exception):
30+
"""For MQTT Specific Errors"""
31+
pass
32+
# Set up displayio group with all the labels
33+
group = displayio.Group()
34+
for key_index in range(12):
35+
x = key_index % 3
36+
y = key_index // 3
37+
group.append(label.Label(terminalio.FONT, text=(str(KEY_LABELS[key_index]) if key_index < len(KEY_LABELS) else ''), color=0xFFFFFF,
38+
anchored_position=((macropad.display.width - 1) * x / 2,
39+
macropad.display.height - 1 -
40+
(3 - y) * 12),
41+
anchor_point=(x / 2, 1.0)))
42+
group.append(Rect(0, 0, macropad.display.width, 12, fill=0xFFFFFF))
43+
group.append(label.Label(terminalio.FONT, text='Home Assistant', color=0x000000,
44+
anchored_position=(macropad.display.width//2, -2),
45+
anchor_point=(0.5, 0.0)))
46+
macropad.display.show(group)
47+
48+
def rpc_call(function, *args, **kwargs):
49+
response = rpc.call(function, *args, **kwargs)
50+
if response["error"]:
51+
if response["error_type"]:
52+
raise MqttError(response["message"])
53+
raise RpcError(response["message"])
54+
return response["return_val"]
55+
56+
def mqtt_init():
57+
rpc_call("mqtt_init", secrets["mqtt_broker"], username=secrets["mqtt_username"], password=secrets["mqtt_password"], port=secrets["mqtt_port"])
58+
rpc_call("mqtt_connect")
59+
60+
def update_key(key_number):
61+
switch_state = rpc_call("mqtt_get_last_value", SUBSCRIBE_TOPICS[key_number])
62+
if switch_state is not None:
63+
macropad.pixels[key_number] = NEOPIXEL_COLORS[switch_state]
64+
else:
65+
macropad.pixels[key_number] = None
66+
67+
mqtt_init()
68+
last_macropad_encoder_value = macropad.encoder
69+
70+
for key_number, topic in enumerate(SUBSCRIBE_TOPICS):
71+
rpc_call("mqtt_subscribe", topic)
72+
update_key(key_number)
73+
74+
while True:
75+
output = {}
76+
77+
key_event = macropad.keys.events.get()
78+
if key_event and key_event.pressed:
79+
output["key_number"] = key_event.key_number
80+
81+
if macropad.encoder != last_macropad_encoder_value:
82+
output["encoder"] = macropad.encoder - last_macropad_encoder_value
83+
last_macropad_encoder_value = macropad.encoder
84+
85+
macropad.encoder_switch_debounced.update()
86+
if macropad.encoder_switch_debounced.pressed and "key_number" not in output and ENCODER_ITEM is not None:
87+
output["key_number"] = ENCODER_ITEM
88+
89+
if output:
90+
try:
91+
rpc_call("mqtt_publish", COMMAND_TOPIC, output)
92+
if "key_number" in output:
93+
time.sleep(UPDATE_DELAY)
94+
update_key(output["key_number"])
95+
except MqttError:
96+
mqtt_init()
97+
except RpcError as err_msg:
98+
print(err_msg)

MacroPad_RPC_Home_Assistant/rpc.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2021 Melissa LeBlanc-Williams for Adafruit Industries
2+
#
3+
# SPDX-License-Identifier: Unlicense
4+
"""
5+
USB CDC Remote Procedure Call class
6+
"""
7+
8+
import time
9+
import json
10+
try:
11+
import serial
12+
import adafruit_board_toolkit.circuitpython_serial
13+
json_decode_exception = json.decoder.JSONDecodeError
14+
except ImportError:
15+
import usb_cdc as serial
16+
json_decode_exception = ValueError
17+
18+
RESPONSE_TIMEOUT=1
19+
DATA_TIMEOUT=0.2
20+
21+
class RpcError(Exception):
22+
"""For RPC Specific Errors"""
23+
pass
24+
25+
class _Rpc:
26+
def __init__(self):
27+
self._serial = None
28+
29+
@staticmethod
30+
def create_response_packet(error=False, error_type="RPC", message=None, return_val=None):
31+
return {
32+
"error": error,
33+
"error_type": error_type if error else None,
34+
"message": message,
35+
"return_val": return_val
36+
}
37+
38+
@staticmethod
39+
def create_request_packet(function, args=[], kwargs={}):
40+
return {
41+
"function": function,
42+
"args": args,
43+
"kwargs": kwargs
44+
}
45+
46+
def _wait_for_packet(self, timeout=None):
47+
incoming_packet = b""
48+
if timeout is not None:
49+
response_start_time = time.monotonic()
50+
while True:
51+
if incoming_packet:
52+
data_start_time = time.monotonic()
53+
while not self._serial.in_waiting:
54+
if incoming_packet and (time.monotonic() - data_start_time) >= DATA_TIMEOUT:
55+
incoming_packet = b""
56+
if not incoming_packet and timeout is not None:
57+
if (time.monotonic() - response_start_time) >= timeout:
58+
return self.create_response_packet(error=True, message="Timed out waiting for response")
59+
time.sleep(0.001)
60+
data = self._serial.read(self._serial.in_waiting)
61+
if data:
62+
try:
63+
incoming_packet += data
64+
packet = json.loads(incoming_packet)
65+
# json can try to be clever with missing braces, so make sure we have everything
66+
if sorted(tuple(packet.keys())) == sorted(self._packet_format()):
67+
return packet
68+
except json_decode_exception:
69+
pass # Incomplete packet
70+
71+
class RpcClient(_Rpc):
72+
def __init__(self):
73+
super().__init__()
74+
self._serial = serial.data
75+
76+
def _packet_format(self):
77+
return self.create_response_packet().keys()
78+
79+
def call(self, function, *args, **kwargs):
80+
packet = self.create_request_packet(function, args, kwargs)
81+
self._serial.write(bytes(json.dumps(packet), "utf-8"))
82+
# Wait for response packet to indicate success
83+
return self._wait_for_packet(RESPONSE_TIMEOUT)
84+
85+
class RpcServer(_Rpc):
86+
def __init__(self, handler, baudrate=9600):
87+
super().__init__()
88+
self._serial = self.init_serial(baudrate)
89+
self._handler = handler
90+
91+
def _packet_format(self):
92+
return self.create_request_packet(None).keys()
93+
94+
def init_serial(self, baudrate):
95+
port = self.detect_port()
96+
97+
return serial.Serial(
98+
port,
99+
baudrate,
100+
parity='N',
101+
rtscts=False,
102+
xonxoff=False,
103+
exclusive=True,
104+
)
105+
106+
def detect_port(self):
107+
"""
108+
Detect the port automatically
109+
"""
110+
comports = adafruit_board_toolkit.circuitpython_serial.data_comports()
111+
ports = [comport.device for comport in comports]
112+
if len(ports) >= 1:
113+
if len(ports) > 1:
114+
print("Multiple devices detected, using the first detected port.")
115+
return ports[0]
116+
raise RuntimeError("Unable to find any CircuitPython Devices with the CDC Data port enabled.")
117+
118+
def loop(self, timeout=None):
119+
packet = self._wait_for_packet(timeout)
120+
if "error" not in packet:
121+
response_packet = self._handler(packet)
122+
self._serial.write(bytes(json.dumps(response_packet), "utf-8"))
123+
124+
def close_serial(self):
125+
if self._serial is not None:
126+
self._serial.close()
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import time
2+
import json
3+
import ssl
4+
import socket
5+
import adafruit_minimqtt.adafruit_minimqtt as MQTT
6+
from rpc import RpcServer
7+
8+
mqtt_client = None
9+
mqtt_connected = False
10+
last_mqtt_messages = {}
11+
12+
# For program flow purposes, we do not want these functions to be called remotely
13+
PROTECTED_FUNCTIONS = ["main", "handle_rpc"]
14+
15+
def connect(mqtt_client, userdata, flags, rc):
16+
global mqtt_connected
17+
mqtt_connected = True
18+
19+
def disconnect(mqtt_client, userdata, rc):
20+
global mqtt_connected
21+
mqtt_connected = False
22+
23+
def message(client, topic, message):
24+
last_mqtt_messages[topic] = message
25+
26+
class MqttError(Exception):
27+
"""For MQTT Specific Errors"""
28+
pass
29+
30+
# Default to 1883 as SSL on CPython is not currently supported
31+
def mqtt_init(broker, port=1883, username=None, password=None):
32+
global mqtt_client, mqtt_connect_info
33+
mqtt_client = MQTT.MQTT(
34+
broker=broker,
35+
port=port,
36+
username=username,
37+
password=password,
38+
socket_pool=socket,
39+
ssl_context=ssl.create_default_context(),
40+
)
41+
42+
mqtt_client.on_connect = connect
43+
mqtt_client.on_disconnect = disconnect
44+
mqtt_client.on_message = message
45+
46+
def mqtt_connect():
47+
mqtt_client.connect()
48+
49+
def mqtt_publish(topic, payload):
50+
if mqtt_client is None:
51+
raise MqttError("MQTT is not initialized")
52+
try:
53+
return_val = mqtt_client.publish(topic, json.dumps(payload))
54+
except BrokenPipeError:
55+
time.sleep(0.5)
56+
mqtt_client.connect()
57+
return_val = mqtt_client.publish(topic, json.dumps(payload))
58+
return return_val
59+
60+
def mqtt_subscribe(topic):
61+
if mqtt_client is None:
62+
raise MqttError("MQTT is not initialized")
63+
return mqtt_client.subscribe(topic)
64+
65+
def mqtt_get_last_value(topic):
66+
"""Return the last value we have received regarding a topic"""
67+
if topic in last_mqtt_messages.keys():
68+
return last_mqtt_messages[topic]
69+
return None
70+
71+
def handle_rpc(packet):
72+
"""This function will verify good data in packet,
73+
call the method with parameters, and generate a response
74+
packet as the return value"""
75+
func_name = packet['function']
76+
if func_name in PROTECTED_FUNCTIONS:
77+
return rpc.create_response_packet(error=True, message=f"{func_name}'() is a protected function and can not be called.")
78+
if func_name not in globals():
79+
return rpc.create_response_packet(error=True, message=f"Function {func_name}() not found")
80+
try:
81+
return_val = globals()[func_name](*packet['args'], **packet['kwargs'])
82+
except MqttError as err:
83+
return rpc.create_response_packet(error=True, error_type="MQTT", message=str(err))
84+
85+
packet = rpc.create_response_packet(return_val=return_val)
86+
return packet
87+
88+
def main():
89+
"""Command line, entry point"""
90+
global mqtt_connected
91+
while True:
92+
rpc.loop(0.25)
93+
if mqtt_connected and mqtt_client is not None:
94+
try:
95+
mqtt_client.loop(0.5)
96+
except AttributeError:
97+
mqtt_connected = False
98+
99+
if __name__ == '__main__':
100+
rpc = RpcServer(handle_rpc)
101+
try:
102+
print(f"Listening for RPC Calls, to stop press \"CTRL+C\"")
103+
main()
104+
except KeyboardInterrupt:
105+
print("")
106+
print(f"Caught interrupt, exiting...")
107+
rpc.close_serial()

0 commit comments

Comments
 (0)