pax_global_header00006660000000000000000000000064146605656070014531gustar00rootroot0000000000000052 comment=932bfd9b783428ee35b7dc56b8810962ecb7be87 dubnom-pyhomeworks-932bfd9/000077500000000000000000000000001466056560700160265ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/.github/000077500000000000000000000000001466056560700173665ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/.github/dependabot.yml000066400000000000000000000004101466056560700222110ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: "github-actions" directory: "/" schedule: interval: daily open-pull-requests-limit: 10 - package-ecosystem: pip directory: "/" schedule: interval: weekly open-pull-requests-limit: 10 dubnom-pyhomeworks-932bfd9/.github/release-drafter.yml000066400000000000000000000002211466056560700231510ustar00rootroot00000000000000categories: - title: "⬆️ Dependencies" collapse-after: 1 labels: - "dependencies" template: | ## What's Changed $CHANGESdubnom-pyhomeworks-932bfd9/.github/workflows/000077500000000000000000000000001466056560700214235ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/.github/workflows/pythonpublish.yml000066400000000000000000000015541466056560700250630ustar00rootroot00000000000000# This workflows will upload a Python Package using Twine when a release is created # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries name: Upload Python Package on: release: types: - published jobs: deploy: runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/pyhomeworks permissions: id-token: write steps: - uses: actions/checkout@v4.1.7 - name: Set up Python uses: actions/setup-python@v5.1.1 with: python-version: '3.12' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.9.0 dubnom-pyhomeworks-932bfd9/.github/workflows/release-drafter.yml000066400000000000000000000005141466056560700252130ustar00rootroot00000000000000name: Release Drafter on: push: branches: - master jobs: update_release_draft: runs-on: ubuntu-latest steps: # Drafts your next Release notes as Pull Requests are merged into "master" - uses: release-drafter/release-drafter@v6.0.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} dubnom-pyhomeworks-932bfd9/.github/workflows/test.yml000066400000000000000000000017071466056560700231320ustar00rootroot00000000000000# This workflow will install Python dependencies, run tests and lint with a single version of Python # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions name: Run Tests on: push: branches: - master pull_request: branches: - master jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4.1.7 - name: Set up Python 3.12 uses: actions/setup-python@v5.1.1 with: python-version: 3.12 - name: Install dependencies run: | pip install -r requirements-test.txt - name: Lint with flake8 run: | flake8 pyhomeworks - name: Check formatting with black run: | black pyhomeworks --check --diff - name: Lint with mypy run: | mypy --strict pyhomeworks - name: Lint with pylint run: | pylint pyhomeworks dubnom-pyhomeworks-932bfd9/.gitignore000066400000000000000000000023721466056560700200220ustar00rootroot00000000000000te-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json dubnom-pyhomeworks-932bfd9/LICENSE000066400000000000000000000020621466056560700170330ustar00rootroot00000000000000Copyright (c) 2018 The Python Packaging Authority Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. dubnom-pyhomeworks-932bfd9/README.md000066400000000000000000000007751466056560700173160ustar00rootroot00000000000000# pyhomeworks Package Package to connect to Lutron Homeworks Series-4 and Series-8 systems. The controller is connected by an RS232 port to an Ethernet adaptor (NPort). # Example: from time import sleep from pyhomeworks import Homeworks def callback(msg,data): print(msg,data) hw = Homeworks( 'host.test.com', 4008, callback ) hw.start() # Sleep for 10 seconds waiting for a callback sleep(10.) # Close the interface and stop the worker thread hw.stop() dubnom-pyhomeworks-932bfd9/examples/000077500000000000000000000000001466056560700176445ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/examples/test.py000066400000000000000000000006121466056560700211740ustar00rootroot00000000000000""" Test Homeworks interface. Michael Dubno - 2018 - New York """ import time from pyhomeworks import Homeworks def callback(msg, args): """Show the message are arguments.""" print(msg, args) print("Starting interace") hw = Homeworks('192.168.2.55', 4008, callback) hw.start() print("Connected. Waiting for messages.") time.sleep(10.) print("Closing.") hw.stop() print("Done.") dubnom-pyhomeworks-932bfd9/ha/000077500000000000000000000000001466056560700164165ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/ha/binary_sensor/000077500000000000000000000000001466056560700212735ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/ha/binary_sensor/homeworks.py000066400000000000000000000052721466056560700236710ustar00rootroot00000000000000"""Component for interfacing to Lutron Homeworks keypads. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homeworks/ """ import logging import voluptuous as vol from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.components.homeworks import ( HomeworksDevice, HOMEWORKS_CONTROLLER) from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['homeworks'] REQUIREMENTS = ['pyhomeworks==0.0.4'] _LOGGER = logging.getLogger(__name__) EVENT_BUTTON_PRESSED = 'button_pressed' CONF_KEYPADS = 'keypads' CONF_ADDR = 'addr' CONF_BUTTONS = 'buttons' BUTTON_SCHEMA = vol.Schema({cv.positive_int: cv.string}) BUTTONS_SCHEMA = vol.Schema({ vol.Required(CONF_ADDR): cv.string, vol.Required(CONF_NAME): cv.string, vol.Required(CONF_BUTTONS): vol.All(cv.ensure_list, [BUTTON_SCHEMA]) }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_KEYPADS): vol.All(cv.ensure_list, [BUTTONS_SCHEMA]) }) def setup_platform(hass, config, add_entities, discover_info=None): """Set up the Homeworks keypads.""" controller = hass.data[HOMEWORKS_CONTROLLER] devs = [] for keypad in config.get(CONF_KEYPADS): name = keypad.get(CONF_NAME) addr = keypad.get(CONF_ADDR) buttons = keypad.get(CONF_BUTTONS) for button in buttons: # FIX: This should be done differently for num, title in button.items(): devname = name + '_' + title dev = HomeworksKeypad(controller, addr, num, devname) devs.append(dev) add_entities(devs, True) return True class HomeworksKeypad(HomeworksDevice, BinarySensorDevice): """Homeworks Keypad.""" def __init__(self, controller, addr, num, name): """Create keypad with addr, num, and name.""" HomeworksDevice.__init__(self, controller, addr, name) self._num = num self._state = None @property def is_on(self): """Return state of the button.""" return self._state def callback(self, msg_type, values): """Dispatch messages from the controller.""" from pyhomeworks.pyhomeworks import ( HW_BUTTON_PRESSED, HW_BUTTON_RELEASED) old_state = self._state if msg_type == HW_BUTTON_PRESSED and values[1] == self._num: self.hass.bus.fire(EVENT_BUTTON_PRESSED, {'entity_id': self.entity_id}) self._state = True elif msg_type == HW_BUTTON_RELEASED and values[1] == self._num: self._state = False return old_state != self._state dubnom-pyhomeworks-932bfd9/ha/homeworks.py000066400000000000000000000057521466056560700210170ustar00rootroot00000000000000"""Component for interfacing to Lutron Homeworks Series 4 and 8 systems. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homeworks/ """ import logging import voluptuous as vol from homeassistant.const import ( CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity REQUIREMENTS = ['pyhomeworks==0.0.4'] _LOGGER = logging.getLogger(__name__) DOMAIN = 'homeworks' HOMEWORKS_CONTROLLER = 'homeworks' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PORT): cv.port, }), }, extra=vol.ALLOW_EXTRA) def setup(hass, base_config): """Start Homeworks controller.""" from pyhomeworks.pyhomeworks import Homeworks class HomeworksController(Homeworks): """Interface between HASS and Homeworks controller.""" def __init__(self, host, port): """Host and port of Lutron Homeworks controller.""" Homeworks.__init__(self, host, port, self.callback) self._subscribers = {} def subscribe(self, device): """Add a device to subscribe to events.""" if device.addr not in self._subscribers: self._subscribers[device.addr] = [] self._subscribers[device.addr].append(device) if device.is_light: self.request_dimmer_level(device.addr) def callback(self, msg_type, values): """Dispatch state changes.""" _LOGGER.debug('callback: %s, %s', msg_type, values) addr = values[0] for sub in self._subscribers.get(addr, []): _LOGGER.debug("callback: %s", sub) if sub.callback(msg_type, values): sub.schedule_update_ha_state() config = base_config.get(DOMAIN) host = config[CONF_HOST] port = config[CONF_PORT] controller = HomeworksController(host, port) hass.data[HOMEWORKS_CONTROLLER] = controller def cleanup(event): controller.close() hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) return True class HomeworksDevice(Entity): """Base class of a Homeworks device.""" is_light = False def __init__(self, controller, addr, name): """Controller, address, and name of the device.""" self._addr = addr self._name = name self._controller = controller async def async_added_to_hass(self): """Register callback.""" self.hass.async_add_job(self._controller.subscribe, self) @property def addr(self): """Device address.""" return self._addr @property def name(self): """Device name.""" return self._name @property def should_poll(self): """No need to poll.""" return False def callback(self, msg_type, values): """Run when Homeworks device changes state.""" pass dubnom-pyhomeworks-932bfd9/ha/light/000077500000000000000000000000001466056560700175255ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/ha/light/homeworks.py000066400000000000000000000055711466056560700221250ustar00rootroot00000000000000"""Component for interfacing to Lutron Homeworks lights. For more details about this component, please refer to the documentation at https://home-assistant.io/components/homeworks/ """ import logging import voluptuous as vol from homeassistant.components.homeworks import ( HomeworksDevice, HOMEWORKS_CONTROLLER) from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['homeworks'] REQUIREMENTS = ['pyhomeworks==0.0.4'] _LOGGER = logging.getLogger(__name__) FADE_RATE = 2. CONF_DIMMERS = 'dimmers' CONF_ADDR = 'addr' CONF_RATE = 'rate' CV_FADE_RATE = vol.All(vol.Coerce(float), vol.Range(min=0, max=20)) DIMMER_SCHEMA = vol.Schema({ vol.Required(CONF_ADDR): cv.string, vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_RATE, default=FADE_RATE): CV_FADE_RATE, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_DIMMERS): vol.All(cv.ensure_list, [DIMMER_SCHEMA]) }) def setup_platform(hass, config, add_entities, discover_info=None): """Set up the Homeworks lights.""" controller = hass.data[HOMEWORKS_CONTROLLER] devs = [] for dimmer in config.get(CONF_DIMMERS): dev = HomeworksLight(controller, dimmer[CONF_ADDR], dimmer[CONF_NAME], dimmer[CONF_RATE]) devs.append(dev) add_entities(devs, True) return True class HomeworksLight(HomeworksDevice, Light): """Homeworks Light.""" def __init__(self, controller, addr, name, rate): """Create device with Addr, name, and rate.""" HomeworksDevice.__init__(self, controller, addr, name) self._rate = rate self._level = None self.is_light = True @property def supported_features(self): """Supported features.""" return SUPPORT_BRIGHTNESS def turn_on(self, **kwargs): """Turn on the light.""" if ATTR_BRIGHTNESS in kwargs: self.brightness = kwargs[ATTR_BRIGHTNESS] else: self.brightness = 255 def turn_off(self, **kwargs): """Turn off the light.""" self.brightness = 0 @property def brightness(self): """Control the brightness.""" return self._level @brightness.setter def brightness(self, level): self._controller.fade_dim( float((level*100.)/255.), self._rate, 0, self._addr) self._level = level @property def is_on(self): """Is the light on/off.""" return self._level != 0 def callback(self, msg_type, values): """Process device specific messages.""" from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED if msg_type == HW_LIGHT_CHANGED: self._level = int((values[1] * 255.)/100.) return True return False dubnom-pyhomeworks-932bfd9/ha/sample.yaml000066400000000000000000000010061466056560700205600ustar00rootroot00000000000000homeworks: host: 192.168.2.55 port: 4008 light: - platform: homeworks dimmers: - addr: "[02:08:01:15]" name: "Lower Stairs" rate: 5 - addr: "[02:08:01:25]" name: "Upper Stairs" binary_sensor: - platform: homeworks keypads: - addr: "[02:08:02:05]" name: "Family Room Keypad" buttons: 0: "Button1" 1: "Button2" - addr: "[02:08:02:02]" name: "Kitchen Keypad" buttons: 0: "Button1" dubnom-pyhomeworks-932bfd9/pyhomeworks/000077500000000000000000000000001466056560700204155ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/pyhomeworks/__init__.py000066400000000000000000000004241466056560700225260ustar00rootroot00000000000000""" Homeworks. A partial implementation of an interface to series-4 and series-8 Lutron Homeworks systems. The Series4/8 is connected to an RS232 port to an Ethernet adaptor (NPort). Michael Dubno - 2018 - New York """ name = "pyhomeworks" # pylint: disable=invalid-name dubnom-pyhomeworks-932bfd9/pyhomeworks/exceptions.py000066400000000000000000000011241466056560700231460ustar00rootroot00000000000000"""Homeworks exceptions.""" class HomeworksException(Exception): """Base class for exceptions.""" class HomeworksConnectionFailed(HomeworksException): """Connection failed.""" class HomeworksConnectionLost(HomeworksException): """Connection lost.""" class HomeworksAuthenticationException(HomeworksException): """Base class for authentication exceptions.""" class HomeworksNoCredentialsProvided(HomeworksAuthenticationException): """Credentials needed.""" class HomeworksInvalidCredentialsProvided(HomeworksAuthenticationException): """Invalid credentials.""" dubnom-pyhomeworks-932bfd9/pyhomeworks/py.typed000066400000000000000000000000001466056560700221020ustar00rootroot00000000000000dubnom-pyhomeworks-932bfd9/pyhomeworks/pyhomeworks.py000066400000000000000000000237531466056560700233700ustar00rootroot00000000000000"""Homeworks. A partial implementation of an interface to series-4 and series-8 Lutron Homeworks systems. The Series4/8 is connected to an RS232 port to an Ethernet adaptor (NPort). Michael Dubno - 2018 - New York """ from collections.abc import Callable from contextlib import suppress import logging import select import socket from threading import Thread import time from typing import Any, Final from . import exceptions _LOGGER = logging.getLogger(__name__) def _p_address(arg: str) -> str: return arg def _p_button(arg: str) -> int: return int(arg) def _p_enabled(arg: str) -> bool: return arg == "enabled" def _p_level(arg: str) -> int: return int(arg) def _p_ledstate(arg: str) -> list[int]: return [int(num) for num in arg] def _norm(x: str) -> tuple[str, Callable[[str], str], Callable[[str], int]]: return (x, _p_address, _p_button) # Callback types HW_BUTTON_DOUBLE_TAP = "button_double_tap" HW_BUTTON_HOLD = "button_hold" HW_BUTTON_PRESSED = "button_pressed" HW_BUTTON_RELEASED = "button_released" HW_KEYPAD_ENABLE_CHANGED = "keypad_enable_changed" HW_KEYPAD_LED_CHANGED = "keypad_led_changed" HW_LIGHT_CHANGED = "light_changed" HW_LOGIN_INCORRECT = "login_incorrect" ACTIONS: dict[str, tuple[str, Callable[[str], str], Callable[[str], Any]]] = { "KBP": _norm(HW_BUTTON_PRESSED), "KBR": _norm(HW_BUTTON_RELEASED), "KBH": _norm(HW_BUTTON_HOLD), "KBDT": _norm(HW_BUTTON_DOUBLE_TAP), "DBP": _norm(HW_BUTTON_PRESSED), "DBR": _norm(HW_BUTTON_RELEASED), "DBH": _norm(HW_BUTTON_HOLD), "DBDT": _norm(HW_BUTTON_DOUBLE_TAP), "SVBP": _norm(HW_BUTTON_PRESSED), "SVBR": _norm(HW_BUTTON_RELEASED), "SVBH": _norm(HW_BUTTON_HOLD), "SVBDT": _norm(HW_BUTTON_DOUBLE_TAP), "KLS": (HW_KEYPAD_LED_CHANGED, _p_address, _p_ledstate), "DL": (HW_LIGHT_CHANGED, _p_address, _p_level), "KES": (HW_KEYPAD_ENABLE_CHANGED, _p_address, _p_enabled), } IGNORED = { "Keypad button monitoring enabled", "GrafikEye scene monitoring enabled", "Dimmer level monitoring enabled", "Keypad led monitoring enabled", } class Homeworks(Thread): """Interface with a Lutron Homeworks 4/8 Series system.""" COMMAND_SEPARATOR_RX: Final = b"\r" COMMAND_SEPARATOR_TX: Final = b"\r\n" LINE_ENDING_CHARACTERS: Final = (b"\r", b"\n") LOGIN_REQUEST: Final = b"LOGIN: " LOGIN_INCORRECT: Final = b"login incorrect" LOGIN_SUCCESSFUL: Final = b"login successful" POLLING_FREQ: Final = 1.0 LOGIN_PROMPT_WAIT_TIME: Final = 0.2 SOCKET_CONNECT_TIMEOUT: Final = 10.0 def __init__( # pylint: disable=too-many-arguments self, host: str, port: int, callback: Callable[[Any, Any], None], username: str | None = None, password: str | None = None, ) -> None: """Initialize.""" Thread.__init__(self) self._host = host self._port = int(port) self._credentials = _format_credentials(username, password) self._callback = callback self._socket: socket.socket | None = None self._running = False def connect(self) -> None: """Connect to controller using host, port. It's not necessary to call this method, but it can be useed to attempt to connect to the remote device without starting the worker thread. """ self._connect(False) def _connect(self, callback_on_login_error: bool) -> None: """Connect to controller using host, port.""" try: self._socket = socket.create_connection( (self._host, self._port), self.SOCKET_CONNECT_TIMEOUT ) except (OSError, ValueError) as error: _LOGGER.debug( "Failed to connect to %s:%s - %s", self._host, self._port, error, exc_info=True, ) raise exceptions.HomeworksConnectionFailed( f"Couldn't connect to '{self._host}:{self._port}'" ) from error _LOGGER.info("Connected to '%s:%s'", self._host, self._port) # Wait for login prompt time.sleep(self.LOGIN_PROMPT_WAIT_TIME) buffer = self._read() while buffer.startswith(self.LINE_ENDING_CHARACTERS): buffer = buffer[1:] if buffer.startswith(self.LOGIN_REQUEST): try: self._handle_login_request(callback_on_login_error) except exceptions.HomeworksException: self._close() raise # Setup interface and subscribe to events self._subscribe() def _handle_login_request(self, callback_on_login_error: bool) -> None: if not self._credentials: raise exceptions.HomeworksNoCredentialsProvided self._send(self._credentials) buffer = self._read() while buffer.startswith(self.LINE_ENDING_CHARACTERS): buffer = buffer[1:] if buffer.startswith(self.LOGIN_INCORRECT): if callback_on_login_error: self._callback(HW_LOGIN_INCORRECT, []) raise exceptions.HomeworksInvalidCredentialsProvided if buffer.startswith(self.LOGIN_SUCCESSFUL): _LOGGER.debug("Login successful") def _read(self) -> bytes: readable, _, _ = select.select([self._socket], [], [], self.POLLING_FREQ) if not readable: return b"" recv = self._socket.recv(1024) # type: ignore[union-attr] if not recv: self._close() raise exceptions.HomeworksConnectionLost _LOGGER.debug("recv: %s", recv) return recv def _send(self, command: str) -> bool: _LOGGER.debug("send: %s", command) try: self._socket.send(command.encode("utf8") + self.COMMAND_SEPARATOR_TX) # type: ignore[union-attr] except (ConnectionError, AttributeError): self._close() return False return True def fade_dim( self, intensity: float, fade_time: float, delay_time: float, addr: str ) -> None: """Change the brightness of a light. Intensity, fade_time and delay_time are rounded because some controllers don't accept decimals. """ self._send( "FADEDIM, " f"{round(intensity)}, {round(fade_time)}, {round(delay_time)}, {addr}" ) def request_dimmer_level(self, addr: str) -> None: """Request the controller to return brightness.""" self._send(f"RDL, {addr}") def run(self) -> None: """Read and dispatch messages from the controller.""" self._running = True buffer = b"" while self._running: # pylint: disable=too-many-nested-blocks if self._socket is None: with suppress(exceptions.HomeworksException): self._connect(True) else: try: buffer += self._read() while True: (command, separator, remainder) = buffer.partition( self.COMMAND_SEPARATOR_RX ) if separator != self.COMMAND_SEPARATOR_RX: break buffer = remainder while buffer.startswith(self.LINE_ENDING_CHARACTERS): buffer = buffer[1:] if not command: continue self._process_received_data(command) except ( ConnectionError, AttributeError, exceptions.HomeworksConnectionLost, ): _LOGGER.warning("Lost connection.") self._close() buffer = b"" if self._running: time.sleep(self.POLLING_FREQ) self._running = False self._close() def _process_received_data(self, data_b: bytes) -> None: _LOGGER.debug("Raw: %s", data_b) try: data = data_b.decode("utf-8") except UnicodeDecodeError: _LOGGER.warning("Invalid data: %s", data_b) return if data in IGNORED: return try: raw_args = data.split(", ") action = ACTIONS.get(raw_args[0], None) if action and len(raw_args) == len(action): args = [parser(arg) for parser, arg in zip(action[1:], raw_args[1:])] self._callback(action[0], args) else: _LOGGER.warning("Not handling: %s", raw_args) except ValueError: _LOGGER.warning("Weird data: %s", data) def close(self) -> None: """Close the connection to the controller.""" if self._running: raise exceptions.HomeworksException( "Can't call close when thread is running" ) self._close() def _close(self) -> None: """Close the connection to the controller.""" if self._socket: self._socket.close() self._socket = None def stop(self) -> None: """Wait for the worker thread to stop.""" self._running = False self.join() def _subscribe(self) -> None: # Setup interface and subscribe to events self._send("PROMPTOFF") # No prompt is needed self._send("KBMON") # Monitor keypad events self._send("GSMON") # Monitor GRAFIKEYE scenes self._send("DLMON") # Monitor dimmer levels self._send("KLMON") # Monitor keypad LED states def _format_credentials(username: str | None, password: str | None) -> str | None: """Return a credential string from username and password.""" if password is not None and username is None: raise exceptions.HomeworksInvalidCredentialsProvided( "Username must be provided if password is not None" ) if password is not None: return f"{username}, {password}" if username is not None: return username return None dubnom-pyhomeworks-932bfd9/pyproject.toml000066400000000000000000000016241466056560700207450ustar00rootroot00000000000000[build-system] requires = ["setuptools~=69.2.0", "wheel~=0.43.0"] build-backend = "setuptools.build_meta" [project] name = "pyhomeworks" version = "1.1.2" authors = [ {name = "Michael Dubno", email = "michael@dubno.com"}, ] license = {text = "MIT"} description = "Lutron Homeworks Series 4 and 8 interface over Ethernet" readme = "README.md" requires-python = ">=3.12.0" classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] [project.urls] "Homepage" = "https://github.com/dubnom/pyhomeworks" [tool.setuptools] platforms = ["any"] zip-safe = true include-package-data = true [tool.setuptools.packages.find] include = ["pyhomeworks*"] [tool.setuptools.package-data] "*" = ["py.typed"] [tool.pylint."MESSAGES CONTROL"] # Reasons disabled: # format - handled by black disable = [ "format", ] dubnom-pyhomeworks-932bfd9/requirements-test.txt000066400000000000000000000001171466056560700222660ustar00rootroot00000000000000black==24.8.0 flake8==7.1.1 flake8-bugbear==24.4.26 mypy==1.11.1 pylint==3.2.6 dubnom-pyhomeworks-932bfd9/setup.cfg000066400000000000000000000001471466056560700176510ustar00rootroot00000000000000[flake8] # To work with Black max-line-length = 80 extend-select = B950 extend-ignore = E203,E501,E701