diff --git a/.eslintrc b/.eslintrc index d405095..35a1bc7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -6,13 +6,19 @@ "plugin:@typescript-eslint/recommended" // uses the recommended rules from the @typescript-eslint/eslint-plugin ], "parserOptions": { - "ecmaVersion": 2018, + "ecmaVersion": 2022, "sourceType": "module" }, "ignorePatterns": [ "dist" ], "rules": { + "no-restricted-imports": ["error", { "patterns": [{ + "group": ["./scripts/*"], + "message": "Scripts should not be imported in the main codebase. They are only meant to be run from the command line." + }] + }], + "quotes": ["warn", "single"], "indent": ["warn", 2, { "SwitchCase": 1 }], "semi": ["off"], diff --git a/.homebridge/config.json b/.homebridge/config.json deleted file mode 100644 index 027b635..0000000 --- a/.homebridge/config.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "bridge": { - "name": "Homebridge DEV", - "username": "CC:22:3D:E4:CE:33", - "manufacturer": "homebridge.io", - "model": "homebridge", - "port": 51826, - "pin": "031-45-154" - }, - - "description": "This is an example configuration file with one fake accessory and one fake platform. You can use this as a template for creating your own configuration file containing devices you actually own.", - "ports": { - "start": 52100, - "end": 52150, - "comment": "This section is used to control the range of ports that separate accessory (like camera or television) should be bind to." - }, - "accessories": [ - ], - - "platforms": [ - { - "platform": "Smartglass", - "devices": [ - { - "name": "Xbox Series S", - "ipaddress": "192.168.2.9", - "liveid": "F000000000000000", - "inputs": [ - { - "name": "Twitch", - "aum_id": "TwitchInteractive.TwitchApp_7kd9w9e3c5jra!Twitch", - "title_id": "442736763" - }, - { - "name": "Spotify", - "aum_id": "SpotifyAB.SpotifyMusic-forXbox_zpdnekdrzrea0!App", - "title_id": "1693425033" - }, - { - "name": "Youtube", - "aum_id": "GoogleInc.YouTube_yfg5n0ztvskxp!App", - "title_id": "122001257" - }, - { - "name": "Destiny 2", - "aum_id": "Bungie.Destiny2basegame_8xb1a0vv8ay84!tiger.ReleaseFinal", - "title_id": "144389848" - } - ] - } - ] - } - ] - } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b76c7c9..dedab18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ -# 2.0.2 +# 3.0.0 -- Fixed plugin to work with newer versions of homebridge. -- Fixed order of Input Sources -- Added additional logs and error handling \ No newline at end of file +This release has **braking changes**. +Please read through the plugins documentation and update your configuration accordingly. + +### Config Changes + +- `ifname` is now `macaddress` and should be the mac address of the homebridge server +- changes in `devices` + - `showApps` (default: false) - show apps as input sources + - `apps` (default: []) - list of apps to show as input sources + - if none are provided, all apps are shown + - `tvType` for always on tvs added + - `default` normal tv + - `fakeSleep` tv with always on where fakeSleep property is used + - `pictureSettings` tv with always on where sleep needs to be detected through the tvs picture settings + - if `tvType` is `pictureSettings` the following properties are required + - `menuId` - the picture setting to check for sleep + - `menuFlag` - the value of the picture settings to check for sleep + - Advanced Settings section: + - `pollingInterval` (default: 4) - seconds to wait between polling the tv for the on/off state + - if you have a tv that is always on, polling is disabled and changing this doesn't do anything + - `wolInterval` (default: 400) - milliseconds to wait between sending WoL packets + - `wolRetries` (default: 3) - number of times to send WoL packets to account for packet loss + + + +### Changed +- Instead of using the ifname to get the macaddress, users should now enter there macaddress directly +- Plugin now connects to mqtt directly while tv is online + - Connection will stay open as long as possible + - Input Source is now updated in real time + - Changing source with the home app works now most of the time +- WoL packets are now sent multiple times in the background + - I noticed that the tv sometimes doesn't wake up on the first packet as the packet got lost or the network interface of the tv has some issues +- Removed telnet dependency as it wasn't used anymore + + +### Added +- Support for showing apps as Input Sources + - showApps and apps config options +- Added support for always on tvs + - see tvType and the menuId and menuFlag properties +- Added `hisense-tv-authorize` and `hisense-tv-always-on-test` scripts to help with the setup +- Added additional config properties + - pollingInterval + - wolInterval + - wolRetries \ No newline at end of file diff --git a/README.md b/README.md index 78960c6..cd00f6a 100644 --- a/README.md +++ b/README.md @@ -9,20 +9,20 @@ This is a plugin for Homebridge that allows you to control your RemoteNow-enable - See the status of the TV (on/off, current input). - Turn on and off. - List inputs (using the display name set on the TV) and switch between them. +- List apps and launch them. - Control the TV volume. - Remote control using the native iOS remote. ## Requirements - NodeJS 18 or later. -- Homebridge 1.6.0 or later. -- Python 3.8 with [paho-mqtt](https://pypi.org/project/paho-mqtt/) version 1.6.1 and [netifaces](https://pypi.org/project/netifaces/). +- Homebridge 1.8.0 or later. - A Hisense TV that supports the RemoteNow app ([App Store](https://apps.apple.com/us/app/remotenow/id1301866548) or [Play Store](https://play.google.com/store/apps/details?id=com.universal.remote.ms&hl=en&gl=US)). - WakeOnLan (WOL) must be enabled on the TV to turn it on with this plugin. - The TV must be configured with a static IP Address or a static DHCP reservation -- *Starting with version 2.0.0, macOS is also supported as host*. ## Compatibility +If you have any issues please check the [Known Issues](#Known-Issues) section first, if your issue is not listed there, please open an issue on GitHub. ### TVs In theory, any RemoteNOW enabled TV should work with this plugin. However, some TVs have different behaviors, different SSL configurations and may not work completely or may require additional steps. @@ -44,95 +44,65 @@ homebridge-hisense-tv-remotenow ``` You also need some additional dependencies, if you haven't installed them already. Follow the instructions below for your operating system and then proceed to "Setting up the TV". -### Linux (including Hoobs) - -```bash -# for Linux distros with APT -apt install python3-paho-mqtt python3-netifaces - -# for any Linux distro, including Hoobs: -sudo su - homebridge -pip3 install netifaces -pip3 install "paho-mqtt==1.6.1" -``` - -### macOS / Windows - -```bash -pip3 install netifaces -pip3 install paho-mqtt -``` - ## Setting up the TV -First, identify the network interface your Homebridge machine uses to connect to the TV. Follow the instructions for your operating system below. Once you have the interface name, proceed to 'Continue the Setup'. -### Generic Linux -``` -ip a -``` - -*The name of a network interface usually looks similar to this: `ens33`.* - -### macOS - -``` -networksetup -listallhardwareports -``` - -*The name of a network interface usually looks similar to this: `en0`.* - -### Windows +On the machine where you are running homebridge identify the mac address of your network interface. +On Linux, you can use the `ifconfig` command, while on Windows, you can use `ipconfig`. +Often you can also find this information in the network settings of your operating system. -Run a Python shell on your system (you should be able to do so by running python or python3 without any parameters) and then typing: +The mac address is needed in the next step and in the config.json file. -``` -import netifaces -netifaces.interfaces() -``` +### Continue the Setup +For this plugin to work correctly, you need to configure your TV to use a static ip address (or configure a static DHCP reservation on your router). -*The name of a network interface usually looks similar to this: `{00000000-0000-0000-0000-000000000000}`.* +To connect to your TV, you need to pair the machine where you're running Homebridge with your TV. This is done in the command line, by manually running the bundled `hisense-tv-authorize` command. To do this, open the homebridge UI and go to Terminal. -To find the correct interface, use the following command in a python shell, repeating it using each network interface name until a matching IP address for the Homebridge host system is identified: +![terminal](images/terminal-location.png) + +Then **turn your TV on** and run one of the following commands, replacing `` with the IP address of your TV and `` with the mac address of the network interface you found out previously. +If this one fails, try the other commands. +SSLMode: default (most common) +```bash +hisense-tv-authorize --hostname --mac ``` -netifaces.ifaddresses('{interface-name-here}') -``` - -In order for the plugin to execute properly within Homebridge and retrieve the input names and TV status, you must also change the Local System Account associated with the Homebridge service using the following steps: +SSLMode: disabled (no SSL) +```bash +hisense-tv-authorize --hostname --mac --no-ssl ``` -Press the Windows Key + R -services.msc -Find the Homebridge Service and double-click -Switch to the "Log On" tab -Change "Local System Account" to "This Account" and enter your user name (usually .\username) --OR- -click "Browse..." and search for your username -Enter your login password in the Password fields -Press "Apply" and "OK" and restart the service if prompted -``` - -### Continue the Setup -For this plugin to work correctly, you need to configure your TV to use a static DHCP (or configure a static reservation on your router). You also need to find your TV's MAC Address: this is usually displayed under Settings > Network Information, but it might vary based on your model. - -To connect to your TV, you need to pair the machine where you're running Homebridge with your TV. This is done in the command line, by manually running the bundled `hisensetv.py` script. To do this, [find the `node_modules` folder in your system](https://docs.npmjs.com/cli/v7/configuring-npm/folders) (on Linux, it is located in `/usr/local/lib/node_modules` and on Windows, `/users/(username)/AppData/Roaming/npm/node_modules`) and move to `homebridge-hisense-tv/bin`, then run: +SSLMode: custom (use cert and key below) +Replace `` and `` with the path to the certificate and key files you want to use. +The certificate and key files most used can be found [here](https://github.com/MrAsterisco/hisensetv/tree/master/cert) ```bash -python3.8 hisensetv.py --authorize --ifname +hisense-tv-authorize --hostname --mac --certfile --keyfile ``` Your TV, if compatible, will display a PIN code: insert it in the command line and confirm. Your device is now paired with your TV and they can communicate when the TV is on. Repeat this step for all the TVs you want to use via HomeKit. -*If the command times-out after a while, make sure your TV is connected to the network and turned on. You can try to `telnet 36669` to make sure your Homebridge instance can reach your TV. If telnet works but this command doesn't, try to run a different command, such as `--get state` (just replace `authorize` with this in the command above): if this command succeeds, it means your TV and your machine are already paired.* +*If the command times-out after a while, make sure your TV is connected to the network and turned on. You can try to `telnet 36669` to make sure your Homebridge instance can reach your TV. If telnet works but this command doesn't, try to run the plugin without this step, it could mean your TV and your machine are already paired.* ## Configure the plugin -You can use the Homebridge UI to make changes to the plugin configuration. You must set the "Network interface name" to the name you found out previously and then configure your TVs (include the { } if configuring on Windows). Then, just add all the TVs you have authorized earlier: +You can use the Homebridge UI to make changes to the plugin configuration. You must set the "Homebridge MAC Address" to the mac address you found out previously and then configure your TVs. Then, just add all the TVs you have authorized earlier: - as ID, you can input your TV's S/N or your own identifier, as long as it's unique in your Home. You can also leave the default value, if you have just one TV. Whatever you input, will be displayed as the accessory "Serial Number" in Home. - as name, input the display name that the Home app will suggest when adding this TV to your Home. +- as Visible Apps, input `true` if you want to show your TV apps as input sources. +- as Apps, input the list of apps you want to show as input sources. If you leave this empty, all apps will be shown. +- as TV Type, input the type of TV you have. If your TV is always on, you can change this to `fakeSleep` or `pictureSettings`. Please check out [Always On TVs (TVs that aren't fully turning off)](#always-on-tvs-tvs-that-arent-fully-turning-off). + - if `tvType` is `pictureSettings` the following properties are required + - `menuId` - the picture setting to check for sleep + - `menuFlag` - the value of the picture settings to check for sleep - as IP address, input the IP that you have assigned to your TV. - as MAC Address, input the MAC Address of your TV (if your TV is connected both via WiFi and Ethernet, make sure to configure the interface that your TV is using). +- as SSLMode, input the SSL mode that your TV requires that you previously found out during activation. + +For each TV there are advanced settings that can be configured if tvType is `Default`: +- `pollingInterval` (default: 4) - seconds to wait between polling the tv for the on/off state +- `wolInterval` (default: 400) - milliseconds to wait between sending WoL packets +- `wolRetries` (default: 3) - number of times to send WoL packets to account for packet loss Repeat the configuration for each TV you want to use, then restart Homebridge. @@ -145,17 +115,24 @@ To change how the plugin connects to your TV, use the `sslmode` config key. See *If your TV needs a specific encryption key and certificate, you can find the most common ones [here](https://github.com/MrAsterisco/hisensetv/tree/master/cert). Choose the appropriate one and download it onto the machine that executes Homebridge.* *When providing the certificate and its key, you'll need to store them outside of the plugin folder (i.e. outside of the `node_modules` directory). If you store them in the directory, they will be deleted when a new version of the plugin is installed. It is not important where you store them, as long as they are readable by the `homebridge` user.* +On Linux, you can store them in `/etc/ssl/certs` and then provide the absolute path to the plugin. ### Config example ```json { "platform": "HiSenseTV", - "ifname": "ens33", + "macaddress": "Your Homebridge MAC Address", "devices": [ { "id": "A unique identifier (such as your TV S/N)", "name": "A name to display in the Home app", + "showApps": false, + "apps": [], + "tvType": "default", + "pollingInterval": 3, + "wolInterval": 400, + "wolRetries": 3, "ipaddress": "Your TV IP address", "macaddress": "Your TV MAC Address", "sslmode": "default (most common)|disabled (no SSL)|custom (use cert and key below)", @@ -166,6 +143,62 @@ To change how the plugin connects to your TV, use the `sslmode` config key. See } ``` +### Adding Apps as Input Sources +In the config, **showApps** can be set to `true` to enable showing Apps as Input Sources. +If you want all installed apps to be shown, you can leave the **apps** array empty. If you want to show only specific apps, you can list them in the **apps** array. The app names must match the names on the TV exactly. + +```json +{ + ... + "devices": [ + { + ... + "showApps": true, + "apps": ["Netflix", "YouTube"], + ... + } + ] +} +``` + +### Always On TVs (TVs that aren't fully turning off) +Some TVs may be shown as "On" even when you turn them off. If you have a TV that is always on, you can change the `tvType`. + +**tvType** may be changed to either: + +- fakeSleep +- pictureSettings + +This can be different for each TV, because the behavior is not consistent across all models. +To find out which one works for you, you can try running the `hisense-tv-always-on-test` command. (From the Homebridge Terminal) +The script will guide you through the process of finding the correct `tvType` for your TV. +If you find any issues with the script, please open an issue on GitHub, as I couldn't test that script with my TV. + +If the script suggest "Fake Sleep" you just have to change the `tvType` in the config file to `fakeSleep`: +Otherwise if its suggesting something with "Picture Settings" you have some more steps to do: + +#### Picture Settings +Picture Settings are literally the settings of the TV. Some always on TVs change specific settings when they are turned off. +In the config you can set the `menuId` and `menuFlag` to the values that are changed when the TV is turned off, this plugin will then also consider the TV as off. +For Example [this](https://github.com/MrAsterisco/homebridge-hisense-tv/issues/18#issuecomment-1247593321) User reported "HDMI Dynamic Range" (menu_id=23) gets set to **1** when the TV is turned off. +Which would mean the config needs to look like this: + +```json +{ + ... + "devices": [ + { + ... + "tvType": "pictureSettings", + "menuId": 23, + "menuFlag": 1, + ... + } + ] +} +``` + + ## Add the TV to Home Once Homebridge is ready, look for a log line in the Homebridge log that looks like this one: @@ -191,26 +224,12 @@ This plugin has been developed and tested running Homebridge on Ubuntu Linux 20. **If you find anything that is not correct, please open an issue (or even better: a PR changing this file) explaining what you're doing differently to make this plugin work with different TV models and/or on different operating systems.** ### Known Issues -- The input list might not be fetched correctly if the TV is turned off while adding the accessory or after restarting Homebridge. To fix this, force close your Home app and open it again. -- Switching input to "TV" might not work properly. Home will not display any error, but the next TV state refresh will bring the input back to the previous one (which is also the one displayed on the TV). -- Making changes to the TV state (turning on/off, changing input) while the Home app is opened will not trigger a live update. *This is theoretically supported by the plugin, but it seems to not work properly.*. Just switching to another app and then going back to Home will trigger a refresh. -- Some newer TV models are always reported as turned on: this happens because they still respond to requests, even if they're "off". *As I don't have a such a model to test, I am unfortunately unable to provide a fix: if you have some experience with Python, TypeScript and have some free time, take a look at [this issue](https://github.com/MrAsterisco/homebridge-hisense-tv/issues/18).* - -#### Hoobs - -Generally, the installation commands should work on [Hoobs](https://hoobs.com) too, however, please note that additional issues may arise when running on this machine, as I unfortunately don't have access to one and cannot test on it. - -I am happy to provide help and support in fixing those issues: just open an issue on this repo and we'll try to figure it out together. - -The following [issue](https://github.com/MrAsterisco/homebridge-hisense-tv/issues/46#issuecomment-1515465450) contains additional steps that might be required in your setup with Hoobs. - -#### Error when installing Netifaces -When you install `netifaces`, depending on your configuration, you may run into an error saying `fatal error: Python.h: No such file or directory`. The following commands should fix the issue by updating the setup tools to the latest version: -```bash -pip3 install --upgrade setuptools -sudo apk add python3-dev # for apk -sudo apt-get install python3-dev # for apt -``` +- The input list might not be fetched correctly if the TV is turned off while adding the accessory or after restarting Homebridge. + - FIX: force close your Home app and open it again a few times. +- Your TV gets shown as "ON" even when it's off. + - FIX: read Section [Always On TVs (TVs that aren't fully turning off)](#always-on-tvs-tvs-that-arent-fully-turning-off) +- Some TVs have inconsistent data regarding apps + - Due to the inconsistent data, the current selected app on the tv may not be shown correctly in homekit (will be "Unknown") # Contributions All contributions to expand the library are welcome. Fork the repo, make the changes you want, and open a Pull Request. @@ -218,7 +237,8 @@ All contributions to expand the library are welcome. Fork the repo, make the cha If you make changes to the codebase, I am not enforcing a coding style, but I may ask you to make changes based on how the rest of the library is made. # Credits -This plugin makes use of a modified version of the [hisensetv](https://github.com/newAM/hisensetv) Python script, originally written by [Alex](https://github.com/newAM) and distributed as open-source software [here](https://github.com/MrAsterisco/hisensetv). +This plugin made use of a modified version of the [hisensetv](https://github.com/newAM/hisensetv) Python script, originally written by [Alex](https://github.com/newAM) and distributed as open-source software [here](https://github.com/MrAsterisco/hisensetv). +Since version 3.0.0 the plugin uses a typescript native mqtt client. The code structure and style is heavily inspired by the [homebridge-smartglass plugin](https://github.com/unknownskl/homebridge-smartglass), written by [UnknownSKL](https://github.com/unknownskl). diff --git a/bin/hisensetv.py b/bin/hisensetv.py deleted file mode 100644 index 34140a3..0000000 --- a/bin/hisensetv.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python3 - -from hisensetv import __main__ - -if __name__ == '__main__': - __main__.main() diff --git a/bin/hisensetv/__init__.py b/bin/hisensetv/__init__.py deleted file mode 100755 index 08c136a..0000000 --- a/bin/hisensetv/__init__.py +++ /dev/null @@ -1,660 +0,0 @@ -import functools -import json -import logging -import posixpath -import queue -import ssl -import time -import uuid -from types import TracebackType -from typing import Any -from typing import Callable -from typing import Dict -from typing import Optional -from typing import Type -from typing import Union - -import netifaces -import paho.mqtt.client as mqtt - - -class HisenseTvError(Exception): - """ Base exception for all exceptions raise by this API. """ - - pass - - -class HisenseTvNotConnectedError(HisenseTvError): - """ Raised when an API function is called without a connection. """ - - pass - - -class HisenseTvAuthorizationError(HisenseTvError): - """ Raised upon authorization failures. """ - - pass - - -class HisenseTvTimeoutError(HisenseTvError): - """ Raised upon a failure to receive a response in the timeout period. """ - - pass - - -def _check_connected(func: Callable): - """ Raises :py:class:`HisenseTvNotConnectedError` if not connected. """ - - @functools.wraps(func) - def wrapper(self, *args, **kwargs): - if not self.connected: - raise HisenseTvNotConnectedError( - f"you must be connected to call {func.__name__}" - ) - return func(self, *args, **kwargs) - - return wrapper - - -def get_mac_address(ifname): - try: - interface = netifaces.ifaddresses(ifname) - return interface[netifaces.AF_LINK][0]["addr"] - except Exception: - raise HisenseTvError("Unknown network interface: " + ifname) - - -class HisenseTv: - """ - Hisense TV. - - Args: - hostname: TV hostname or IP. - port: Port of the MQTT broker on the TV, typically 36669. - username: - Username for the MQTT broker on the TV, - typically "hisenseservice". - password: - Password for the MQTT broker on the TV, - typically "multimqttservice". - timeout: Duration to wait for a response from the TV for API calls. - enable_client_logger: Enable MQTT client logging for debug. - ssl_context: - SSL context to utilize for the connection, - ``None`` to skip SSL usage (required for some models). - """ - - #: Services in the MQTT API. - _VALID_SERVICES = {"platform_service", "remote_service", "ui_service"} - - _BASE_TOPIC = posixpath.join("/", "remoteapp", "mobile") - _BROADCAST_TOPIC = posixpath.join(_BASE_TOPIC, "broadcast", "#") - - def __init__( - self, - hostname: str, - *, - port: int = 36669, - username: str = "hisenseservice", - password: str = "multimqttservice", - timeout: Union[int, float] = 10.0, - enable_client_logger: bool = False, - ssl_context: Optional[ssl.SSLContext] = None, - network_interface: str = "" - ): - self.logger = logging.getLogger(__name__) - self.hostname = hostname - self.port = port - self.username = username - self.password = password - self.timeout = timeout - self.enable_client_logger = enable_client_logger - self.client_id = f"{self.__class__.__name__}/{uuid.uuid4()!s}" - self.connected = False - self.ssl_context = ssl_context - - self._mac = get_mac_address(network_interface) - self.logger.info(f"Network interface MAC Address: {self._mac}") - - self._device_topic = f"{self._mac.upper()}$normal" - self._our_topic = posixpath.join(self._BASE_TOPIC, self._device_topic, "#") - self._queue = {self._BROADCAST_TOPIC: queue.Queue(), self._our_topic: queue.Queue()} - - def __enter__(self): - self._mqtt_client = mqtt.Client(self.client_id) - self._mqtt_client.username_pw_set( - username=self.username, password=self.password - ) - if self.ssl_context is not None: - self._mqtt_client.tls_set_context(context=self.ssl_context) - - self._mqtt_client.on_connect = self._on_connect - self._mqtt_client.on_message = self._on_message - if self.enable_client_logger: - self._mqtt_client.enable_logger() - - self._mqtt_client.connect(self.hostname, self.port) - - self._mqtt_client.loop_start() - - start_time = time.monotonic() - while not self.connected: - time.sleep(0.01) - if time.monotonic() - start_time > self.timeout: - raise HisenseTvTimeoutError(f"failed to connect in {self.timeout:.3f}s") - - return self - - def __exit__( - self, - exception_type: Optional[Type[BaseException]], - exception_value: Optional[BaseException], - traceback: Optional[TracebackType], - ) -> Optional[bool]: - self.connected = False - if isinstance(exception_value, Exception): - self._mqtt_client.disconnect() - self._mqtt_client.loop_stop() - - def _on_connect( - self, - client: mqtt.Client, - userdata: Optional[Any], - flags: Dict[str, int], - rc: int, - ): - """ Callback upon MQTT broker connection. """ - self.logger.debug(f"subscribing to {self._our_topic} and {self._BROADCAST_TOPIC}") - self._mqtt_client.subscribe(self._our_topic) - self._mqtt_client.subscribe(self._BROADCAST_TOPIC) - self.connected = True - - def _on_message( - self, - client: mqtt.Client, - userdata: Optional[Any], - msg: mqtt.MQTTMessage, - ): - """ Callback upon MQTT broker message on a subcribed topic. """ - if msg.payload: - try: - payload = msg.payload.decode("utf-8", errors="strict") - except UnicodeDecodeError: - self.logger.error(f"Payload is invalid UTF-8: {msg.payload!r}") - raise - - try: - payload = json.loads(payload) - except json.JSONDecodeError: - self.logger.error(f"Payload is invalid JSON: {payload!r}") - raise - - self.logger.debug( - f"Recieved message on topic {msg.topic} with payload: {payload}" - ) - else: - payload = msg.payload - - for queue_key in self._queue: - queue_name = queue_key.replace("/#", "") - if msg.topic.startswith(queue_name): - self._queue[queue_key].put_nowait(payload) - - - def _wait_for_response(self, topic) -> Optional[dict]: - """ Waits for the first response from the TV. """ - try: - return self._queue[topic].get(block=True, timeout=self.timeout) - except queue.Empty as e: - raise HisenseTvTimeoutError( - f"failed to recieve a response in {self.timeout:.3f}s" - ) from e - - def _call_service( - self, - *, - service: str, - action: str, - payload: Optional[Union[str, dict]] = None, - ): - """ - Calls a service on the TV API. - - Args: - service: "platform_service", remote_service", or "ui_service". - action: The action to send to the service. - payload: Payload to send. - """ - if service not in self._VALID_SERVICES: - raise ValueError( - f"service of {service!r} is invalid, service must be one of " - f"{self._VALID_SERVICES!r}" - ) - - if isinstance(payload, dict): - payload = json.dumps(payload) - - full_topic = posixpath.join( - "/", - "remoteapp", - "tv", - service, - self._device_topic, - "actions", - action, - ) - - msg = self._mqtt_client.publish(topic=full_topic, payload=payload) - msg.wait_for_publish() - - def send_key(self, keyname: str): - """ - Sends a keypress to the TV, as if it had been pressed on the IR remote. - - Args: - keyname: Name of the key press to send. - """ - self._call_service(service="remote_service", action="sendkey", payload=keyname) - - def _launch_app(self, app: str): - """ - Sends a launch command to the TV, as if it had been pressed on the - "remoteNOW" app. - - Args: - app: Name of the app to launch - """ - if app == "amazon": - launch = {"name": "Amazon", "urlType": 37, "storeType": 0, "url": "amazon"} - - elif app == "netflix": - launch = { - "name": "Netflix", - "urlType": 37, - "storeType": 0, - "url": "netflix", - } - - elif app == "youtube": - launch = { - "name": "YouTube", - "urlType": 37, - "storeType": 0, - "url": "youtube", - } - - else: - raise ValueError(f"{app} is not a known app.") - - self._call_service(service="ui_service", action="launchapp", payload=launch) - - def _change_source(self, id: str): - """ - Sets the source of the TV. - - Args: - id: id of the Source - """ - payload = {"sourceid": id} - self._call_service(service="ui_service", action="changesource", payload=payload) - - @_check_connected - def send_key_power(self): - """ Sends a keypress of the powerkey to the TV. """ - self.send_key("KEY_POWER") - - @_check_connected - def send_key_up(self): - """ Sends a keypress of the up key to the TV. """ - self.send_key("KEY_UP") - - @_check_connected - def send_key_down(self): - """ Sends a keypress of the down key to the TV. """ - self.send_key("KEY_DOWN") - - @_check_connected - def send_key_left(self): - """ Sends a keypress of the left key to the TV. """ - self.send_key("KEY_LEFT") - - @_check_connected - def send_key_right(self): - """ Sends a keypress of the right key to the TV. """ - self.send_key("KEY_RIGHT") - - @_check_connected - def send_key_menu(self): - """ Sends a keypress of the menu key to the TV. """ - self.send_key("KEY_MENU") - - @_check_connected - def send_key_back(self): - """ Sends a keypress of the back key to the TV. """ - self.send_key("KEY_RETURNS") - - @_check_connected - def send_key_exit(self): - """ Sends a keypress of the exit key to the TV. """ - self.send_key("KEY_EXIT") - - @_check_connected - def send_key_ok(self): - """ Sends a keypress of the OK key to the TV. """ - self.send_key("KEY_OK") - - @_check_connected - def send_key_volume_up(self): - """ Sends a keypress of the volume up key to the TV. """ - self.send_key("KEY_VOLUMEUP") - - @_check_connected - def send_key_volume_down(self): - """ Sends a keypress of the volume down key to the TV. """ - self.send_key("KEY_VOLUMEDOWN") - - @_check_connected - def send_key_channel_up(self): - """ Sends a keypress of the channel up key to the TV. """ - self.send_key("KEY_CHANNELUP") - - @_check_connected - def send_key_channel_down(self): - """ Sends a keypress of the channel down key to the TV. """ - self.send_key("KEY_CHANNELDOWN") - - @_check_connected - def send_key_fast_forward(self): - """ Sends a keypress of the fast forward key to the TV. """ - self.send_key("KEY_FORWARDS") - - @_check_connected - def send_key_rewind(self): - """ Sends a keypress of the rewind key to the TV. """ - self.send_key("KEY_BACK") - - @_check_connected - def send_key_stop(self): - """ Sends a keypress of the stop key to the TV. """ - self.send_key("KEY_STOP") - - @_check_connected - def send_key_play(self): - """ Sends a keypress of the play key to the TV. """ - self.send_key("KEY_PLAY") - - @_check_connected - def send_key_pause(self): - """ Sends a keypress of the pause key to the TV. """ - self.send_key("KEY_PAUSE") - - @_check_connected - def send_key_mute(self): - """ Sends a keypress of the mute key to the TV. """ - self.send_key("KEY_MUTE") - - @_check_connected - def send_key_home(self): - """ Sends a keypress of the home key to the TV. """ - self.send_key("KEY_HOME") - - @_check_connected - def send_key_subtitle(self): - """ Sends a keypress of the subtitle key to the TV. """ - self.send_key("KEY_SUBTITLE") - - @_check_connected - def send_key_netflix(self): - """ Sends a keypress of the Netflix key to the TV. """ - self._launch_app("netflix") - - @_check_connected - def send_key_youtube(self): - """ Sends a keypress of the YouTube key to the TV. """ - self._launch_app("youtube") - - @_check_connected - def send_key_amazon(self): - """ Sends a keypress of the Amazon key to the TV. """ - self._launch_app("amazon") - - @_check_connected - def send_key_0(self): - """ Sends a keypress of the 0 key to the TV. """ - self.send_key("KEY_0") - - @_check_connected - def send_key_1(self): - """ Sends a keypress of the 1 key to the TV. """ - self.send_key("KEY_1") - - @_check_connected - def send_key_2(self): - """ Sends a keypress of the 2 key to the TV. """ - self.send_key("KEY_2") - - @_check_connected - def send_key_3(self): - """ Sends a keypress of the 3 key to the TV. """ - self.send_key("KEY_3") - - @_check_connected - def send_key_4(self): - """ Sends a keypress of the 4 key to the TV. """ - self.send_key("KEY_4") - - @_check_connected - def send_key_5(self): - """ Sends a keypress of the 5 key to the TV. """ - self.send_key("KEY_5") - - @_check_connected - def send_key_6(self): - """ Sends a keypress of the 6 key to the TV. """ - self.send_key("KEY_6") - - @_check_connected - def send_key_7(self): - """ Sends a keypress of the 7 key to the TV. """ - self.send_key("KEY_7") - - @_check_connected - def send_key_8(self): - """ Sends a keypress of the 8 key to the TV. """ - self.send_key("KEY_8") - - @_check_connected - def send_key_9(self): - """ Sends a keypress of the 9 key to the TV. """ - self.send_key("KEY_9") - - @_check_connected - def send_key_source_0(self): - """ Sets TV to Input 0 """ - self._change_source("0") - - @_check_connected - def send_key_source_1(self): - """ Sets TV to Input 1 """ - self._change_source("1") - - @_check_connected - def send_key_source_2(self): - """ Sets TV to Input 2 """ - self._change_source("2") - - @_check_connected - def send_key_source_3(self): - """ Sets TV to Input 3 """ - self._change_source("3") - - @_check_connected - def send_key_source_4(self): - """ Sets TV to Input 4 """ - self._change_source("4") - - @_check_connected - def send_key_source_5(self): - """ Sets TV to Input 5 """ - self._change_source("5") - - @_check_connected - def send_key_source_6(self): - """ Sets TV to Input 6 """ - self._change_source("6") - - @_check_connected - def send_key_source_7(self): - """ Sets TV to Input 7 """ - self._change_source("7") - - @_check_connected - def get_sources(self) -> Optional[dict]: - """ - Gets the video sources from the TV. - - Returns: - List of source dictionaries. - - Example:: - - [ - { - "displayname": "TV", - "hotel_mode": "0", - "is_lock": "false", - "is_signal": "1", - "sourceid": "1", - "sourcename": "TV", - }, - { - "displayname": "HDMI 1", - "hotel_mode": "0", - "is_lock": "false", - "is_signal": "0", - "sourceid": "2", - "sourcename": "HDMI 1", - }, - { - "displayname": "HDMI 2", - "hotel_mode": "0", - "is_lock": "false", - "is_signal": "0", - "sourceid": "3", - "sourcename": "HDMI 2", - }, - { - "displayname": "HDMI 3", - "hotel_mode": "0", - "is_lock": "false", - "is_signal": "0", - "sourceid": "4", - "sourcename": "HDMI 3", - }, - { - "displayname": "PC", - "hotel_mode": "0", - "is_lock": "false", - "is_signal": "1", - "sourceid": "5", - "sourcename": "HDMI 4", - }, - { - "displayname": "Composite", - "hotel_mode": "0", - "is_lock": "false", - "is_signal": "0", - "sourceid": "6", - "sourcename": "Composite", - }, - ] - """ - self._call_service(service="ui_service", action="sourcelist") - return self._wait_for_response(topic=self._our_topic) - - @_check_connected - def set_source(self, sourceid: Union[int, str], sourcename: str): - """ - Sets the video source on the TV. - - Args: - sourceid: Numeric source identier. - sourcename: Human readable source name. - """ - sourceid = str(int(sourceid)) - self._call_service( - service="ui_service", - action="changesource", - payload={"sourceid": sourceid, "sourcename": sourcename}, - ) - - @_check_connected - def get_volume(self) -> dict: - """ - Gets the volume level on the TV. - - Returns: - Dictionary with keys for volume_type and volume_value. - - Example:: - - {"volume_type": 0, "volume_value": 0} - """ - self._call_service(service="platform_service", action="getvolume") - return self._wait_for_response(topic=self._BROADCAST_TOPIC) - - @_check_connected - def set_volume(self, volume: int): - """ - Sets the volume level on the TV. - - Args: - volume: Volume level from 0-100. - - Raises: - ValueError: Volume level is out of range. - """ - volume = int(volume) - if volume < 0 or volume > 100: - raise ValueError( - f"volume of {volume!r} is invalid, volume must be between 0 and 100" - ) - self._call_service( - service="platform_service", - action="changevolume", - payload=str(volume), - ) - - @_check_connected - def start_authorization(self): - """ Starts the authorization flow. """ - self._call_service(service="ui_service", action="gettvstate") - self._wait_for_response(topic=self._our_topic) - - @_check_connected - def send_authorization_code(self, code: Union[int, str]): - """ - Sends the authorization code to the TV. - - Args: - code: 4-digit code as displayed on the TV. - - Raises: - HisenseTvAuthorizationError: Failed to authenticate with the TV. - """ - self._call_service( - service="ui_service", - action="authenticationcode", - payload={"authNum": str(code)}, - ) - payload = self._wait_for_response(topic=self._our_topic) - result = int(payload["result"]) - if result != 1: - raise HisenseTvAuthorizationError( - f"authorization failed with code {result}" - ) - - @_check_connected - def get_state(self) -> dict: - self._call_service(service="ui_service", action="gettvstate") - return self._wait_for_response(topic=self._BROADCAST_TOPIC) diff --git a/bin/hisensetv/__main__.py b/bin/hisensetv/__main__.py deleted file mode 100644 index 042e5a0..0000000 --- a/bin/hisensetv/__main__.py +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env python3.8 - -import argparse -import json -import logging -import ssl - -from . import HisenseTv - - -def main(): - parser = argparse.ArgumentParser(description="Hisense TV control.") - parser.add_argument("hostname", type=str, help="Hostname or IP for the TV.") - parser.add_argument( - "--authorize", - action="store_true", - help="Authorize this API to access the TV.", - ) - parser.add_argument( - "--ifname", - type=str, - help="Name of the network interface to use", - default="" - ) - parser.add_argument( - "--get", - action="append", - default=[], - choices=["sources", "volume", "state"], - help="Gets a value from the TV.", - ) - parser.add_argument( - "--key", - action="append", - default=[], - choices=[ - "power", - "up", - "down", - "left", - "right", - "menu", - "back", - "exit", - "ok", - "volume_up", - "volume_down", - "channel_up", - "channel_down", - "fast_forward", - "rewind", - "stop", - "play", - "pause", - "mute", - "home", - "subtitle", - "netflix", - "youtube", - "amazon", - "0", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "source_0", - "source_1", - "source_2", - "source_3", - "source_4", - "source_5", - "source_6", - "source_7", - ], - help="Sends a keypress to the TV.", - ) - parser.add_argument( - "--no-ssl", - action="store_true", - help="Do not connect with SSL (required for some models).", - ) - parser.add_argument("--certfile", help="Absolute path to the .cer file (required for some models). " - "Works only when --keyfile is also specified. " - "Will be ignored if --no-ssl is specified.") - parser.add_argument("--keyfile", help="Absolute path to the .pkcs8 file (required for some models). " - "Works only when --certfile is also specified. " - "Will be ignored if --no-ssl is specified.") - parser.add_argument( - "-v", "--verbose", action="count", default=0, help="Logging verbosity." - ) - - args = parser.parse_args() - - if args.verbose: - level = logging.DEBUG - else: - level = logging.INFO - - root_logger = logging.getLogger() - stream_handler = logging.StreamHandler() - formatter = logging.Formatter( - fmt="[{asctime}] [{levelname:<8}] {message}", style="{" - ) - stream_handler.setFormatter(formatter) - root_logger.addHandler(stream_handler) - root_logger.setLevel(level) - logger = logging.getLogger(__name__) - - if args.no_ssl: - logger.info("No SSL context specified.") - ssl_context = None - elif args.certfile is not None and args.keyfile is not None: - ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - ssl_context.load_cert_chain(certfile=args.certfile, keyfile=args.keyfile) - ssl_context.check_hostname = False - ssl_context.verify_mode = ssl.CERT_NONE - logger.info("SSL context created with cert file (" + args.certfile + ") and private key (" + args.keyfile + ")") - else: - ssl_context = ssl._create_unverified_context() - logger.info("Unverified SSL context created.") - - tv = HisenseTv( - args.hostname, enable_client_logger=args.verbose >= 2, ssl_context=ssl_context, network_interface=args.ifname - ) - with tv: - if args.authorize: - tv.start_authorization() - code = input("Please enter the 4-digit code: ") - tv.send_authorization_code(code) - - for key in args.key: - func = getattr(tv, f"send_key_{key}") - logger.info(f"sending keypress: {key}") - func() - - for getter in args.get: - func = getattr(tv, f"get_{getter}") - output = func() - if isinstance(output, dict) or isinstance(output, list): - output = json.dumps(output, indent=4) - print(output) - - -if __name__ == "__main__": - main() diff --git a/config.schema.json b/config.schema.json index 7701197..b2c24b7 100644 --- a/config.schema.json +++ b/config.schema.json @@ -6,10 +6,11 @@ "schema": { "type": "object", "properties": { - "ifname": { - "title": "Network interface name", - "description": "This plugin must be able to authenticate with a valid MAC address to communicate with your TVs. Input a network interface name of your Homebridge machine.", + "macaddress": { + "title": "Homebridge MAC Address", + "description": "This plugin needs a MAC address to communicate with your TVs. Input the mac address of the network interface you're using to run Homebridge.", "type": "string", + "placeholder": "00:XX:00:XX:00:XX", "required": true }, "devices": { @@ -35,6 +36,99 @@ "default": "HiSense", "description": "The name of your TV. This will be used as default name in Home." }, + "showApps": { + "title": "Show Apps", + "type": "boolean", + "required": true, + "default": false, + "description": "Show the apps installed on the TV in the Home app as source input." + }, + "apps": { + "title": "Visible Apps", + "description": "If none are entered all installed apps are shown. Name must match exactly the name like the tv sends it over mqtt.", + "type": "array", + "items": { + "title": "App Name", + "type": "string" + } + }, + "tvType": { + "title": "TV Type (Always On)", + "type": "string", + "required": true, + "default": "default", + "oneOf": [ + { + "title": "Default (not always on)", + "enum": [ + "default" + ] + }, + { + "title": "Fake Sleep (always on)", + "enum": [ + "fakeSleep" + ] + }, + { + "title": "Picture Settings (always on)", + "enum": [ + "pictureSettings" + ] + } + ], + "description": "Change if your TV shows up as ON in the home app even when it's off. It means your TV is still reachable when it's off, and needs special handling. Polling is disabled when this option is enabled." + }, + "pictureSettings": { + "title": "Always On Mode (Picture Settings)", + "type": "object", + "condition": { + "functionBody": "return model.devices[arrayIndices].tvType === 'pictureSettings';" + }, + "properties": { + "menuId": { + "title": "Menu ID", + "type": "integer", + "required": true, + "default": "", + "description": "The picture settings menu id of the the item that will be checked if the TV is Off/On." + }, + "menuFlag": { + "title": "Menu Flag", + "type": "integer", + "required": true, + "default": "", + "description": "The picture settings menu flag which indicates that the TV is OFF." + } + } + }, + "pollingInterval": { + "title": "Polling Interval", + "type": "integer", + "required": true, + "default": 4, + "minimum": 1, + "maximum": 10, + "description": "The interval in seconds between each state check (state needs to be polled). The default value is 4 second. Increase this value if you want to reduce the network traffic." + }, + "wolInterval": { + "title": "WOL Interval", + "type": "integer", + "required": true, + "default": 400, + "minimum": 100, + "maximum": 1000, + "description": "The interval in seconds between each WOL packet sent. The default value is 400 milliseconds." + }, + "wolRetries": { + "title": "WOL Retries", + "type": "integer", + "required": true, + "default": 3, + "minimum": 1, + "maximum": 10, + "description": "The number of WOL packets sent continuously to wake up the tv to account for packet loss." + }, "ipaddress": { "title": "IP Address", "type": "string", @@ -55,9 +149,24 @@ "type": "string", "default": "default", "oneOf": [ - { "title": "Default", "enum": ["default"] }, - { "title": "Disabled", "enum": ["disabled"] }, - { "title": "Custom", "enum": ["custom"] } + { + "title": "Default", + "enum": [ + "default" + ] + }, + { + "title": "Disabled", + "enum": [ + "disabled" + ] + }, + { + "title": "Custom", + "enum": [ + "custom" + ] + } ], "required": true, "description": "Depending on your TV model, you might need to change the SSL mode. The Default mode uses a normal SSL context and works with TVs that have a valid certificate onboard; if your TV does not have a valid certificate, you can try to disable it. Certain models need specific certificates and private keys: in this case, select Custom and specify the URL to them, making sure they are reachable from the machine that is running Homebridge." @@ -80,5 +189,101 @@ } } } - } + }, + "form": [ + { + "key": "macaddress" + }, + { + "key": "devices", + "type": "tabarray", + "add": "New", + "remove": "Delete", + "style": { + "remove": "btn-danger" + }, + "title": "{{ value.name || 'TV' }}", + "items": [ + { + "key": "devices[].id" + }, + { + "key": "devices[].name" + }, + { + "key": "devices[].showApps" + }, + { + "key": "devices[].apps", + "type": "array", + "items": { + "title": "App Name", + "description": "Name must match exactly the name like the tv sends it over mqtt.", + "type": "string" + }, + "condition": { + "functionBody": "return model.devices[arrayIndices].showApps;" + } + }, + { + "key": "devices[].tvType" + }, + { + "key": "devices[].pictureSettings.menuId", + "condition": { + "functionBody": "return model.devices[arrayIndices].tvType === 'pictureSettings';" + } + }, + { + "key": "devices[].pictureSettings.menuFlag", + "condition": { + "functionBody": "return model.devices[arrayIndices].tvType === 'pictureSettings';" + } + }, + { + "key": "devices[].ipaddress" + }, + { + "key": "devices[].macaddress" + }, + { + "key": "devices[].sslmode" + }, + { + "key": "devices[].sslcertificate", + "condition": { + "functionBody": "return model.devices[arrayIndices].sslmode === 'custom';" + } + }, + { + "key": "devices[].sslprivatekey", + "condition": { + "functionBody": "return model.devices[arrayIndices].sslmode === 'custom';" + } + }, + { + "key": "devices[]", + "title": "Advanced Settings", + "orderable": false, + "type": "section", + "expandable": true, + "expanded": false, + "condition": { + "functionBody": "return model.devices[arrayIndices].tvType === 'default';" + }, + "items": [ + { + "key": "devices[].pollingInterval" + }, + { + "key": "devices[].wolInterval" + }, + { + "key": "devices[].wolRetries" + } + ] + } + ] + } + ] } \ No newline at end of file diff --git a/images/terminal-location.png b/images/terminal-location.png new file mode 100644 index 0000000..0d75caa Binary files /dev/null and b/images/terminal-location.png differ diff --git a/package-lock.json b/package-lock.json index 6b7c4b3..186183b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,37 +1,52 @@ { "name": "homebridge-hisense-tv-remotenow", - "version": "2.0.2-beta.1", + "version": "3.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "homebridge-hisense-tv-remotenow", - "version": "2.0.2-beta.1", + "version": "3.0.0", "license": "MIT", "dependencies": { - "python-shell": "^2.0.3", - "telnet-stream": "^1.0.5", + "fast-deep-equal": "^3.1.3", + "mqtt": "^5.8.1", "wol": "^1.0.7" }, + "bin": { + "hisense-tv-always-on-test": "dist/scripts/alwaysOnTest.js", + "hisense-tv-authorize": "dist/scripts/authorize.js" + }, "devDependencies": { - "@types/node": "^20.12.7", + "@types/node": "^20.14.10", "@types/wol": "^1.0.0", - "@typescript-eslint/eslint-plugin": "^7.7.0", - "@typescript-eslint/parser": "^7.7.0", + "@typescript-eslint/eslint-plugin": "^7.17.0", + "@typescript-eslint/parser": "^7.17.0", "eslint": "^8.57.0", - "homebridge": "^1.8.0", + "homebridge": "^1.8.4", "husky": "^9.0.11", "lint-staged": "^15.2.5", - "nodemon": "^3.1.0", - "rimraf": "^5.0.5", + "nodemon": "^3.1.4", + "rimraf": "^6.0.1", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.5.3" }, "engines": { - "homebridge": "^1.6.0", + "homebridge": "^1.8.0", "node": "^18.17.0 || ^20.9.0" } }, + "node_modules/@babel/runtime": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.6.tgz", + "integrity": "sha512-Ja18XcETdEl5mzzACGd+DKgaGJzPTCow7EglgwTmHdwokzDFYh/MHua6lU6DV/hjF2IaOJ4oX2nqnjG7RElKOw==", + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -101,15 +116,15 @@ } }, "node_modules/@homebridge/ciao": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.2.0.tgz", - "integrity": "sha512-2Qa8MVC7Q5DKH6iXh6cRvqz9VJYVpVZ+whHKrnr8YdPkXxc67kiQ9IOxMb0ydokDTETBVyXgr1m+HrheBtqDoQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@homebridge/ciao/-/ciao-1.3.0.tgz", + "integrity": "sha512-PRbY5FiukY13gIDwiAAZng598gMyI61GL9VU19G2CB4D3vNwh8vESgI9TkjeecCDethTqJmNiruYvdunfQkt7w==", "dev": true, "dependencies": { - "debug": "^4.3.4", + "debug": "^4.3.5", "fast-deep-equal": "^3.1.3", "source-map-support": "^0.5.21", - "tslib": "^2.6.2" + "tslib": "^2.6.3" }, "bin": { "ciao-bcs": "lib/bonjour-conformance-testing.js" @@ -358,14 +373,27 @@ "dev": true }, "node_modules/@types/node": { - "version": "20.12.14", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz", - "integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==", - "dev": true, + "version": "20.14.11", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.11.tgz", + "integrity": "sha512-kprQpL8MMeszbz6ojB5/tU8PLN4kesnN8Gjzw349rDlNgsSzg90lAVj3llK99Dh7JON+t9AuscPPFW6mPbTnSA==", "dependencies": { "undici-types": "~5.26.4" } }, + "node_modules/@types/readable-stream": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.14.tgz", + "integrity": "sha512-xZn/AuUbCMShGsqH/ehZtGDwQtbx00M9rZ2ENLe4tOjFZ/JFeWMhEZkk2fEe1jAUqqEAURIkFJ7Az/go8mM1/w==", + "dependencies": { + "@types/node": "*", + "safe-buffer": "~5.1.1" + } + }, + "node_modules/@types/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, "node_modules/@types/wol": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@types/wol/-/wol-1.0.0.tgz", @@ -375,17 +403,25 @@ "@types/node": "*" } }, + "node_modules/@types/ws": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz", + "integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.11.0.tgz", - "integrity": "sha512-P+qEahbgeHW4JQ/87FuItjBj8O3MYv5gELDzr8QaQ7fsll1gSMTYb6j87MYyxwf3DtD7uGFB9ShwgmCJB5KmaQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.17.0.tgz", + "integrity": "sha512-pyiDhEuLM3PuANxH7uNYan1AaFs5XE0zw1hq69JBvGvE7gSuEoQl1ydtEe/XQeoC3GQxLXyOVa5kNOATgM638A==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "7.11.0", - "@typescript-eslint/type-utils": "7.11.0", - "@typescript-eslint/utils": "7.11.0", - "@typescript-eslint/visitor-keys": "7.11.0", + "@typescript-eslint/scope-manager": "7.17.0", + "@typescript-eslint/type-utils": "7.17.0", + "@typescript-eslint/utils": "7.17.0", + "@typescript-eslint/visitor-keys": "7.17.0", "graphemer": "^1.4.0", "ignore": "^5.3.1", "natural-compare": "^1.4.0", @@ -409,15 +445,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.11.0.tgz", - "integrity": "sha512-yimw99teuaXVWsBcPO1Ais02kwJ1jmNA1KxE7ng0aT7ndr1pT1wqj0OJnsYVGKKlc4QJai86l/025L6z8CljOg==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.17.0.tgz", + "integrity": "sha512-puiYfGeg5Ydop8eusb/Hy1k7QmOU6X3nvsqCgzrB2K4qMavK//21+PzNE8qeECgNOIoertJPUC1SpegHDI515A==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.11.0", - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/typescript-estree": "7.11.0", - "@typescript-eslint/visitor-keys": "7.11.0", + "@typescript-eslint/scope-manager": "7.17.0", + "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/typescript-estree": "7.17.0", + "@typescript-eslint/visitor-keys": "7.17.0", "debug": "^4.3.4" }, "engines": { @@ -437,13 +473,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.11.0.tgz", - "integrity": "sha512-27tGdVEiutD4POirLZX4YzT180vevUURJl4wJGmm6TrQoiYwuxTIY98PBp6L2oN+JQxzE0URvYlzJaBHIekXAw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.17.0.tgz", + "integrity": "sha512-0P2jTTqyxWp9HiKLu/Vemr2Rg1Xb5B7uHItdVZ6iAenXmPo4SZ86yOPCJwMqpCyaMiEHTNqizHfsbmCFT1x9SA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/visitor-keys": "7.11.0" + "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/visitor-keys": "7.17.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -454,13 +490,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.11.0.tgz", - "integrity": "sha512-WmppUEgYy+y1NTseNMJ6mCFxt03/7jTOy08bcg7bxJJdsM4nuhnchyBbE8vryveaJUf62noH7LodPSo5Z0WUCg==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.17.0.tgz", + "integrity": "sha512-XD3aaBt+orgkM/7Cei0XNEm1vwUxQ958AOLALzPlbPqb8C1G8PZK85tND7Jpe69Wualri81PLU+Zc48GVKIMMA==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.11.0", - "@typescript-eslint/utils": "7.11.0", + "@typescript-eslint/typescript-estree": "7.17.0", + "@typescript-eslint/utils": "7.17.0", "debug": "^4.3.4", "ts-api-utils": "^1.3.0" }, @@ -481,9 +517,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.11.0.tgz", - "integrity": "sha512-MPEsDRZTyCiXkD4vd3zywDCifi7tatc4K37KqTprCvaXptP7Xlpdw0NR2hRJTetG5TxbWDB79Ys4kLmHliEo/w==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.17.0.tgz", + "integrity": "sha512-a29Ir0EbyKTKHnZWbNsrc/gqfIBqYPwj3F2M+jWE/9bqfEHg0AMtXzkbUkOG6QgEScxh2+Pz9OXe11jHDnHR7A==", "dev": true, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -494,13 +530,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.11.0.tgz", - "integrity": "sha512-cxkhZ2C/iyi3/6U9EPc5y+a6csqHItndvN/CzbNXTNrsC3/ASoYQZEt9uMaEp+xFNjasqQyszp5TumAVKKvJeQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.17.0.tgz", + "integrity": "sha512-72I3TGq93t2GoSBWI093wmKo0n6/b7O4j9o8U+f65TVD0FS6bI2180X5eGEr8MA8PhKMvYe9myZJquUT2JkCZw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/visitor-keys": "7.11.0", + "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/visitor-keys": "7.17.0", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -531,9 +567,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -546,15 +582,15 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.11.0.tgz", - "integrity": "sha512-xlAWwPleNRHwF37AhrZurOxA1wyXowW4PqVXZVUNCLjB48CqdPJoJWkrpH2nij9Q3Lb7rtWindtoXwxjxlKKCA==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.17.0.tgz", + "integrity": "sha512-r+JFlm5NdB+JXc7aWWZ3fKSm1gn0pkswEwIYsrGPdsT2GjsRATAKXiNtp3vgAAO1xZhX8alIOEQnNMl3kbTgJw==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "7.11.0", - "@typescript-eslint/types": "7.11.0", - "@typescript-eslint/typescript-estree": "7.11.0" + "@typescript-eslint/scope-manager": "7.17.0", + "@typescript-eslint/types": "7.17.0", + "@typescript-eslint/typescript-estree": "7.17.0" }, "engines": { "node": "^18.18.0 || >=20.0.0" @@ -568,12 +604,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.11.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.11.0.tgz", - "integrity": "sha512-7syYk4MzjxTEk0g/w3iqtgxnFQspDJfn6QKD36xMuuhTzjcxY7F8EmBLnALjVyaOF1/bVocu3bS/2/F7rXrveQ==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.17.0.tgz", + "integrity": "sha512-RVGC9UhPOCsfCdI9pU++K4nD7to+jTcMIbXTSOcrLqUEW6gF2pU1UUbYJKc9cvcRSK1UDeMJ7pdMxf4bhMpV/A==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.11.0", + "@typescript-eslint/types": "7.17.0", "eslint-visitor-keys": "^3.4.3" }, "engines": { @@ -596,6 +632,17 @@ "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", "dev": true }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/acorn": { "version": "8.11.3", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", @@ -720,9 +767,9 @@ } }, "node_modules/array-flatten": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-2.1.2.tgz", - "integrity": "sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==", "dev": true }, "node_modules/array-union": { @@ -755,6 +802,25 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -767,13 +833,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bl": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.0.12.tgz", + "integrity": "sha512-EnEYHilP93oaOa2MnmNEjAcovPS3JlQZOyzGXi3EyEpPhm9qWvdDp7BmAVEVusGzp8LlwQK56Av+OkDoRjzE0w==", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, "node_modules/bonjour-hap": { - "version": "3.7.2", - "resolved": "https://registry.npmjs.org/bonjour-hap/-/bonjour-hap-3.7.2.tgz", - "integrity": "sha512-BzOdOSIpXqjE1hejVNhj1T7E5YazPNG7cMOph5jQfzf1nF2yO18FSxuIg2zDMa4tFxhNC5d+U+0hT2bQkC5nTw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/bonjour-hap/-/bonjour-hap-3.8.0.tgz", + "integrity": "sha512-l/Ptvrt/pjN2pCgiVyyA0EkE0uVoXXYZ4DW4xhL4kDVBaw0w54/3Jhdhzn5EyT1Z8YhNXiNhSX0uW6xz2zSxqQ==", "dev": true, "dependencies": { - "array-flatten": "^2.1.2", + "array-flatten": "^3.0.0", "deep-equal": "^2.2.3", "multicast-dns": "^7.2.5", "multicast-dns-service-types": "^1.1.0" @@ -801,11 +878,33 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" }, "node_modules/call-bind": { "version": "1.0.7", @@ -1042,20 +1141,52 @@ "dev": true }, "node_modules/commander": { - "version": "12.0.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz", - "integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==", + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", "dev": true, "engines": { "node": ">=18" } }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -1080,7 +1211,6 @@ "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", - "dev": true, "dependencies": { "ms": "2.1.2" }, @@ -1450,12 +1580,28 @@ "through": "^2.3.8" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "dev": true }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", @@ -1482,8 +1628,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.3.2", @@ -1522,6 +1667,18 @@ "node": ">=10.17.0" } }, + "node_modules/fast-unique-numbers": { + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-8.0.13.tgz", + "integrity": "sha512-7OnTFAVPefgw2eBJ1xj2PGGR9FwYzSUso9decayHgCDX4sJkHLdcsYTytTg+tYv+wKF3U8gJuSBz2jJpQV4u/g==", + "dependencies": { + "@babel/runtime": "^7.23.8", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.1.0" + } + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -1638,9 +1795,9 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", "dev": true, "dependencies": { "cross-spawn": "^7.0.0", @@ -1764,22 +1921,23 @@ } }, "node_modules/glob": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz", - "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz", + "integrity": "sha512-9UiX/Bl6J2yaBbxKoEBRm4Cipxgok8kQYcOPEhScPwebu2I0HoQOuYdIO6S3hLuWoZgpDpwQZMzTFxgpkyT76g==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "jackspeak": "^4.0.1", + "minimatch": "^10.0.0", "minipass": "^7.1.2", - "path-scurry": "^1.11.1" + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1807,15 +1965,15 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.1.tgz", + "integrity": "sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -1881,18 +2039,18 @@ "dev": true }, "node_modules/hap-nodejs": { - "version": "0.12.1", - "resolved": "https://registry.npmjs.org/hap-nodejs/-/hap-nodejs-0.12.1.tgz", - "integrity": "sha512-iUUMaK6ucDKLMjT4m5Oz6CoLKkGg+omI6GR96weyL8fPGR1HYoCMtoJoUNW2NSIp4b2A6hx4zjNOEtLEaTA2MQ==", + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/hap-nodejs/-/hap-nodejs-0.12.2.tgz", + "integrity": "sha512-EAgcBOxxMaWKkRfuGc8FBVJO5/OCFM2jMt5+szkqazWLKANkRLrvf/GtQnPsl6GUuLiWYddLZ/zwasayU8ljQQ==", "dev": true, "dependencies": { "@homebridge/ciao": "^1.2.0", "@homebridge/dbus-native": "^0.6.0", "bonjour-hap": "^3.7.2", - "debug": "^4.3.4", + "debug": "^4.3.5", "fast-srp-hap": "^2.0.4", "futoin-hkdf": "^1.5.3", - "node-persist": "^0.0.11", + "node-persist": "^0.0.12", "source-map-support": "^0.5.21", "tslib": "^2.6.2", "tweetnacl": "^1.0.3" @@ -1982,6 +2140,11 @@ "node": ">= 0.4" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, "node_modules/hexy": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/hexy/-/hexy-0.3.5.tgz", @@ -1995,17 +2158,17 @@ } }, "node_modules/homebridge": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.8.2.tgz", - "integrity": "sha512-K0P9/qk3RdAKGLhGrmtF4skUjcygNlnBu0S/ssKIdp4p0kMzW2wjw2Q+z7TCxgZVy84/kaR09UD1n6uJAunTOQ==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/homebridge/-/homebridge-1.8.4.tgz", + "integrity": "sha512-Wr5QdI/OCpxCM3rdXyuPUxNlbuwiFngnuj+ZsrCbg5cMwKRMfN+RRGiW2LUSY2BDGQC0cSUt/9TF6tIlov16Qw==", "dev": true, "dependencies": { "chalk": "4.1.2", - "commander": "12.0.0", + "commander": "12.1.0", "fs-extra": "11.2.0", - "hap-nodejs": "0.12.1", + "hap-nodejs": "0.12.2", "qrcode-terminal": "0.12.0", - "semver": "7.6.2", + "semver": "7.6.3", "source-map-support": "0.5.21" }, "bin": { @@ -2039,6 +2202,25 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -2093,8 +2275,7 @@ "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/internal-slot": { "version": "1.0.7", @@ -2410,15 +2591,15 @@ "dev": true }, "node_modules/jackspeak": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz", - "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.0.1.tgz", + "integrity": "sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, "engines": { - "node": ">=14" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2427,6 +2608,15 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2542,15 +2732,6 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/lint-staged/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "engines": { - "node": ">=18" - } - }, "node_modules/listr2": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.1.tgz", @@ -2682,7 +2863,6 @@ "version": "10.2.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", - "dev": true, "engines": { "node": "14 || >=16.14" } @@ -2755,7 +2935,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2781,11 +2960,52 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mqtt": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.8.1.tgz", + "integrity": "sha512-EL5yY3yOdEBOyCTM41erawRxdWmGktc48eEGO74NpEBMUbTAPepo5Id4wi+/do85sACFfsycaURvoiCNxQRTHw==", + "dependencies": { + "@types/readable-stream": "^4.0.5", + "@types/ws": "^8.5.9", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.3.4", + "help-me": "^5.0.0", + "lru-cache": "^10.0.1", + "minimist": "^1.2.8", + "mqtt": "^5.2.0", + "mqtt-packet": "^9.0.0", + "number-allocator": "^1.0.14", + "readable-stream": "^4.4.2", + "reinterval": "^1.1.0", + "rfdc": "^1.3.0", + "split2": "^4.2.0", + "worker-timers": "^7.1.4", + "ws": "^8.17.1" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.0.tgz", + "integrity": "sha512-8v+HkX+fwbodsWAZIZTI074XIoxVBOmPeggQuDFCGg1SqNcC+uoRMWu7J6QlJPqIUIJXmjNYYHxBBLr1Y/Df4w==", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" }, "node_modules/multicast-dns": { "version": "7.2.5", @@ -2813,9 +3033,9 @@ "dev": true }, "node_modules/node-persist": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-0.0.11.tgz", - "integrity": "sha512-J3EPzQDgPxPBID7TqHSd5KkpTULFqJUvYDoISfOWg9EihpeVCH3b6YQeDeubzVuc4e6+aiVmkz2sdkWI4K+ghA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/node-persist/-/node-persist-0.0.12.tgz", + "integrity": "sha512-Fbia3FYnURzaql53wLu0t19dmAwQg/tXT6O7YPmdwNwysNKEyFmgoT2BQlPD3XXQnYeiQVNvR5lfvufGwKuxhg==", "dev": true, "dependencies": { "mkdirp": "~0.5.1", @@ -2823,9 +3043,9 @@ } }, "node_modules/nodemon": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.2.tgz", - "integrity": "sha512-/Ib/kloefDy+N0iRTxIUzyGcdW9lzlnca2Jsa5w73bs3npXjg+WInmiX6VY13mIb6SykkthYX/U5t0ukryGqBw==", + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz", + "integrity": "sha512-wjPBbFhtpJwmIeY2yP7QF+UKzPfltVGtfce1g/bB15/8vCGZj8uxD62b/b9M9/WVgme0NZudpownKN+c0plXlQ==", "dev": true, "dependencies": { "chokidar": "^3.5.2", @@ -2901,11 +3121,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", "dev": true, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -3024,6 +3256,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -3064,21 +3302,30 @@ } }, "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.0.tgz", + "integrity": "sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==", "dev": true, "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.0.0.tgz", + "integrity": "sha512-Qv32eSV1RSCfhY3fpPE2GNZ8jgM9X7rdAfemLWqTUxwiyIC4jJ6Sy0fZ8H+oLWevO6i4/bizg7c8d8i6bxrzbA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -3139,6 +3386,19 @@ "node": ">= 0.8.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", @@ -3154,18 +3414,11 @@ "node": ">=6" } }, - "node_modules/python-shell": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/python-shell/-/python-shell-2.0.3.tgz", - "integrity": "sha512-SBYQzXjexcxmmgzpjdIVxum9tj4Zaov1jNuSGMLssPxRhZ7lxqlpuEtLT0TEed0RAqhGfx2YFVC+imCJOmkVHg==", - "engines": { - "node": ">=0.10" - } - }, "node_modules/q": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/q/-/q-1.1.2.tgz", "integrity": "sha512-ROtylwux7Vkc4C07oKE/ReigUmb33kVoLtcR4SJ1QVqwaZkBEDL3vX4/kwFzIERQ5PfCl0XafbU8u2YUhyGgVA==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", "dev": true, "engines": { "node": ">=0.6.0", @@ -3201,6 +3454,21 @@ } ] }, + "node_modules/readable-stream": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.5.2.tgz", + "integrity": "sha512-yjavECdqeZ3GLXNgRXgeQEdz9fvDDkNKyHnbHRFtOr7/LcfgBcmct7t/ET+HaCTqfh06OzoAxrkN/IfjJBVe+g==", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -3213,6 +3481,11 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" + }, "node_modules/regexp.prototype.flags": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", @@ -3231,6 +3504,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reinterval": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reinterval/-/reinterval-1.1.0.tgz", + "integrity": "sha512-QIRet3SYrGp0HUHO88jVskiG6seqUGC5iAG7AwI/BV4ypGcuqk9Du6YQBUOUqm9c8pw1eyLoIaONifRua1lsEQ==" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3299,22 +3577,22 @@ "node_modules/rfdc": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.3.1.tgz", - "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==", - "dev": true + "integrity": "sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==" }, "node_modules/rimraf": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.7.tgz", - "integrity": "sha512-nV6YcJo5wbLW77m+8KjH8aB/7/rxQy9SZ0HY5shnwULfS+9nmTtVXAJET5NdZmCzA4fPI/Hm1wo/Po/4mopOdg==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz", + "integrity": "sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==", "dev": true, "dependencies": { - "glob": "^10.3.7" + "glob": "^11.0.0", + "package-json-from-dist": "^1.0.0" }, "bin": { "rimraf": "dist/esm/bin.mjs" }, "engines": { - "node": ">=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3347,7 +3625,6 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, "funding": [ { "type": "github", @@ -3370,9 +3647,9 @@ "dev": true }, "node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -3516,6 +3793,14 @@ "node": "*" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stop-iteration-iterator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.0.0.tgz", @@ -3538,6 +3823,14 @@ "through": "~2.3.4" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3673,11 +3966,6 @@ "node": ">=4" } }, - "node_modules/telnet-stream": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/telnet-stream/-/telnet-stream-1.0.5.tgz", - "integrity": "sha512-LAkCd4RHjAkTf87vYRzcTPNQ+O5nXQJDv1xiaJ0IVRJ1QgW2JvEFsp7R03M+T9fA7M7of+ylMX7vX4sMlMPMdA==" - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -3776,10 +4064,9 @@ } }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", - "dev": true + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, "node_modules/tweetnacl": { "version": "1.0.3", @@ -3811,10 +4098,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==" + }, "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -3833,8 +4125,7 @@ "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/universalify": { "version": "2.0.1", @@ -3854,6 +4145,11 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -3945,6 +4241,37 @@ "node": ">=0.10.0" } }, + "node_modules/worker-timers": { + "version": "7.1.8", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-7.1.8.tgz", + "integrity": "sha512-R54psRKYVLuzff7c1OTFcq/4Hue5Vlz4bFtNEIarpSiCYhpifHU3aIQI29S84o1j87ePCYqbmEJPqwBTf+3sfw==", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2", + "worker-timers-broker": "^6.1.8", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-broker": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-6.1.8.tgz", + "integrity": "sha512-FUCJu9jlK3A8WqLTKXM9E6kAmI/dR1vAJ8dHYLMisLNB/n3GuaFIjJ7pn16ZcD1zCOf7P6H62lWIEBi+yz/zQQ==", + "dependencies": { + "@babel/runtime": "^7.24.5", + "fast-unique-numbers": "^8.0.13", + "tslib": "^2.6.2", + "worker-timers-worker": "^7.0.71" + } + }, + "node_modules/worker-timers-worker": { + "version": "7.0.71", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-7.0.71.tgz", + "integrity": "sha512-ks/5YKwZsto1c2vmljroppOKCivB/ma97g9y77MAAz2TBBjPPgpoOiS1qYQKIgvGTr2QYPT3XhJWIB6Rj2MVPQ==", + "dependencies": { + "@babel/runtime": "^7.24.5", + "tslib": "^2.6.2" + } + }, "node_modules/wrap-ansi": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", @@ -4068,6 +4395,26 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xml2js": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", diff --git a/package.json b/package.json index b28c1de..a2c6c9d 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "displayName": "HiSense TV", "name": "homebridge-hisense-tv-remotenow", - "version": "2.0.2", + "version": "3.0.0", "description": "Control RemoteNow-enabled HiSense TVs.", "main": "dist/index.js", "license": "MIT", + "type": "module", "homepage": "https://github.com/MrAsterisco/homebridge-hisense-tv", "keywords": [ "homebridge", @@ -19,12 +20,15 @@ "bugs": { "url": "https://github.com/MrAsterisco/homebridge-hisense-tv/issues" }, + "bin": { + "hisense-tv-authorize": "dist/scripts/authorize.js", + "hisense-tv-always-on-test": "dist/scripts/alwaysOnTest.js" + }, "scripts": { - "lint": "npm run version && eslint src/**.ts --max-warnings=5 --fix", + "lint": "eslint src/**.ts --max-warnings=5 --fix", "watch": "npm run build && npm link && nodemon", - "build": "rimraf ./dist && npm run version && tsc", + "build": "rimraf ./dist && tsc && chmod +x dist/scripts/authorize.js && chmod +x dist/scripts/alwaysOnTest.js", "prepublishOnly": "npm run lint && npm run build", - "version": "cp package.json src/package-info.json", "prepare": "husky" }, "lint-staged": { @@ -34,25 +38,25 @@ }, "engines": { "node": "^18.17.0 || ^20.9.0", - "homebridge": "^1.6.0" + "homebridge": "^1.8.0" }, "dependencies": { - "python-shell": "^2.0.3", - "telnet-stream": "^1.0.5", + "fast-deep-equal": "^3.1.3", + "mqtt": "^5.8.1", "wol": "^1.0.7" }, "devDependencies": { - "@types/node": "^20.12.7", + "@types/node": "^20.14.10", "@types/wol": "^1.0.0", - "@typescript-eslint/eslint-plugin": "^7.7.0", - "@typescript-eslint/parser": "^7.7.0", + "@typescript-eslint/eslint-plugin": "^7.17.0", + "@typescript-eslint/parser": "^7.17.0", "eslint": "^8.57.0", - "homebridge": "^1.8.0", + "homebridge": "^1.8.4", "husky": "^9.0.11", "lint-staged": "^15.2.5", - "nodemon": "^3.1.0", - "rimraf": "^5.0.5", + "nodemon": "^3.1.4", + "rimraf": "^6.0.1", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.5.3" } } diff --git a/src/hisenseMQTTClient.ts b/src/hisenseMQTTClient.ts new file mode 100644 index 0000000..c678946 --- /dev/null +++ b/src/hisenseMQTTClient.ts @@ -0,0 +1,81 @@ +import * as mqtt from 'mqtt'; +import path from 'path'; +import {DeviceConfig} from './interfaces/device-config.interface.js'; +import fs from 'node:fs'; + +/** + * HisenseMQTTClient + * This class is used to interact with the MQTT server on the TV + * We also store all useful topics in this class + * + */ +export class HisenseMQTTClient { + public _BASE_TOPIC : string; + public _STATE_TOPIC: string; + public _DEVICE_TOPIC: string; + public _COMMUNICATION_TOPIC: string; + public _SOURCE_LIST_TOPIC : string; + public _APP_LIST_TOPIC : string; + public _PICTURE_SETTINGS_TOPIC : string; + + public mqttClient: mqtt.MqttClient; + + constructor(public deviceConfig: Pick, macaddress: string) { + this._BASE_TOPIC = path.join('/', 'remoteapp', 'mobile'); + this._STATE_TOPIC = path.join(this._BASE_TOPIC, 'broadcast', 'ui_service', 'state'); + this._DEVICE_TOPIC = `${macaddress.toUpperCase()}$normal`; + this._COMMUNICATION_TOPIC = path.join(this._BASE_TOPIC, this._DEVICE_TOPIC, 'ui_service', 'data'); + this._APP_LIST_TOPIC = path.join(this._COMMUNICATION_TOPIC, 'applist'); + this._SOURCE_LIST_TOPIC = path.join(this._COMMUNICATION_TOPIC, 'sourcelist'); + this._PICTURE_SETTINGS_TOPIC = path.join(this._BASE_TOPIC, 'broadcast', 'platform_service', 'data', 'picturesetting'); + + let key: Buffer|null = null; + let cert: Buffer|null = null; + + if(this.deviceConfig.sslmode === 'custom') { + key = fs.readFileSync(this.deviceConfig.sslprivatekey); + cert = fs.readFileSync(this.deviceConfig.sslcertificate); + } + + this.mqttClient = mqtt.connect({ + port: 36669, + host: this.deviceConfig.ipaddress, + key: key, + cert: cert, + username: 'hisenseservice', + password: 'multimqttservice', + rejectUnauthorized: false, + queueQoSZero: false, + protocol: this.deviceConfig.sslmode === 'disabled' ? 'mqtt' : 'mqtts', + } as mqtt.IClientOptions); + } + + public callService(service: string, action: string, payload?: string) { + const topic = path.join('/', 'remoteapp', 'tv', service, this._DEVICE_TOPIC, 'actions', action); + this.mqttClient.publish(topic, payload ?? ''); + } + + public changeSource(sourceId: string) { + this.callService('ui_service', 'changesource', JSON.stringify({'sourceid': sourceId})); + } + + /** + * Send a command to the TV to open an app + * Different TVs need different parameters (appId is mandatory for some for others it is not) + */ + public changeApp(name: string, appId: string, url: string, urlType: number|string, storeType: number) { + this.callService('ui_service', 'launchapp', JSON.stringify({'name': name, 'appId': appId, 'url': url, 'urlType': urlType, 'storeType': storeType})); + } + + public sendKey(key) { + this.callService('remote_service', 'sendkey', key); + } + + public subscribe(topic: string){ + this.mqttClient.subscribe(topic); + } + + public sendAuthCode(code: string) { + this.callService('ui_service', 'authenticationcode', JSON.stringify({'authNum': code})); + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 0f02391..7f3d5c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,11 @@ import { API } from 'homebridge'; -import { PLATFORM_NAME } from './settings'; -import { HiSenseTVPlatform } from './platform'; +import { PLATFORM_NAME } from './settings.js'; +import { HiSenseTVPlatform } from './platform.js'; /** * This method registers the platform with Homebridge */ -export = (api: API) => { +export default (api: API) => { api.registerPlatform(PLATFORM_NAME, HiSenseTVPlatform); }; diff --git a/src/inputSourceSubPlatformAccessory.ts b/src/inputSourceSubPlatformAccessory.ts new file mode 100644 index 0000000..138537b --- /dev/null +++ b/src/inputSourceSubPlatformAccessory.ts @@ -0,0 +1,67 @@ +import {Characteristic, CharacteristicValue, PlatformAccessory, Service} from 'homebridge'; +import {InputSource} from './interfaces/input-source.interface.js'; +import {TVApp} from './interfaces/tv-app.interface.js'; + +/** + * InputSourceSubPlatformAccessory + * Manages the creation of input sources for the TV accessory. + * These can be HDMI inputs or apps. + */ +export class InputSourceSubPlatformAccessory { + constructor(public service: typeof Service, public accessory: PlatformAccessory, public characteristic: typeof Characteristic ) { + } + + public createInputService(identifier: string){ + return this.accessory.getService(identifier) + || this.accessory.addService(this.service.InputSource, identifier, identifier); + } + + /** + * Is for creating a single input source that is unknown. + */ + public createUnknownSource(){ + const inputService = this.createInputService('inputhome'); + + this.setCharacteristics(inputService, 0, 'Unknown', 'Unknown', this.characteristic.InputSourceType.OTHER); + return inputService; + } + + public addTVInputSource(inputSource: InputSource, identifier: number) { + const inputService = this.createInputService('input' + inputSource.sourceid); + + const inputSourceType = this.getInputSourceTypeFromSourceName(inputSource.sourcename); + + this.setCharacteristics(inputService, identifier, inputSource.displayname, inputSource.sourcename, inputSourceType); + + return inputService; + } + + public addAppInputSource(app: TVApp, identifier: number) { + const inputService = this.createInputService('app' + app.name); + + this.setCharacteristics(inputService, identifier, app.name, app.name, this.characteristic.InputSourceType.APPLICATION); + return inputService; + } + + public getInputSourceTypeFromSourceName(sourceName: string){ + let inputType = this.characteristic.InputSourceType.OTHER; + if (sourceName === 'TV') { + inputType = this.characteristic.InputSourceType.TUNER; + } else if (sourceName === 'AV') { + inputType = this.characteristic.InputSourceType.COMPOSITE_VIDEO; + } else if (sourceName.startsWith('HDMI')) { + inputType = this.characteristic.InputSourceType.HDMI; + } + return inputType; + } + + public setCharacteristics(inputService: Service, identifier: number, configuredName: string, sourceName: string, inputType: CharacteristicValue){ + inputService + .setCharacteristic(this.characteristic.Identifier, identifier) + .setCharacteristic(this.characteristic.IsConfigured, this.characteristic.IsConfigured.CONFIGURED) + .setCharacteristic(this.characteristic.ConfiguredName, configuredName) + .setCharacteristic(this.characteristic.Name, sourceName) + .setCharacteristic(this.characteristic.CurrentVisibilityState, this.characteristic.CurrentVisibilityState.SHOWN) + .setCharacteristic(this.characteristic.InputSourceType, inputType); + } +} \ No newline at end of file diff --git a/src/interfaces/device-config.interface.ts b/src/interfaces/device-config.interface.ts new file mode 100644 index 0000000..b3c8531 --- /dev/null +++ b/src/interfaces/device-config.interface.ts @@ -0,0 +1,19 @@ +export interface DeviceConfig { + id: string; + tvType: 'default' | 'fakeSleep' | 'pictureSettings'; + pictureSettings?: { + menuId: number; + menuFlag: number; + }; + name: string; + pollingInterval: number; + wolInterval: number; + wolRetries: number; + ipaddress: string; + macaddress: string; + sslmode: 'disabled' | 'custom'; + sslcertificate: string; + sslprivatekey: string; + showApps: boolean; + apps?: Array; +} \ No newline at end of file diff --git a/src/interfaces/input-source.interface.ts b/src/interfaces/input-source.interface.ts new file mode 100644 index 0000000..2197e4e --- /dev/null +++ b/src/interfaces/input-source.interface.ts @@ -0,0 +1,5 @@ +export interface InputSource { + sourceid: string; + sourcename : string; + displayname : string; +} \ No newline at end of file diff --git a/src/interfaces/picturesetting.interface.ts b/src/interfaces/picturesetting.interface.ts new file mode 100644 index 0000000..3240af6 --- /dev/null +++ b/src/interfaces/picturesetting.interface.ts @@ -0,0 +1,7 @@ +export interface PictureSetting { + menu_info: Array<{ + menu_flag: number; + menu_name: string; + menu_id: number; + }>; +} \ No newline at end of file diff --git a/src/interfaces/tv-app.interface.ts b/src/interfaces/tv-app.interface.ts new file mode 100644 index 0000000..45d8574 --- /dev/null +++ b/src/interfaces/tv-app.interface.ts @@ -0,0 +1,11 @@ +import {Service} from 'homebridge'; + +export interface TVApp { + url: string; + isunInstalled: boolean; + name: string; + id: string; + urlType?: number|string; + storeType: number; + service?: Service; +} \ No newline at end of file diff --git a/src/interfaces/tv-state.interface.ts b/src/interfaces/tv-state.interface.ts new file mode 100644 index 0000000..1826fc1 --- /dev/null +++ b/src/interfaces/tv-state.interface.ts @@ -0,0 +1,21 @@ +export interface GenericTVState { + statetype: string; + + // this seems a bit weird but is needed + // we can't create a type like Exclude + // because it resolves back to string... + name: string; + sourcename: string; +} + +export interface TVStateApp { + statetype: 'app'; + name: string; +} + +export interface TVStateSource { + statetype: 'sourceswitch'; + sourcename: string; +} + +export type TVState = TVStateApp | TVStateSource | GenericTVState; \ No newline at end of file diff --git a/src/platform.ts b/src/platform.ts index b074db2..839f102 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -1,14 +1,8 @@ -import {API, DynamicPlatformPlugin, Logger, PlatformAccessory, PlatformConfig, Service, Characteristic, Categories} from 'homebridge'; +import {API, DynamicPlatformPlugin, PlatformAccessory, PlatformConfig, Service, Characteristic, Categories, Logging} from 'homebridge'; -import { PLUGIN_NAME } from './settings'; -import { HiSenseTVAccessory } from './platformAccessory'; - -interface DeviceConfig { - id: string; - name: string; - ipaddress: string; - macaddress: string; -} +import { PLUGIN_NAME } from './settings.js'; +import { HiSenseTVAccessory } from './platformAccessory.js'; +import {DeviceConfig} from './interfaces/device-config.interface.js'; /** * HomebridgePlatform @@ -16,17 +10,20 @@ interface DeviceConfig { * parse the user config and discover/register accessories with Homebridge. */ export class HiSenseTVPlatform implements DynamicPlatformPlugin { - public readonly Service: typeof Service = this.api.hap.Service; - public readonly Characteristic: typeof Characteristic = this.api.hap.Characteristic; + public readonly Service: typeof Service; + public readonly Characteristic: typeof Characteristic; // this is used to track restored cached accessories public readonly accessories: PlatformAccessory[] = []; constructor( - public readonly log: Logger, + public readonly log: Logging, public readonly config: PlatformConfig, public readonly api: API, ) { + this.Service = this.api.hap.Service; + this.Characteristic = this.api.hap.Characteristic; + this.log.debug('Finished initializing platform:', this.config.platform); // Homebridge 1.8.0 introduced a `log.success` method that can be used to log success messages @@ -58,9 +55,8 @@ export class HiSenseTVPlatform implements DynamicPlatformPlugin { } /** - * This is an example method showing how to register discovered accessories. - * Accessories must only be registered once, previously created accessories - * must not be registered again to prevent "duplicate UUID" errors. + * As we are only publishing external accessories, they don't get stored in the cache. + * So only the else is important here. */ discoverDevices() { const configDevices = this.config.devices as DeviceConfig[] || []; @@ -106,6 +102,7 @@ export class HiSenseTVPlatform implements DynamicPlatformPlugin { // store a copy of the device object in the `accessory.context` // the `context` property can be used to store any data about the accessory you may need accessory.context.device = device; + accessory.context.macaddress = this.config.macaddress; // create the accessory handler for the newly create accessory // this is imported from `platformAccessory.ts` diff --git a/src/platformAccessory.ts b/src/platformAccessory.ts index 9178712..7029b6c 100644 --- a/src/platformAccessory.ts +++ b/src/platformAccessory.ts @@ -1,11 +1,19 @@ -import {Characteristic, CharacteristicValue, PlatformAccessory, Service} from 'homebridge'; +import {Characteristic, CharacteristicValue, Logging, PlatformAccessory, Service} from 'homebridge'; -import {HiSenseTVPlatform} from './platform'; -import wol from 'wol'; -import {PythonShell} from 'python-shell'; +import {HiSenseTVPlatform} from './platform.js'; import net from 'net'; -import path from 'path'; +import {DeviceConfig} from './interfaces/device-config.interface.js'; +import {TVState} from './interfaces/tv-state.interface.js'; +import {InputSource} from './interfaces/input-source.interface.js'; +import {HisenseMQTTClient} from './hisenseMQTTClient.js'; +import equal from 'fast-deep-equal'; +import {PictureSetting} from './interfaces/picturesetting.interface.js'; +import {TVApp} from './interfaces/tv-app.interface.js'; +import {WoL} from './wol.js'; +import {sourcesAreEqual} from './utils/sourcesAreEqual.function.js'; +import {InputSourceSubPlatformAccessory} from './inputSourceSubPlatformAccessory.js'; +import {validateDeviceConfig} from './utils/validateDeviceConfig.function.js'; /** * Platform Accessory @@ -15,50 +23,87 @@ import path from 'path'; export class HiSenseTVAccessory { private Characteristic: typeof Characteristic; private Service: typeof Service; + private log: Logging; + + private wol: WoL; + private inputSourceSubPlatformAccessory: InputSourceSubPlatformAccessory; private service: Service; private speakerService: Service; + private mqttHelper: HisenseMQTTClient; + private deviceConfig: DeviceConfig; + + /** + * Counter to keep track of how many times the TV state has been checked and its not yet correct. + * + * When turning off the TV offCounter is set to -1, it means the TV should be off. + * at the same time onCounter is set to 0, and everytime the tv is still on in the polling phase, it will be incremented. + * if onCounter reaches 8, the TV will be considered on. (means the TV didn't turn off) + * + * The same applies for turning on the TV, but with onCounter set to -1 and increasing offCounter. + * + * This is done to prevent false positives/negatives when checking the TV state. + */ + private offCounter = 0; + private onCounter = 0; + + /** + * Counter threshold to determine if the TV is on or off. + * 8 seconds seems reasonable, as the TV should respond faster. + */ + private counterThreshold = 8; + private deviceState = { isConnected: false, hasFetchedInputs: false, currentSourceName: '', }; private inputSources: InputSource[] = []; + private availableApps: TVApp[] = []; constructor(private readonly platform: HiSenseTVPlatform, private readonly accessory: PlatformAccessory) { + this.log = platform.log; + + if (accessory.context.macaddress == null || accessory.context.macaddress == '') { + this.log.warn('Config not up to date, please check the README on https://github.com/MrAsterisco/homebridge-hisense-tv' + + ' for the latest configuration options or use the homebridge UI to update the configuration.'); + this.log.error('Homebridge MAC address is required for the TV accessory.'); + process.exit(1); + } + this.Characteristic = platform.Characteristic; this.Service = platform.Service; - // Start the asynchronous check of the TV status. - this.checkTVStatus(); + this.deviceConfig = validateDeviceConfig(accessory.context.device); + + // create useful subclasses + this.inputSourceSubPlatformAccessory = new InputSourceSubPlatformAccessory(this.Service, accessory, this.Characteristic); + this.wol = new WoL(this.log, this.deviceConfig.macaddress, this.deviceConfig.wolRetries, this.deviceConfig.wolInterval); // Configure the TV details. this.accessory.getService(this.Service.AccessoryInformation)! .setCharacteristic(this.Characteristic.Manufacturer, 'HiSense') .setCharacteristic(this.Characteristic.Model, 'TV') - .setCharacteristic(this.Characteristic.SerialNumber, accessory.context.device.id); + .setCharacteristic(this.Characteristic.SerialNumber, this.deviceConfig.id); // Create the service. this.service = this.accessory.getService(this.Service.Television) || this.accessory.addService(this.Service.Television); // Configure the service. this.service - .setCharacteristic(this.Characteristic.ConfiguredName, accessory.context.device.name) - .setCharacteristic(this.Characteristic.SleepDiscoveryMode, this.Characteristic.SleepDiscoveryMode.NOT_DISCOVERABLE); + .setCharacteristic(this.Characteristic.ConfiguredName, this.deviceConfig.name) + .setCharacteristic(this.Characteristic.SleepDiscoveryMode, this.Characteristic.SleepDiscoveryMode.NOT_DISCOVERABLE) + .setCharacteristic(this.Characteristic.Active, this.Characteristic.Active.INACTIVE); // Bind to events. this.service.getCharacteristic(this.Characteristic.Active) - .onSet(this.setOn.bind(this)) - .onGet(this.getOn.bind(this)); + .onSet(this.setOn.bind(this)); this.service.getCharacteristic(this.Characteristic.RemoteKey) .onSet(this.setRemoteKey.bind(this)); - this.service.setCharacteristic(this.Characteristic.ActiveIdentifier, 0); - this.service.getCharacteristic(this.Characteristic.ActiveIdentifier) - .onSet(this.setCurrentApplication.bind(this)) - .onGet(this.getCurrentApplication.bind(this)); + .onSet(this.setCurrentApplication.bind(this)); // Create the TV speaker service. this.speakerService = this.accessory.getService(this.Service.TelevisionSpeaker) || this.accessory.addService(this.Service.TelevisionSpeaker); @@ -68,6 +113,9 @@ export class HiSenseTVAccessory { .setCharacteristic(this.Characteristic.Active, 1) .setCharacteristic(this.Characteristic.VolumeControlType, this.Characteristic.VolumeControlType.RELATIVE); + this.speakerService.getCharacteristic(this.Characteristic.Mute) + .onSet(this.setMute.bind(this)); + // Bind to TV speaker events. this.speakerService .getCharacteristic(this.Characteristic.VolumeSelector) @@ -79,222 +127,269 @@ export class HiSenseTVAccessory { // Create "Unknown" source. this.createHomeSource(); - // Setup an interval to periodically check the TV status. - setInterval(() => { - this.checkTVStatus(); - }, 10000); + this.mqttHelper = new HisenseMQTTClient(this.deviceConfig, accessory.context.macaddress); + this.setupMqtt(); + + // set the counter threshold based on the polling interval + this.counterThreshold = Math.round(8 / this.deviceConfig.pollingInterval); + if (this.counterThreshold < 1) { + this.counterThreshold = 1; + } + + if (this.deviceConfig.tvType === 'default') { + setInterval(() => { + this.checkTVStatus(); + }, this.deviceConfig.pollingInterval * 1000); + } + } + + public setupMqtt() { + this.mqttHelper.mqttClient.on('connect', () => { + this.log.debug('Connected to MQTT service on TV.'); + + this.setTVPowerStateOn(); + + this.mqttHelper.subscribe(this.mqttHelper._SOURCE_LIST_TOPIC); + this.mqttHelper.subscribe(this.mqttHelper._APP_LIST_TOPIC); + this.mqttHelper.subscribe(this.mqttHelper._STATE_TOPIC); + if (this.deviceConfig.tvType === 'pictureSettings') { + this.mqttHelper.subscribe(this.mqttHelper._PICTURE_SETTINGS_TOPIC); + } + + // always fetch data when connection is established + this.mqttHelper.callService('ui_service', 'sourcelist'); + this.mqttHelper.callService('ui_service', 'gettvstate'); + this.mqttHelper.callService('ui_service', 'applist'); + }); + + + // + this.mqttHelper.mqttClient.on('message', (topic, message) => { + this.log.debug(`Received message from TV (${topic}):` + message.toString()); + const parsedMessage = JSON.parse(message.toString()); + switch (topic) { + case this.mqttHelper._STATE_TOPIC: + // handle tvType fakeSleep differently as it has a different state + if (this.deviceConfig.tvType === 'fakeSleep') { + // setCurrentInput will be called in setAlwaysOnFakeSleepState + this.setAlwaysOnFakeSleepState(parsedMessage); + } else { + this.setCurrentInput(parsedMessage); + } + break; + case this.mqttHelper._SOURCE_LIST_TOPIC: + this.createSources(parsedMessage, this.availableApps); + break; + case this.mqttHelper._PICTURE_SETTINGS_TOPIC: + this.setAlwaysOnPictureSettingsPowerState(parsedMessage); + break; + case this.mqttHelper._APP_LIST_TOPIC: + this.createSources(this.inputSources, parsedMessage); + break; + default: + this.log.debug('Received unknown message from TV. Topic: ' + topic + ' Message: ' + message.toString()); + break; + } + }); + + this.mqttHelper.mqttClient.on('close', () => { + this.log.debug('Closed connection to MQTT service on TV.'); + this.setTVPowerStateOff(); + }); + + this.mqttHelper.mqttClient.on('end', () => { + this.log.debug('Connection to MQTT service on TV ended.'); + this.setTVPowerStateOff(); + }); + + this.mqttHelper.mqttClient.on('disconnect', () => { + this.log.debug('Disconnected from MQTT service on TV.'); + this.setTVPowerStateOff(); + }); + + + this.mqttHelper.mqttClient.on('error', (err) => { + this.log.error('An error occurred while connecting to MQTT service: ' + JSON.stringify(err)); + this.setTVPowerStateOff(); + }); + } + + setTVPowerStateOff() { + this.service.updateCharacteristic(this.Characteristic.Active, this.Characteristic.Active.INACTIVE); + this.deviceState.isConnected = false; } - async getOn(): Promise { - return this.deviceState.isConnected; + setTVPowerStateOn() { + this.service.updateCharacteristic(this.Characteristic.Active, this.Characteristic.Active.ACTIVE); + this.deviceState.isConnected = true; } async setOn(value: CharacteristicValue) { - this.platform.log.debug('Set Characteristic On ->', value); + this.log.debug('Set Characteristic On ->', value); - if (value === 1) { - if (this.deviceState.isConnected) { - // The device is already turned on. Nothing to do - return; - } - await wol.wake(this.accessory.context.device.macaddress); - this.deviceState.isConnected = true; - this.service.updateCharacteristic(this.Characteristic.Active, this.Characteristic.Active.ACTIVE); + // only send wol if the TV is a normal TV and not an always on TV + if (value === 1 && this.deviceConfig.tvType === 'default') { + + this.wol.sendMagicPacket(); + this.onCounter = -1; + this.offCounter = 0; } else { - if (!this.deviceState.isConnected) { - // The device is already turned off. Nothing to do - return; - } - const [err, _] = await this.sendCommand(['--key', 'power']); - if (err) { - this.platform.log.error('An error occurred while turning off the TV: ' + err); - }else { - this.deviceState.isConnected = false; - this.service.updateCharacteristic(this.Characteristic.Active, this.Characteristic.Active.INACTIVE); + this.offCounter = -1; + this.onCounter = 0; + this.mqttHelper.sendKey('KEY_POWER'); + } + } + + /** + * Sets the power state of the TV based on the always on picture settings of the tv. + * These must be configured beforehand in the Homebridge config. + * There is an attached script to help with this. + * @param settings + */ + setAlwaysOnPictureSettingsPowerState(settings: PictureSetting) { + if (settings.menu_info) { + const alwaysOnSetting = settings.menu_info.find((setting) => setting.menu_id === this.deviceConfig.pictureSettings?.menuId); + if (alwaysOnSetting) { + if (alwaysOnSetting.menu_flag === this.deviceConfig.pictureSettings?.menuFlag) { + this.setTVPowerStateOff(); + } else { + this.setTVPowerStateOn(); + } } } } async setRemoteKey(newValue: CharacteristicValue) { let keyName = ''; - switch (newValue) { - case this.Characteristic.RemoteKey.REWIND: { - this.platform.log.debug('set Remote Key Pressed: REWIND'); - keyName = 'rewind'; - break; - } - case this.Characteristic.RemoteKey.FAST_FORWARD: { - this.platform.log.debug('set Remote Key Pressed: FAST_FORWARD'); - keyName = 'fast_forward'; - break; - } - case this.Characteristic.RemoteKey.NEXT_TRACK: { - this.platform.log.debug('unsupported Remote Key Pressed: NEXT_TRACK, ignoring.'); - return; - } - case this.Characteristic.RemoteKey.PREVIOUS_TRACK: { - this.platform.log.debug('unsupported Remote Key Pressed: PREVIOUS_TRACK, ignoring.'); - return; - } - case this.Characteristic.RemoteKey.ARROW_UP: { - this.platform.log.debug('set Remote Key Pressed: ARROW_UP'); - keyName = 'up'; - break; - } - case this.Characteristic.RemoteKey.ARROW_DOWN: { - this.platform.log.debug('set Remote Key Pressed: ARROW_DOWN'); - keyName = 'down'; - break; - } - case this.Characteristic.RemoteKey.ARROW_LEFT: { - this.platform.log.debug('set Remote Key Pressed: ARROW_LEFT'); - keyName = 'left'; - break; - } - case this.Characteristic.RemoteKey.ARROW_RIGHT: { - this.platform.log.debug('set Remote Key Pressed: ARROW_RIGHT'); - keyName = 'right'; - break; - } - case this.Characteristic.RemoteKey.SELECT: { - this.platform.log.debug('set Remote Key Pressed: SELECT'); - keyName = 'ok'; - break; - } - case this.Characteristic.RemoteKey.BACK: { - this.platform.log.debug('set Remote Key Pressed: BACK'); - keyName = 'back'; - break; - } - case this.Characteristic.RemoteKey.EXIT: { - this.platform.log.debug('set Remote Key Pressed: EXIT'); - keyName = 'exit'; - break; - } - case this.Characteristic.RemoteKey.PLAY_PAUSE: { - this.platform.log.debug('set Remote Key Pressed: PLAY_PAUSE'); - keyName = 'play'; - break; - } - case this.Characteristic.RemoteKey.INFORMATION: { - this.platform.log.debug('set Remote Key Pressed: INFORMATION'); - keyName = 'home'; - break; - } + + // shorter than a switch statement + const keyDict = { + [this.Characteristic.RemoteKey.REWIND]: 'KEY_BACK', + [this.Characteristic.RemoteKey.FAST_FORWARD]: 'KEY_FORWARDS', + [this.Characteristic.RemoteKey.NEXT_TRACK]: null, + [this.Characteristic.RemoteKey.PREVIOUS_TRACK]: null, + [this.Characteristic.RemoteKey.ARROW_UP]: 'KEY_UP', + [this.Characteristic.RemoteKey.ARROW_DOWN]: 'KEY_DOWN', + [this.Characteristic.RemoteKey.ARROW_LEFT]: 'KEY_LEFT', + [this.Characteristic.RemoteKey.ARROW_RIGHT]: 'KEY_RIGHT', + [this.Characteristic.RemoteKey.SELECT]: 'KEY_OK', + [this.Characteristic.RemoteKey.BACK]: 'KEY_BACK', + [this.Characteristic.RemoteKey.EXIT]: 'KEY_EXIT', + [this.Characteristic.RemoteKey.PLAY_PAUSE]: 'KEY_PLAY', + [this.Characteristic.RemoteKey.INFORMATION]: 'KEY_HOME', + }; + + keyName = keyDict[newValue as number]; + + if (keyName) { + this.mqttHelper.sendKey(keyName); + } else { + this.log.debug(`Key ${newValue} not supported.`); } + } - const [err] = await this.sendCommand(['--key', keyName]); - if (err) { - this.platform.log.error('An error occurred while sending the remote key: ' + err); - } + async setMute() { + this.mqttHelper.sendKey('KEY_MUTE'); } async setVolume(value: CharacteristicValue) { - this.platform.log.debug('setVolume called with: ' + value); + this.log.debug('setVolume called with: ' + value); if (value === 0) { - const [err] = await this.sendCommand(['--key', 'volume_up']); - if (err) { - this.platform.log.error('An error occurred while changing the volume: ' + err); - } + this.mqttHelper.sendKey('KEY_VOLUMEUP'); } else { - const [err] = await this.sendCommand(['--key', 'volume_down']); - if (err) { - this.platform.log.error('An error occurred while changing the volume: ' + err); - } + this.mqttHelper.sendKey('KEY_VOLUMEDOWN'); } } - async getCurrentApplication() { - return this.getCurrentInputIndex(); - } - async setCurrentApplication(value: CharacteristicValue) { - this.platform.log.debug('setCurrentApplication() invoked to ->', value); + this.log.debug('setCurrentApplication() invoked to ->', value); if (value === 0) { - this.platform.log.debug('Switching to the Other input is unsupported. This input is only used when the plugin is unable to identify the current input on the TV (i.e. you are using an app).'); - } else if (this.deviceState.hasFetchedInputs) { + this.log.debug('Switching to the Other input is unsupported. This input is only used when the plugin is unable to identify the current input on the TV (i.e. you are using an app).'); + } else if (this.inputSources.length >= (value as number)) { + if (!this.deviceState.hasFetchedInputs) { + this.log.debug('Cannot switch input because the input list has not been fetched yet.'); + return; + } + + // input is a source const inputSource = this.inputSources[(value as number) - 1]; - const [err, _] = await this.sendCommand(['--key', 'source_' + inputSource.sourceid]); - if(err) { - this.platform.log.error('An error occurred while changing the input: ' + err); - }else { - this.service.updateCharacteristic(this.Characteristic.ActiveIdentifier, value); + if (this.deviceState.currentSourceName === inputSource.sourcename) { + this.log.debug(`Input ${inputSource.sourcename} is already selected.`); + } else { + this.mqttHelper.changeSource(inputSource.sourceid); } } else { - this.platform.log.debug('Inputs have not been fetched yet. Please wait until the TV is turned on.'); + // input is an app + value = (value as number) - this.inputSources.length - 1; + if (value >= this.availableApps.length) { + this.log.debug('Cannot switch input as apps have not been fetched yet.'); + return; + } + + const app = this.availableApps[value]; + if (this.deviceState.currentSourceName === app.name) { + this.log.debug(`App ${app.name} is already selected.`); + } else { + this.mqttHelper.changeApp(app.name, app.id, app.url, app.urlType ?? '', app.storeType); + } } } - /** - * Fetch the available inputs from the TV. - * - * This function calls `hisensetv --get sources` and registers new inputs - * with HomeKit. It will automatically get the display name from each input and - * use that as name in HomeKit. + * Save the list of inputs for the TV. * - * This function will be executed only once when registering a new device or - * starting up. It will be executed again if the TV is off the first time. + * This function takes the list of inputs from the TV and a list of apps and creates a HomeKit input. + * It will automatically get the display name from each input and use that as name in HomeKit. */ - async getSources() { - this.platform.log.debug('Fetching input sources...'); - if (!this.deviceState.isConnected) { - this.platform.log.info('Unable to fetch input sources because the TV is off. Will retry as soon as the device comes back online.'); - this.deviceState.hasFetchedInputs = false; - return; - } + createSources(sources: InputSource[], apps: TVApp[]) { + sources = sources.sort((a, b) => { + return parseInt(a.sourceid, 10) - parseInt(b.sourceid, 10); + }); - const [err, output] = await this.sendCommand(['--get', 'sources']); - if (err) { - this.platform.log.error('An error occurred while fetching inputs: ' + err); - this.deviceState.hasFetchedInputs = false; - return; - } + apps = this.getFilteredApps(apps).sort((a, b) => { + return a.name.localeCompare(b.name); + }); - try { - this.inputSources = (JSON.parse((output as string[]).join('')) as InputSource[]) - .sort((a, b) => { - return parseInt(a.sourceid, 10) - parseInt(b.sourceid, 10); - }); + const sourcesChanged = !sourcesAreEqual(sources, this.inputSources); + const appsChanged = !equal(this.availableApps, apps); + + if (sourcesChanged) { + this.inputSources = sources; this.inputSources.forEach((inputSource, index) => { - this.platform.log.debug('Adding input: ' + JSON.stringify(inputSource)); - - let inputType = this.Characteristic.InputSourceType.OTHER; - if (inputSource.sourcename === 'TV') { - inputType = this.Characteristic.InputSourceType.TUNER; - } else if (inputSource.sourcename === 'AV') { - inputType = this.Characteristic.InputSourceType.COMPOSITE_VIDEO; - } else if (inputSource.sourcename.startsWith('HDMI')) { - inputType = this.Characteristic.InputSourceType.HDMI; - } + this.log.debug('Adding input: ' + JSON.stringify(inputSource)); + + const inputService = this.inputSourceSubPlatformAccessory.addTVInputSource(inputSource, index + 1); + + this.service.addLinkedService(inputService); + }); - const inputService = this.accessory.getService('input' + inputSource.sourceid) - || this.accessory.addService(this.Service.InputSource, 'input' + inputSource.sourceid, 'input' + inputSource.sourceid); + this.deviceState.hasFetchedInputs = true; + } - inputService.setCharacteristic(this.Characteristic.Identifier, (index + 1)); - inputService.setCharacteristic(this.Characteristic.IsConfigured, this.Characteristic.IsConfigured.CONFIGURED); - inputService.setCharacteristic(this.Characteristic.ConfiguredName, inputSource.displayname); - inputService.setCharacteristic(this.Characteristic.Name, inputSource.sourcename); - inputService.setCharacteristic(this.Characteristic.CurrentVisibilityState, this.Characteristic.CurrentVisibilityState.SHOWN); - inputService.setCharacteristic(this.Characteristic.InputSourceType, inputType); + if (sourcesChanged || appsChanged) { + // we always need to run both these snippets if source changed + // as the app identifier is based on the input source length + this.availableApps = apps; + const startIndex = this.inputSources.length; + this.availableApps.forEach((app, index) => { + this.log.debug('Adding app: ' + JSON.stringify(app)); - inputSource.service = inputService; + const inputService = this.inputSourceSubPlatformAccessory.addAppInputSource(app, startIndex + index + 1); this.service.addLinkedService(inputService); }); - const displayOrder = [0].concat(this.inputSources.map((_, index) => index+1)); + // display order is based on the identifier of the input source + const displayOrder = [0].concat(this.inputSources.map((_, index) => index + 1)).concat(this.availableApps.map((_, index) => index + this.inputSources.length + 1)); + this.service.setCharacteristic(this.platform.api.hap.Characteristic.DisplayOrder, this.platform.api.hap.encode(1, displayOrder).toString('base64')); - this.deviceState.hasFetchedInputs = true; - this.getCurrentInput(); - } catch (error) { - this.platform.log.error('An error occurred while fetching inputs: ' + error); - this.deviceState.hasFetchedInputs = false; + // run in case the current input is not set after fetching the sources + this.service.setCharacteristic(this.Characteristic.ActiveIdentifier, this.getCurrentInputIndex()); } } @@ -308,181 +403,173 @@ export class HiSenseTVAccessory { * Switching to this input is unsupported. */ createHomeSource() { - this.platform.log.debug('Adding unknown source...'); - - const inputService = this.accessory.getService('inputhome') || this.accessory.addService(this.Service.InputSource, 'inputhome', 'inputhome'); - - inputService - .setCharacteristic(this.Characteristic.IsConfigured, this.Characteristic.IsConfigured.CONFIGURED) - .setCharacteristic(this.Characteristic.ConfiguredName, 'Unknown') - .setCharacteristic(this.Characteristic.Name, 'Unknown') - .setCharacteristic(this.Characteristic.InputSourceType, this.Characteristic.InputSourceType.OTHER) - .setCharacteristic(this.Characteristic.Identifier, 0) - .setCharacteristic(this.Characteristic.CurrentVisibilityState, this.Characteristic.CurrentVisibilityState.SHOWN); - + this.log.debug('Adding unknown source...'); + const inputService = this.inputSourceSubPlatformAccessory.createUnknownSource(); this.service.addLinkedService(inputService); } /** - * Check the current TV status by attempting to telnet the MQTT service directly. + * Check the current TV status by attempting to connect to the mqtt port. * - * Instead of trying to send a command to the TV, it is way faster and lighter to just - * try to connect to the MQTT service via telnet and then disconnect immediately. - * If the telnet connection succeds, the TV will be displayed as Active, otherwise it will appear as turned off. + * Instead of trying to reconnect the mqtt client every few seconds, it is way faster and lighter to just + * try to connect to the MQTT Socket and then disconnect immediately. + * If the connection succeeds, the mqtt connection will be reestablished, otherwise it will be closed. * - * After checking the status, if the inputs have not already been fetched, this function will invoke `getSources`. - * Otherwise, it'll just check for the current visible input, by calling `getCurrentInput`. + * There is also a counter to prevent false positives/negatives when checking the TV state. + * Check the documentation of the counter for more information. */ checkTVStatus() { - this.platform.log.debug('Checking state for TV at IP: ' + this.accessory.context.device.ipaddress); + this.log.debug('Checking state for TV at IP: ' + this.deviceConfig.ipaddress); - const socket = net.createConnection({host: this.accessory.context.device.ipaddress, port: 36669, timeout: 500}); + const socket = net.createConnection({host: this.deviceConfig.ipaddress, port: 36669, timeout: 500}); socket.on('connect', () => { - this.platform.log.debug('Connected to TV!'); - this.deviceState.isConnected = true; - this.service.updateCharacteristic(this.Characteristic.Active, this.deviceState.isConnected); - socket.destroy(); + this.log.debug('Connected to TV!'); - if (!this.deviceState.hasFetchedInputs) { - this.deviceState.hasFetchedInputs = true; - this.getSources(); + if (this.offCounter === -1) { + this.onCounter++; } else { - this.getCurrentInput(); + this.onCounter = 0; + } + + if (this.onCounter === this.counterThreshold) { + this.offCounter = 0; + this.onCounter = 0; + this.setTVPowerStateOn(); + } + + if (!this.mqttHelper.mqttClient.connected) { + this.mqttHelper.mqttClient.reconnect(); } }); + const tvOffCallback = () => { + if (this.onCounter === -1) { + this.offCounter++; + } else { + this.offCounter = 0; + } + + if (this.offCounter === this.counterThreshold) { + this.onCounter = 0; + this.offCounter = 0; + this.setTVPowerStateOff(); + } + + if (!this.mqttHelper.mqttClient.disconnected) { + this.mqttHelper.mqttClient.end(true); + } + }; + socket.on('timeout', () => { - this.platform.log.debug('Connection to TV timed out.'); - this.deviceState.isConnected = false; - this.service.updateCharacteristic(this.Characteristic.Active, this.deviceState.isConnected); socket.destroy(); + this.log.debug('Connection to TV timed out.'); + + tvOffCallback(); }); socket.on('error', (err) => { - this.platform.log.debug('An error occurred while connecting to TV: ' + err); - this.deviceState.isConnected = false; - this.service.updateCharacteristic(this.Characteristic.Active, this.deviceState.isConnected); socket.destroy(); + this.log.debug('An error occurred while connecting to TV: ' + JSON.stringify(err)); + + tvOffCallback(); }); } /** - * Get the currently visible input and updates HomeKit. + * Sets the current input based on the state received from the TV. * - * This function will call `hisensetv --get state` and will try to match - * the reported state to an input. At the moment, only the following states are supported: + * At the moment, only the following states are supported: * - `sourceswitch`: any external input (i.e. HDMIs, AV). * - `livetv`: the tuner. + * - `app`: any app running on the TV. * - * Any other state will be matched as the "Other" input. + * Any other state will be matched as the "Unknown" input. */ - async getCurrentInput() { - this.platform.log.debug('Checking current input...'); - - const [_, output] = await this.sendCommand(['--get', 'state']); - - try { - const response = JSON.parse((output as string[]).join('')) as TVState; - if (response.statetype === 'sourceswitch') { - this.deviceState.currentSourceName = response.sourcename; - this.platform.log.debug('Current input is: ' + this.deviceState.currentSourceName); - } else if (response.statetype === 'livetv') { - this.deviceState.currentSourceName = 'TV'; - this.platform.log.debug('Current input is: ' + this.deviceState.currentSourceName); - } else { - this.deviceState.currentSourceName = ''; - this.platform.log.debug('Current input is unsupported.'); - } - - this.service.updateCharacteristic(this.Characteristic.ActiveIdentifier, this.getCurrentInputIndex()); - } catch (error) { - this.platform.log.error('An error occurred while fetching the current input: ' + error); + setCurrentInput(input: TVState) { + this.log.debug('Received state from TV: ' + JSON.stringify(input)); + + if (input.statetype === 'sourceswitch') { + this.deviceState.currentSourceName = input.sourcename; + this.log.debug('Current input is: ' + this.deviceState.currentSourceName); + } else if (input.statetype === 'livetv') { + this.deviceState.currentSourceName = 'TV'; + this.log.debug('Current input is: ' + this.deviceState.currentSourceName); + } else if (input.statetype === 'app') { + this.deviceState.currentSourceName = input.name; + this.log.debug('Current input is: ' + this.deviceState.currentSourceName); + } else { + this.deviceState.currentSourceName = ''; + this.log.debug('Current input is unsupported.'); } + + this.service.updateCharacteristic(this.Characteristic.ActiveIdentifier, this.getCurrentInputIndex()); } /** * Get the index of the current input by matching the current input name to the - * list of HomeKit inputs. + * list of HomeKit inputs and apps. * * If the current input cannot be found, this function will return `0`. * * @returns The index of the current input in HomeKit. */ getCurrentInputIndex() { - for (let index = 0; index < this.inputSources.length; index++) { - const inputSource = this.inputSources[index]; - if (inputSource.sourcename === this.deviceState.currentSourceName) { - return index + 1; - } + if (!this.deviceState.hasFetchedInputs) { + return 0; } - return 0; - } + let index = this.inputSources.findIndex((inputSource) => inputSource.sourcename === this.deviceState.currentSourceName); - /** - * Invoke a function on the Hisense script. - * - * @param args the arguments to pass to the Hisense script. - * @param callback A callback to call with an error and the output of the script. - */ - async sendCommand(args: string[]): Promise<[err?: Error, output?: unknown]> { - const sslParameter = this.getSslArgument(); - - const pythonScript = path.resolve(__dirname, '../bin/hisensetv.py'); - - let pythonArgs = args.concat([this.accessory.context.device.ipaddress, '--ifname', this.platform.config.ifname]); - if (sslParameter !== null) { - pythonArgs = pythonArgs.concat(sslParameter); + if (index !== -1) { + return index + 1; } - this.platform.log.debug('Run Python command: ' + pythonScript + ' ' + pythonArgs.join(' ')); + // if not found in the input sources, check the apps + index = this.availableApps.findIndex((app) => app.name === this.deviceState.currentSourceName); - return new Promise((resolve) => { - PythonShell.run(pythonScript, {args: pythonArgs}, (err, output) => { - if (err === null) { - this.platform.log.debug('Received Python command response: ' + output); - } else { - this.platform.log.debug('Received Python command error: ' + err); - } + if (index === -1) { + return 0; + } - resolve([err, output]); - }); - }); + return index + this.inputSources.length + 1; } /** - * Compute the SSL argument to pass to the underlying script, - * based on the current device configuration. - * - * @returns The SSL parameter to pass or null. + * Get the list of apps that should be displayed in HomeKit. + * showApps needs to be enabled in the config for this to work. + * If no apps are specified, all apps will be displayed. + * @param apps */ - getSslArgument(): string[] { - let sslParameter: string[] = []; - switch (this.accessory.context.device.sslmode) { - case 'disabled': - sslParameter = ['--no-ssl']; - break; - case 'custom': - sslParameter = ['--certfile', this.accessory.context.device.sslcertificate.trim(), '--keyfile', this.accessory.context.device.sslprivatekey.trim()]; - break; + getFilteredApps(apps: TVApp[]) { + if (!this.deviceConfig.showApps) { + return []; } - return sslParameter; - } - - // #endregion - -} + const visibleAppNames = this.deviceConfig.apps ?? []; -class InputSource { - sourceid = ''; - sourcename = ''; - displayname = ''; - service?: Service; -} + return apps.filter((app) => !app.isunInstalled && (visibleAppNames.length === 0 || visibleAppNames.includes(app.name))); + } -class TVState { - statetype = ''; - sourcename = ''; + /** + * Special case for always on TVs with the tvType fakeSleep. + * This will set the TV to the correct state based on the state received from the TV. + * @param tvState + * @private + */ + private setAlwaysOnFakeSleepState(tvState: TVState) { + const tvStateIsOff = tvState.statetype.startsWith('fake_sleep'); + + if (tvStateIsOff && this.deviceState.isConnected) { + // Disconnect + this.setTVPowerStateOff(); + } else if (!tvStateIsOff) { + // is on + this.setCurrentInput(tvState); + if (!this.deviceState.isConnected) { + // Connect + this.setTVPowerStateOn(); + } + } + } } \ No newline at end of file diff --git a/src/scripts/alwaysOnTest.ts b/src/scripts/alwaysOnTest.ts new file mode 100644 index 0000000..f9da3cc --- /dev/null +++ b/src/scripts/alwaysOnTest.ts @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import {HisenseMQTTClient} from '../hisenseMQTTClient.js'; +import {parseArgs} from 'node:util'; +import * as readline from 'node:readline/promises'; +import {PictureSetting} from '../interfaces/picturesetting.interface.js'; + + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + + +const args = process.argv.slice(2); +const options = { + 'no-ssl': { + type: 'boolean', + default: false, + }, + hostname: { + type: 'string', + }, + certfile: { + type: 'string', + }, + keyfile: { + type: 'string', + }, + mac: { + type: 'string', + }, +} as const; +const {values} = parseArgs({args, options}); + +const sslMode = values['no-ssl'] ? 'disabled' : 'custom'; +const sslCertificate = (values['certfile'] ?? '') as string; +const sslPrivateKey = (values['keyfile'] ?? '') as string; +const macaddress = values['mac'] as string; +const hostname = values['hostname'] as string; + +let pictureSettingsOff: null|PictureSetting = null; + + +(async () => { + rl.write('Running first test to determine if TV is always on or off'); + await rl.question('Turn your TV off now and press enter when ready: '); + rl.write('Wait for a few seconds...'); + try { + const mqttHelper = new HisenseMQTTClient({sslmode: sslMode, ipaddress: hostname, sslcertificate: sslCertificate, sslprivatekey: sslPrivateKey}, macaddress); + const timeout = setTimeout(() => { + mqttHelper.mqttClient.end(true); + rl.write('Could not detect always on TV'); + process.exit(0); + }, 5000); + + mqttHelper.mqttClient.on('connect', () => { + clearTimeout(timeout); + rl.write('Always on TV detected'); + + rl.write('Running second test to determine if Always On TV has Fake Sleep Mode'); + rl.write('Wait for a few seconds...'); + + mqttHelper.mqttClient.on('message', async (topic, message) => { + const data = JSON.parse(message.toString()); + if(topic === mqttHelper._STATE_TOPIC) { + mqttHelper.mqttClient.unsubscribe(mqttHelper._STATE_TOPIC); + if('statetype' in data && data['statetype'].startsWith('fake_sleep')) { + rl.write('Possible Always On TV with Fake Sleep Detected: ' + data['statetype']); + }else{ + rl.write('First test didn\'t detect always on mode.'); + rl.write('Continuing with Picture Settings Test'); + mqttHelper.subscribe(mqttHelper._PICTURE_SETTINGS_TOPIC); + mqttHelper.callService('platform_service', 'picturesetting'); + } + }else if(topic === mqttHelper._PICTURE_SETTINGS_TOPIC) { + const pictureSettings = data as PictureSetting; + if(pictureSettingsOff == null) { + pictureSettingsOff = pictureSettings; + mqttHelper.mqttClient.unsubscribe(mqttHelper._PICTURE_SETTINGS_TOPIC); + await rl.question('Turn your TV on now and press enter when ready:'); + mqttHelper.subscribe(mqttHelper._PICTURE_SETTINGS_TOPIC); + mqttHelper.callService('platform_service', 'picturesetting'); + }else { + // find different objects in picture settings + mqttHelper.mqttClient.end(true); + const diff = pictureSettings.menu_info.filter((menu) => { + const offMenu = pictureSettingsOff?.menu_info.find((offMenu) => offMenu.menu_id === menu.menu_id); + return menu.menu_flag !== offMenu?.menu_flag; + }); + + if(diff.length > 0){ + rl.write('Picture Settings Always On Mode possible.'); + } + + diff.forEach(menu => { + const oldMenu = pictureSettingsOff?.menu_info.find((offMenu) => offMenu.menu_id === menu.menu_id); + + rl.write(`Menu: ${menu.menu_name} with id ${menu.menu_id} has changed from ${oldMenu?.menu_flag} to ${menu.menu_flag}`); + }); + process.exit(0); + } + } + }); + mqttHelper.subscribe(mqttHelper._STATE_TOPIC); + mqttHelper.callService('ui_service', 'gettvstate'); + }); + } catch (e) { + rl.write('Connection failed - please check your configuration and try again\n'); + process.exit(1); + } +})(); \ No newline at end of file diff --git a/src/scripts/authorize.ts b/src/scripts/authorize.ts new file mode 100644 index 0000000..6999cc8 --- /dev/null +++ b/src/scripts/authorize.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import {HisenseMQTTClient} from '../hisenseMQTTClient.js'; +import {parseArgs} from 'node:util'; +import * as readline from 'node:readline/promises'; +import path from 'path'; + + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); + + +const args = process.argv.slice(2); +const options = { + 'no-ssl': { + type: 'boolean', + default: false, + }, + hostname: { + type: 'string', + }, + certfile: { + type: 'string', + }, + keyfile: { + type: 'string', + }, + mac: { + type: 'string', + }, +} as const; +const {values} = parseArgs({args, options}); + +const sslMode = values['no-ssl'] ? 'disabled' : 'custom'; +const sslCertificate = (values['certfile'] ?? '') as string; +const sslPrivateKey = (values['keyfile'] ?? '') as string; +const macaddress = values['mac'] as string; +const hostname = values['hostname'] as string; + +try { + const mqttHelper = new HisenseMQTTClient({sslmode: sslMode, ipaddress: hostname, sslcertificate: sslCertificate, sslprivatekey: sslPrivateKey}, macaddress); + + mqttHelper.mqttClient.on('connect', () => { + mqttHelper.callService('ui_service', 'gettvstate'); + }); + mqttHelper.mqttClient.on('message', (topic, message) => { + const data = JSON.parse(message.toString()); + if(data != null && typeof data === 'object' && 'result' in data) { + if(data.result !== 1) { + rl.write('TV pairing failed - please try again'); + } + mqttHelper.mqttClient.end(true); + process.exit(0); + } + }); + + (async () => { + const code = await rl.question('Please enter the 4-digit code shown on tv: '); + mqttHelper.subscribe(path.join(mqttHelper._COMMUNICATION_TOPIC, '#')); + mqttHelper.sendAuthCode(code); + })(); +} catch (e) { + rl.write('Connection failed - please check your configuration and try again\n'); + process.exit(1); +} \ No newline at end of file diff --git a/src/utils/sourcesAreEqual.function.ts b/src/utils/sourcesAreEqual.function.ts new file mode 100644 index 0000000..dc526e0 --- /dev/null +++ b/src/utils/sourcesAreEqual.function.ts @@ -0,0 +1,28 @@ +import {InputSource} from '../interfaces/input-source.interface.js'; +import equal from 'fast-deep-equal'; + +/** + * Check if the current list of inputs is equal to the new list of inputs. + * inputs are considered equal if the sourceid, sourcename and displayname are the same. + * New objects get created as there could be additional properties in the input source object. + * @param newSources + * @param oldSources + */ +export function sourcesAreEqual(newSources: InputSource[], oldSources: InputSource[]) { + const minSources = oldSources.map((inputSource) => { + return { + sourceid: inputSource.sourceid, + sourcename: inputSource.sourcename, + displayname: inputSource.displayname, + }; + }); + const minNewSources = newSources.map((inputSource) => { + return { + sourceid: inputSource.sourceid, + sourcename: inputSource.sourcename, + displayname: inputSource.displayname, + }; + }); + + return equal(minSources, minNewSources); +} \ No newline at end of file diff --git a/src/utils/validateDeviceConfig.function.ts b/src/utils/validateDeviceConfig.function.ts new file mode 100644 index 0000000..fe4e72d --- /dev/null +++ b/src/utils/validateDeviceConfig.function.ts @@ -0,0 +1,13 @@ +import {DeviceConfig} from '../interfaces/device-config.interface.js'; + +export function validateDeviceConfig(deviceConfig: DeviceConfig){ + deviceConfig.showApps = deviceConfig.showApps ?? false; + + deviceConfig.tvType = deviceConfig.tvType ?? 'default'; + + deviceConfig.pollingInterval = deviceConfig.pollingInterval ?? 4; + deviceConfig.wolInterval = deviceConfig.wolInterval ?? 400; + deviceConfig.wolRetries = deviceConfig.wolRetries ?? 3; + + return deviceConfig; +} \ No newline at end of file diff --git a/src/wol.ts b/src/wol.ts new file mode 100644 index 0000000..0c26917 --- /dev/null +++ b/src/wol.ts @@ -0,0 +1,35 @@ +import {Logging} from 'homebridge'; +import wol from 'wol'; + +/** + * Wake on Lan + */ +export class WoL { + /** + * @param log Homebridge logger + * @param macAddress MAC address of the device to wake up + * @param retries Number of retries to send the magic packet + * @param interval Interval between retries in milliseconds + */ + constructor(private log: Logging, private macAddress: string, private retries: number, private interval: number){ + } + + + /** + * Send the magic packet to wake up the device + * @param attempt Current attempt + */ + public async sendMagicPacket(attempt = 0) { + if(attempt < this.retries){ + try { + await wol.wake(this.macAddress); + this.log.debug('Send Wake On Lan'); + setTimeout(() => { + this.sendMagicPacket(attempt + 1); + }, this.interval ?? 400); + } catch (error) { + this.log.error('An error occurred while sending WoL: ' + error); + } + } + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 033eb73..ecb5bab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,22 +1,22 @@ { "compilerOptions": { - "target": "ES2018", // ~node10 - "module": "commonjs", - "lib": [ - "es2015", - "es2016", - "es2017", - "es2018" - ], + "allowSyntheticDefaultImports": true, "declaration": true, - "declarationMap": true, - "sourceMap": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, "outDir": "./dist", "rootDir": "./src", + "lib": [ + "DOM", + "ES2022" + ], + "module": "ES2022", + "moduleResolution":"node", + "sourceMap": true, "strict": true, - "esModuleInterop": true, "noImplicitAny": false, - "resolveJsonModule": true + "resolveJsonModule": true, + "target": "ES2022" }, "include": [ "src/"