diff --git a/Pipfile b/Pipfile index 3530c98..98393e7 100644 --- a/Pipfile +++ b/Pipfile @@ -6,6 +6,7 @@ verify_ssl = true [dev-packages] pytest = "*" pytest-only = "*" +fake-rpigpio = "*" [packages] paho-mqtt = "*" diff --git a/Pipfile.lock b/Pipfile.lock index e66d2ae..a04606f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "95cb763b84222f24ced404ae51ecc73ad0b6bcc9449c4023534b92644e6a3514" + "sha256": "4c26a9ea229202e41e73296e4da1176a46a7edc6ac009ef22e8216be54e94008" }, "pipfile-spec": 6, "requires": { @@ -22,6 +22,7 @@ "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" ], "index": "pypi", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==15.0.1" }, "configargparse": { @@ -30,6 +31,7 @@ "sha256:e7067471884de5478c58a511e529f0f9bd1c66bfef1dea90935438d6c23306d1" ], "index": "pypi", + "markers": "python_version >= '3.5'", "version": "==1.7" }, "humanfriendly": { @@ -37,6 +39,7 @@ "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==10.0" }, "paho-mqtt": { @@ -99,7 +102,7 @@ "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d", "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f" ], - "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==6.0.1" }, "rpi-rf": { @@ -126,17 +129,26 @@ "develop": { "exceptiongroup": { "hashes": [ - "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", - "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" + "sha256:4bfd3996ac73b41e9b9628b04e079f193850720ea5945fc96a08633c66912f14", + "sha256:91f5c769735f051a4290d52edd0858999b57e5876e9f85937691bd4c9fa3ed68" ], "markers": "python_version < '3.11'", - "version": "==1.1.3" + "version": "==1.2.0" + }, + "fake-rpigpio": { + "hashes": [ + "sha256:4e5efc6c5982267380d19b3ecf652aeb3e97c8377d9b4d5dd950801a4b8b7863", + "sha256:fd723b85b807e813a229f737bc0ac691bc4deb2fb9a0d2e97de99365dd18981a" + ], + "index": "pypi", + "version": "==0.1.1" }, "iniconfig": { "hashes": [ "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374" ], + "markers": "python_version >= '3.7'", "version": "==2.0.0" }, "packaging": { @@ -144,6 +156,7 @@ "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], + "markers": "python_version >= '3.7'", "version": "==23.2" }, "pluggy": { @@ -151,16 +164,17 @@ "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" ], + "markers": "python_version >= '3.8'", "version": "==1.3.0" }, "pytest": { "hashes": [ - "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac", - "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5" + "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280", + "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8" ], "index": "pypi", - "markers": "python_version >= '3.7' and python_version < '4.0'", - "version": "==7.4.3" + "markers": "python_version >= '3.7'", + "version": "==7.4.4" }, "pytest-only": { "hashes": [ @@ -168,6 +182,7 @@ "sha256:1f344d15d6495ca108b341ca12ca90eb1695bebb3bd3978c8efd4a0e121bdb8f" ], "index": "pypi", + "markers": "python_full_version >= '3.6.2' and python_version < '4'", "version": "==2.0.0" }, "tomli": { diff --git a/christmas_light_control.py b/christmas_light_control.py index 087db06..86888a4 100755 --- a/christmas_light_control.py +++ b/christmas_light_control.py @@ -4,10 +4,18 @@ import paho.mqtt.client as mqtt import configargparse -import RPi.GPIO as GPIO +try: + import RPi.GPIO +except (RuntimeError, ModuleNotFoundError): + print('Using Fake RPi GPIO Imports') + from fake_rpigpio import utils, RPi + import sys + utils.install() + sys.modules['RPi'] = RPi from src.devices_parser import DevicesParser from src.ir_device import IRDevice +from src.homeassistant import expose_devices # Create the logger logger = logging.getLogger() @@ -30,6 +38,10 @@ rfDeviceConfig.add_argument('--rf_gpio_pin', help='The pin where the RF - Transmitter is connected to', type=int, default=17) rfDeviceConfig.add_argument('-e', '--rf_enable_pin', help='If your Transmitter has an optional enable pin, this needs to be set', type=int, nargs=1) +homeassistantConfig = argParser.add_argument_group('HomeAssistant', description='Arguments used to configure the connection to homeassistant') +homeassistantConfig.add_argument('--expose_to_homeassistant', default=False, action='store_true', help='If set, all devices read from the devices.yaml file will be exposed to homeassistant via the configured MQTT broker.') +homeassistantConfig.add_argument('--discovery_topic', type=str, default='homeassistant', help='Root Topic name for the homeassistant discovery. Set if it needs to be changed.') + # Parse the command line arguments args = argParser.parse_args() if not args.debug: @@ -49,15 +61,15 @@ logger.exception(ex) exit(1) -# Initialize the GPIO pins -GPIO.setmode(GPIO.BCM) +# Initialize the RPi.GPIO pins +RPi.GPIO.setmode(RPi.GPIO.BCM) if args.rf_enable_pin: pin = args.rf_enable_pin[0] logger.info(f'Enable Pin for the RF - Devices was set to pin {pin}') logger.debug(f'Setting pin {pin} as OUTPUT') - GPIO.setup(pin, GPIO.OUT) - GPIO.output(pin, GPIO.LOW) + RPi.GPIO.setup(pin, RPi.GPIO.OUT) + RPi.GPIO.output(pin, RPi.GPIO.LOW) def on_connect(client, userdata, flags, rc): logger.info('Sucessfully connected to the mqtt broker') @@ -66,6 +78,10 @@ def on_connect(client, userdata, flags, rc): logger.debug(f'Subscribing to root topic: {topic}') client.subscribe(topic) + if args.expose_to_homeassistant: + logger.info('Exposing all PowerPlug Devices to Homeassistant') + expose_devices(devDict, client, root_topic=args.topic, discovery_topic=args.discovery_topic) + def on_message(client, userdata, message): logger.debug('Received mqtt messaged from the broker') logger.debug('Topic: {}'.format(message.topic)) @@ -107,7 +123,5 @@ def on_message(client, userdata, message): logger.error('An unexpected error occured: {}'.format(ex)) logger.exception(ex) finally: - logger.debug('Cleaning up GPIO Channels') - - # TODO: I need some sort of cleanup function in the device classes. - GPIO.cleanup() + logger.debug('Cleaning up RPi.GPIO Channels') + RPi.GPIO.cleanup() diff --git a/config.conf b/config.conf index 6fde23a..d77c00a 100644 --- a/config.conf +++ b/config.conf @@ -1,2 +1,3 @@ rf_gpio_pin = 22 rf_enable_pin = 27 +expose_to_homeassistant = True diff --git a/devices.yaml b/devices.yaml index 77e7587..8092328 100755 --- a/devices.yaml +++ b/devices.yaml @@ -1,5 +1,6 @@ fiberglaslampe: type: "PowerPlug" + friendlyName: "Fieberglas Lampe" codes: - 5825904 - 5888928 @@ -7,6 +8,7 @@ fiberglaslampe: protocol: 5 blauelampe: + friendlyName: "Blaue Lampe" type: "PowerPlug" codes: - 5825908 @@ -15,6 +17,7 @@ blauelampe: protocol: 5 grauelampe: + friendlyName: "Graue Lampe" type: "PowerPlug" codes: - 5825916 diff --git a/homeassistant/docker-compose.yaml b/homeassistant/docker-compose.yaml new file mode 100644 index 0000000..42e2ae8 --- /dev/null +++ b/homeassistant/docker-compose.yaml @@ -0,0 +1,28 @@ +services: + homeassistant: + image: homeassistant/home-assistant:latest + ports: + - "8123:8123" + volumes: + - "homeassistant_volume:/config" + networks: + - homeassistant_network + + mqttbroker: + image: eclipse-mosquitto + expose: + - 1883 + - 1884 + ports: + - "1883:1883" + - "1884:1884" + networks: + - homeassistant_network + command: "mosquitto -c /mosquitto-no-auth.conf" + +volumes: + homeassistant_volume: + +networks: + homeassistant_network: + diff --git a/src/device.py b/src/device.py index 11c76b5..3e09373 100755 --- a/src/device.py +++ b/src/device.py @@ -4,8 +4,9 @@ """ class Device(): - def __init__(self, name: str): + def __init__(self, name: str, friendlyName: str = ''): self.__name = name + self.__friendlyName = friendlyName def getName(self): return self.__name @@ -13,7 +14,14 @@ def getName(self): def setName(self, newName: str): self.__name = newName + def getFriendlyName(self): + return self.__friendlyName + + def setFriendlyName(self, newFriendlyName: str): + self.__friendlyName = newFriendlyName + name = property(getName, setName) + friendlyName = property(getFriendlyName, setFriendlyName) def turnOn(self): raise NotImplementedError('The devices subclass has to implement this function') diff --git a/src/devices_parser.py b/src/devices_parser.py index 4c53650..bcce00b 100755 --- a/src/devices_parser.py +++ b/src/devices_parser.py @@ -31,14 +31,13 @@ def parseDevicesFile(self, args, devicesFilePath=''): self.__logger.exception('Error while trying to open the devices file: {}'.format(ioErr)) raise - parsedDevicesuration = None + devicesYaml = None try: - parsedDevicesuration = yaml.load(fileContent, Loader=yaml.CLoader) - print(parsedDevicesuration) + devicesYaml = yaml.load(fileContent, Loader=yaml.CLoader) except yaml.ScannerError as err: self.__logger.error('Failed to read the devices file: {}'.format(err)) raise - return self.__parseTypes(parsedDevicesuration, args) + return self.__parseTypes(devicesYaml, args) def __parseTypes(self, parsedDevicesFile, args): """ @@ -50,21 +49,22 @@ def __parseTypes(self, parsedDevicesFile, args): for curDevName, curDev in parsedDevicesFile.items(): try: devType = curDev['type'] + friendlyName = curDev.get('friendlyName', curDevName) if devType == 'PowerPlug': - parsedPlug = self.__parsePowerPlug(curDev, curDevName, args) + parsedPlug = self.__parsePowerPlug(curDev, curDevName, friendlyName, args) if parsedPlug is not None: outDict[curDevName] = parsedPlug elif devType == 'GPIODevice': - parsedGpioDev = self.__parseGpioDevice(curDev, curDevName) + parsedGpioDev = self.__parseGpioDevice(curDev, curDevName, friendlyName) if parsedGpioDev is not None: outDict[curDevName] = parsedGpioDev elif devType == 'IRDevice': - parsedIrDev = self.__parseIrDevice(curDev, curDevName) + parsedIrDev = self.__parseIrDevice(curDev, curDevName, friendlyName) if parsedIrDev is not None: outDict[curDevName] = parsedIrDev @@ -80,7 +80,7 @@ def __parseTypes(self, parsedDevicesFile, args): return outDict - def __parsePowerPlug(self, powerPlugRaw, devName, args): + def __parsePowerPlug(self, powerPlugRaw, devName, friendlyName, args): """ Returns the PowerPlug object parsed from the passed object model. """ @@ -108,6 +108,7 @@ def __parsePowerPlug(self, powerPlugRaw, devName, args): if args.rf_enable_pin: outPlug = PowerPlug(onCodes, offCodes,\ name=devName,\ + friendlyName=friendlyName,\ senderGpioPin=args.rf_gpio_pin,\ pulselength=pulselength,\ protocol=protocol,\ @@ -126,7 +127,7 @@ def __parsePowerPlug(self, powerPlugRaw, devName, args): return outPlug - def __parseGpioDevice(self, gpioDeviceRaw, devName): + def __parseGpioDevice(self, gpioDeviceRaw, devName, friendlyName): """ Returns the GPIO object parsed from the passed object model. """ @@ -134,14 +135,14 @@ def __parseGpioDevice(self, gpioDeviceRaw, devName): try: pin = gpioDeviceRaw['pin'] - outGpioDevice = GPIODevice(devName, pin) + outGpioDevice = GPIODevice(devName, pin, friendlyName) except KeyError as kerr: self.__logger.warning('Missing Property {}, skipping PowerPlug'.format(kerr)) return outGpioDevice - def __parseIrDevice(self, irDeviceRaw, devName): + def __parseIrDevice(self, irDeviceRaw, devName, friendlyName): """ Returns the parsed IR Device """ @@ -164,4 +165,4 @@ def __parseIrDevice(self, irDeviceRaw, devName): kwargs[k] = int(v) - return IRDevice(devName, **kwargs) + return IRDevice(devName, friendlyName=friendlyName, **kwargs) diff --git a/src/gpio_device.py b/src/gpio_device.py index 296846f..6aff150 100755 --- a/src/gpio_device.py +++ b/src/gpio_device.py @@ -11,8 +11,8 @@ """ class GPIODevice(Device): - def __init__(self, name: str, pin: int): - super().__init__(name) + def __init__(self, name: str, pin: int, friendlyName=''): + super().__init__(name, friendlyName=friendlyName) self.__logger = logging.getLogger().getChild('GPIO Device') self.__pin = pin diff --git a/src/homeassistant.py b/src/homeassistant.py new file mode 100644 index 0000000..6232962 --- /dev/null +++ b/src/homeassistant.py @@ -0,0 +1,48 @@ +import logging +import json +from paho.mqtt.client import Client + +from src.power_plug import PowerPlug + +logger = logging.getLogger().getChild('HomeAssistant expose') + +def expose_devices(devices_dict: dict, mqttclient: Client, **expose_args): + """ + Exposes all RF - Plug Devices inside the passed devices dict to Homeassistant via + MQTT. + """ + + for key, val in devices_dict.items(): + # Only expose PowerPlugs for now. + if not isinstance(val, PowerPlug): + logger.debug(f'Skipping non PowerPlug Device: {key}') + continue + + # Serialize and Publish Switch Data for HomeAssistant + hadict = create_ha_dict(key, val, expose_args['root_topic']) + hadict_serialized = json.dumps(hadict) + + ha_device_config_topic = f'{expose_args["discovery_topic"]}/switch/{key}/config' + + logger.debug(f'Publishing HomeAssistant Discovery Data for Device: {key}') + logger.debug(f'topic: {ha_device_config_topic}; Payload: {hadict_serialized}') + mqttclient.publish(ha_device_config_topic, hadict_serialized) + + +def create_ha_dict(devname: str, devobject: PowerPlug, roottopic: str): + command_topic = f'{roottopic}/{devname}' if roottopic != '#' else devname + hadict = { + 'name': 'Switch', + 'unique_id': f'rfswitch_{devname}', + 'device': { + 'name': devobject.friendlyName, + 'identifiers': devname, + 'model': 'RFSwitch' + }, + 'command_topic': command_topic, + 'payload_on': 'ON', + 'payload_off': 'OFF', + 'retain': 'false' + } + + return hadict diff --git a/src/ir_device.py b/src/ir_device.py index 9df3957..3907907 100644 --- a/src/ir_device.py +++ b/src/ir_device.py @@ -6,7 +6,7 @@ class IRDevice(Device): - def __init__(self, name: str, powerKeyName='KEY_POWER', repeatCount=1, timeout=2): + def __init__(self, name: str, friendlyName: str = '', powerKeyName='KEY_POWER', repeatCount=1, timeout=2): self.__logger = logging.getLogger().getChild('IR Device') self.__logger.debug(f'Creating new IRDevice') @@ -15,6 +15,7 @@ def __init__(self, name: str, powerKeyName='KEY_POWER', repeatCount=1, timeout=2 self.__logger.debug(f'timeout: {timeout}') self.__name = name + self.__friendlyName = friendlyName self.__repeatCount = repeatCount self.__timeout = timeout diff --git a/src/power_plug.py b/src/power_plug.py index 4d7a0ae..48a1b89 100755 --- a/src/power_plug.py +++ b/src/power_plug.py @@ -12,8 +12,8 @@ """ class PowerPlug(Device): - def __init__(self, onCodes, offCodes, name='', pulselength=0, protocol=0, senderGpioPin=17, sendRepeat=20, setEnable=False, enablePin=0): - super().__init__(name) + def __init__(self, onCodes, offCodes, name='', friendlyName='', pulselength=0, protocol=0, senderGpioPin=17, sendRepeat=20, setEnable=False, enablePin=0): + super().__init__(name, friendlyName) self.__logger = logging.getLogger().getChild('Power Plug') diff --git a/test/devices_parser_tests/fixtures/everything.yaml b/test/devices_parser_tests/fixtures/everything.yaml index b8eb4fd..f1e6582 100755 --- a/test/devices_parser_tests/fixtures/everything.yaml +++ b/test/devices_parser_tests/fixtures/everything.yaml @@ -1,4 +1,5 @@ PowerPlug1: + friendlyName: 'Test PowerPlug 1' type: 'PowerPlug' codes: - 123 @@ -7,5 +8,6 @@ PowerPlug1: protocol: 1 GPIODevice1: + friendlyName: 'Test GPIO Device 1' type: 'GPIODevice' pin: 2 diff --git a/test/devices_parser_tests/test_config_parser.py b/test/devices_parser_tests/test_config_parser.py index 8a7cf67..cc1ce90 100755 --- a/test/devices_parser_tests/test_config_parser.py +++ b/test/devices_parser_tests/test_config_parser.py @@ -1,6 +1,14 @@ import pytest -import logging -import RPi.GPIO as GPIO + +try: + import RPi.GPIO +except (RuntimeError, ModuleNotFoundError): + print('Using Fake RPi GPIO Imports') + from fake_rpigpio import utils, RPi + import sys + utils.install() + sys.modules['RPi'] = RPi + from collections import OrderedDict from src.devices_parser import DevicesParser @@ -13,11 +21,6 @@ class SampleArgs: rf_gpio_pin = 17 class TestDevicesParser(): - - @pytest.fixture(scope = 'session', autouse = True) - def setGPIOPinLayout(self): - GPIO.setmode(GPIO.BCM) - @pytest.fixture(scope = 'session') def devicesParser(self): return DevicesParser() @@ -29,9 +32,10 @@ def testParseEverything(self, devicesParser): 'PowerPlug1': PowerPlug( [123], [456], name='PowerPlug1', + friendlyName='Test PowerPlug 1', protocol=1, pulselength=234), - 'GPIODevice1': GPIODevice('GPIODevice1', 2) + 'GPIODevice1': GPIODevice('GPIODevice1', 2, friendlyName='Test GPIO Device') } parsedDevices = devicesParser.parseDevicesFile(SampleArgs(), devicesFilePath='test/devices_parser_tests/fixtures/everything.yaml') @@ -63,7 +67,7 @@ def testParseFaultyPPs(self, devicesParser): print('It should at least parse all GPIODevices, even if the PowerPlugs format is wrong') expectedResult = { - 'GPIODevice1': GPIODevice('GPIODevice1', 2) + 'GPIODevice1': GPIODevice('GPIODevice1', '', 2) } parsedDevices = devicesParser.parseDevicesFile(SampleArgs(), devicesFilePath='test/devices_parser_tests/fixtures/faulty_powerplugs.yaml')