diff --git a/.gitignore b/.gitignore index 3d638fc..b35a678 100644 --- a/.gitignore +++ b/.gitignore @@ -130,3 +130,6 @@ dmypy.json # VS Code Settings / Launch files .vscode/ + +# Temp directory +.tmp/ diff --git a/setup.cfg b/setup.cfg index f8832cf..8ff3e11 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,4 +19,7 @@ python_requires = >=3.6 install_requires = aiohttp>=3.6 certifi>=2019.9.11 - pubnub>=5.0.1 \ No newline at end of file + pubnub>=5.0.1 + +[options.package_data] +vivintpy = zjs_device_config_db.json \ No newline at end of file diff --git a/setup.py b/setup.py index b4e6055..35cda83 100644 --- a/setup.py +++ b/setup.py @@ -1,4 +1,11 @@ """vivintpy setup script.""" +import os + import setuptools +file_name = "vivintpy/zjs_device_config_db.json" + +with open(file_name, "w") as file: + pass + setuptools.setup() diff --git a/vivintpy/devices/__init__.py b/vivintpy/devices/__init__.py index a78cc0d..ffcd033 100644 --- a/vivintpy/devices/__init__.py +++ b/vivintpy/devices/__init__.py @@ -1,14 +1,13 @@ """This package contains the various devices attached to a Vivint system.""" from __future__ import annotations -import asyncio -import concurrent.futures from typing import TYPE_CHECKING, Callable, Dict, List, Optional from ..const import VivintDeviceAttribute as Attribute from ..entity import Entity from ..enums import CapabilityCategoryType, CapabilityType from ..vivintskyapi import VivintSkyApi +from ..zjs_device_config_db import get_zwave_device_info if TYPE_CHECKING: from .alarm_panel import AlarmPanel @@ -130,19 +129,27 @@ def get_zwave_details(self): if self.data.get("zpd") is None: return None - pool = concurrent.futures.ThreadPoolExecutor() - result = pool.submit(asyncio.run, self.get_zwave_details_async()).result() - return result - - async def get_zwave_details_async(self): - manufacturer_id = f"{self.data.get('manid'):04x}" - product_id = f"{self.data.get('prid'):04x}" - product_type_id = f"{self.data.get('prtid'):04x}" - result = await self.vivintskyapi.get_zwave_details( - manufacturer_id, product_id, product_type_id + result = get_zwave_device_info( + self.data.get("manid"), + self.data.get("prtid"), + self.data.get("prid"), ) - [self._manufacturer, self._model] = result - return result + + self._manufacturer = result.get("manufacturer", "Unknown") + + label = result.get("label") + description = result.get("description") + + if label and description: + self._model = f"{description} ({label})" + elif label: + self._model = label + elif description: + self._model = description + else: + self._model = "Unknown" + + return [self._manufacturer, self._model] class UnknownDevice(VivintDevice): diff --git a/vivintpy/vivintskyapi.py b/vivintpy/vivintskyapi.py index 40e637d..6bf0066 100644 --- a/vivintpy/vivintskyapi.py +++ b/vivintpy/vivintskyapi.py @@ -387,30 +387,3 @@ async def __call( data=data, allow_redirects=allow_redirects, ) - - async def get_zwave_details(self, manufacturer_id, product_id, product_type_id): - """Gets the zwave details by looking up the details on the openzwave device database.""" - UNKNOWN_RESULT = ["Unknown", "Unknown"] - - # zwave_lookup = f"{manufacturer_id}:{product_id}:{product_type_id}" - # device_info = self.__zwave_device_info.get(zwave_lookup) - # if device_info is not None: - # return device_info - - # async with aiohttp.ClientSession() as session: - # async with session.get( - # url=f"http://openzwave.net/device-database/{zwave_lookup}" - # ) as response: - # if response.status == 200: - # text = await response.text() - # title = re.search("(.*)", text, re.IGNORECASE)[1] - # result = self.__zwave_device_info[zwave_lookup] = ( - # UNKNOWN_RESULT - # if title == "Device Database" - # else title.split(" - ") - # ) - # return result - # else: - # response.raise_for_status() - # return UNKNOWN_RESULT - return UNKNOWN_RESULT diff --git a/vivintpy/zjs_device_config_db.json b/vivintpy/zjs_device_config_db.json new file mode 100644 index 0000000..e69de29 diff --git a/vivintpy/zjs_device_config_db.py b/vivintpy/zjs_device_config_db.py new file mode 100644 index 0000000..773f580 --- /dev/null +++ b/vivintpy/zjs_device_config_db.py @@ -0,0 +1,158 @@ +import asyncio +import concurrent.futures +import json +import logging +import os +import re +import shutil +import tarfile +import threading +from pathlib import Path + +import aiohttp +import async_timeout + +_LOGGER = logging.getLogger(__name__) + +TMP_DIR = os.path.join(os.path.dirname(__file__), "./.tmp/") +ZJS_TAR_FILE = os.path.join(TMP_DIR, "zjs.tar.gz") +ZJS_TAR_URL = "http://github.com/zwave-js/node-zwave-js/archive/master.tar.gz" +ZJS_TAR_CONFIG_BASE = "node-zwave-js-master/packages/config/config/" +ZJS_DEVICE_CONFIG_DB_FILE = os.path.join( + os.path.dirname(__file__), "zjs_device_config_db.json" +) + +__MUTEX = threading.Lock() + + +def get_zwave_device_info( + manufacturer_id: int, product_type: int, product_id: int +) -> dict: + """Lookup the Z-Wave device based on the manufacturer id, product type, and product id""" + key = f"0x{manufacturer_id:04x}:0x{product_type:04x}:0x{product_id:04x}" + return get_zjs_device_config_db().get(key, {}) + + +def get_zjs_device_config_db() -> dict: + """Returns the Z-Wave JS device config db as a dict.""" + if not _device_config_db_file_exists(): + pool = concurrent.futures.ThreadPoolExecutor() + result = pool.submit(asyncio.run, download_zjs_device_config_db()).result() + return result + + return _load_db_from_file() + + +def _device_config_db_file_exists() -> bool: + """Returns True if the device config db file exists.""" + return ( + os.path.isfile(ZJS_DEVICE_CONFIG_DB_FILE) + and os.path.getsize(ZJS_DEVICE_CONFIG_DB_FILE) > 0 + ) + + +def _load_db_from_file() -> dict: + """Loads the Z-Wave JS device config from the saved JSON file.""" + data = {} + if _device_config_db_file_exists(): + with open(ZJS_DEVICE_CONFIG_DB_FILE) as f: + data = json.load(f) + return data + + +async def download_zjs_device_config_db(): + """Downloads the Z-Wave JS device config database.""" + with __MUTEX: + if not _device_config_db_file_exists(): + _LOGGER.info("Beginning download process") + _clean_temp_directory(create=True) + await _download_zjs_tarfile() + _extract_zjs_config_files() + data = _create_db_from_zjs_config_files() + _clean_temp_directory() + return data + else: + return _load_db_from_file() + + +def _clean_temp_directory(create: bool = False) -> None: + """Ensures the temp directory is empty and creates it if specified.""" + if os.path.exists(TMP_DIR): + _LOGGER.info("Removing temp directory") + shutil.rmtree(TMP_DIR) + + if create: + _LOGGER.info("Creating temp directory") + os.mkdir(TMP_DIR) + + +async def _download_zjs_tarfile() -> None: + """Downloads the Z-Wave JS tarfile from http://github.com/zwave-js/node-zwave-js.""" + _LOGGER.info("Downloading tarfile from Z-Wave JS") + async with aiohttp.ClientSession() as session: + async with async_timeout.timeout(120): + async with session.get(ZJS_TAR_URL) as response: + with open(ZJS_TAR_FILE, "wb") as file: + async for data in response.content.iter_chunked(1024): + file.write(data) + + +def _extract_zjs_config_files() -> None: + """Extracts the Z-Wave JSON config files.""" + manufacturers_path = "".join([ZJS_TAR_CONFIG_BASE, "manufacturers.json"]) + devices_path = "".join([ZJS_TAR_CONFIG_BASE, "devices/"]) + + def members(tf): + l = len(ZJS_TAR_CONFIG_BASE) + for member in tf.getmembers(): + if member.path.startswith(manufacturers_path) or ( + member.path.startswith(devices_path) + and member.path.endswith(".json") + and "/templates/" not in member.path + ): + member.path = member.path[l:] + yield member + + _LOGGER.info("Extracting config files from download") + with tarfile.open(ZJS_TAR_FILE) as tar: + tar.extractall(members=members(tar), path=TMP_DIR) + + +def _create_db_from_zjs_config_files() -> dict: + """Parses the Z-Wave JSON config files and creates a consolidated device db.""" + _LOGGER.info("Parsing extracted config files") + json_files = Path(os.path.join(TMP_DIR, "devices")).glob("**/*.json") + + device_db = {} + + for file in json_files: + data = {} + try: + with open(file) as json_file: + json_string = "".join( + re.sub("(([^:]|^)//[^a-zA-Z\d:].*)|(/\*.*\*/)", "", line) + for line in json_file.readlines() + ) + data = json.loads(json_string) + except: + print("oops, couldn't parse file %s", file) + + manufacturer_id = data.get("manufacturerId") + manufacturer = data.get("manufacturer") + label = data.get("label") + description = data.get("description") + + for device in data.get("devices", []): + product_type = device.get("productType") + product_id = device.get("productId") + device_db[f"{manufacturer_id}:{product_type}:{product_id}"] = { + "manufacturer": manufacturer, + "label": label, + "description": description, + } + + _LOGGER.info("Creating consolidated device db") + with open(ZJS_DEVICE_CONFIG_DB_FILE, "w") as device_file: + device_file.write(json.dumps(device_db)) + + return device_db