From 4ea239d4fd7f14c38efba8b458052d74b28d7bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20De=20Keersmaeker?= <francois.dekeersmaeker@uclouvain.be> Date: Wed, 11 Dec 2024 12:41:46 +0100 Subject: [PATCH] Added all devices --- .gitignore | 7 ++ pyproject.toml | 4 +- .../DeviceControl/CameraControl.py | 46 ++++++++++ .../DeviceControl/DeviceControl.py | 62 ++++++++++++++ .../DeviceControl/LightControl.py | 67 +++++++++++++++ .../DeviceControl/PlugControl.py | 14 ++++ .../DeviceControl/SmartThingsControl.py | 34 ++++++++ .../DeviceControl/SmartThingsLightControl.py | 20 +++++ .../DeviceControl/SmartThingsPlugControl.py | 12 +++ .../DeviceControl/TapoControl.py | 26 ++++++ .../DeviceControl/TapoPlugControl.py | 12 +++ .../DeviceControl/TuyaControl.py | 27 ++++++ smart_home_testbed/DeviceControl/__init__.py | 10 +++ .../DeviceState/CameraScreenshotState.py | 51 +++++++++++ smart_home_testbed/DeviceState/CameraState.py | 30 +++++++ smart_home_testbed/DeviceState/DeviceState.py | 51 +++++++++++ smart_home_testbed/DeviceState/HueState.py | 77 +++++++++++++++++ smart_home_testbed/DeviceState/LightState.py | 27 ++++++ .../DeviceState/ScreenshotState.py | 46 ++++++++++ .../DeviceState/SmartThingsState.py | 72 ++++++++++++++++ .../DeviceState/TapoLightState.py | 26 ++++++ .../DeviceState/TapoPlugState.py | 19 +++++ smart_home_testbed/DeviceState/TapoState.py | 50 +++++++++++ .../DeviceState/TpLinkPlugState.py | 22 +++++ smart_home_testbed/DeviceState/__init__.py | 17 ++++ smart_home_testbed/__init__.py | 28 +++++++ smart_home_testbed/devices/.gitignore | 8 ++ smart_home_testbed/devices/__init__.py | 27 ++++++ .../cameras/DLinkCamera/DLinkCamera.py | 35 ++++++++ .../devices/cameras/DLinkCamera/__init__.py | 1 + .../devices/cameras/TapoCamera/TapoCamera.py | 20 +++++ .../devices/cameras/TapoCamera/__init__.py | 1 + .../TapoCameraSmartThings.py | 59 +++++++++++++ .../cameras/TapoCameraSmartThings/__init__.py | 1 + .../cameras/XiaomiCamera/XiaomiCamera.py | 82 ++++++++++++++++++ .../devices/cameras/XiaomiCamera/__init__.py | 1 + .../devices/cameras/__init__.py | 4 + smart_home_testbed/devices/init_device.py | 20 +++++ .../devices/lights/HueLight/HueLight.py | 46 ++++++++++ .../devices/lights/HueLight/__init__.py | 1 + .../HueLightEssentials/HueLightEssentials.py | 74 ++++++++++++++++ .../lights/HueLightEssentials/__init__.py | 1 + .../HueLightSmartThings.py | 14 ++++ .../lights/HueLightSmartThings/__init__.py | 1 + .../devices/lights/TapoLight/TapoLight.py | 34 ++++++++ .../devices/lights/TapoLight/__init__.py | 1 + .../TapoLightSmartThings.py | 14 ++++ .../lights/TapoLightSmartThings/__init__.py | 1 + .../devices/lights/TuyaLight/TuyaLight.py | 84 +++++++++++++++++++ .../devices/lights/TuyaLight/__init__.py | 1 + smart_home_testbed/devices/lights/__init__.py | 6 ++ .../SmartThingsOutlet/SmartThingsOutlet.py | 25 ++++++ .../plugs/SmartThingsOutlet/__init__.py | 1 + .../devices/plugs/TapoPlug/TapoPlug.py | 12 +++ .../devices/plugs/TapoPlug/__init__.py | 1 + .../TapoPlugSmartThings.py | 14 ++++ .../plugs/TapoPlugSmartThings/__init__.py | 1 + .../devices/plugs/TpLinkPlug/TpLinkPlug.py | 15 ++++ .../devices/plugs/TpLinkPlug/__init__.py | 1 + .../TpLinkPlugSmartThings.py | 14 ++++ .../plugs/TpLinkPlugSmartThings/__init__.py | 1 + .../plugs/TpLinkPlugTapo/TpLinkPlugTapo.py | 13 +++ .../devices/plugs/TpLinkPlugTapo/__init__.py | 1 + .../devices/plugs/TuyaPlug/TuyaPlug.py | 16 ++++ .../devices/plugs/TuyaPlug/__init__.py | 1 + smart_home_testbed/devices/plugs/__init__.py | 7 ++ 66 files changed, 1515 insertions(+), 2 deletions(-) create mode 100644 smart_home_testbed/DeviceControl/CameraControl.py create mode 100644 smart_home_testbed/DeviceControl/DeviceControl.py create mode 100644 smart_home_testbed/DeviceControl/LightControl.py create mode 100644 smart_home_testbed/DeviceControl/PlugControl.py create mode 100644 smart_home_testbed/DeviceControl/SmartThingsControl.py create mode 100644 smart_home_testbed/DeviceControl/SmartThingsLightControl.py create mode 100644 smart_home_testbed/DeviceControl/SmartThingsPlugControl.py create mode 100644 smart_home_testbed/DeviceControl/TapoControl.py create mode 100644 smart_home_testbed/DeviceControl/TapoPlugControl.py create mode 100644 smart_home_testbed/DeviceControl/TuyaControl.py create mode 100644 smart_home_testbed/DeviceControl/__init__.py create mode 100644 smart_home_testbed/DeviceState/CameraScreenshotState.py create mode 100644 smart_home_testbed/DeviceState/CameraState.py create mode 100644 smart_home_testbed/DeviceState/DeviceState.py create mode 100644 smart_home_testbed/DeviceState/HueState.py create mode 100644 smart_home_testbed/DeviceState/LightState.py create mode 100644 smart_home_testbed/DeviceState/ScreenshotState.py create mode 100644 smart_home_testbed/DeviceState/SmartThingsState.py create mode 100644 smart_home_testbed/DeviceState/TapoLightState.py create mode 100644 smart_home_testbed/DeviceState/TapoPlugState.py create mode 100644 smart_home_testbed/DeviceState/TapoState.py create mode 100644 smart_home_testbed/DeviceState/TpLinkPlugState.py create mode 100644 smart_home_testbed/DeviceState/__init__.py create mode 100644 smart_home_testbed/__init__.py create mode 100644 smart_home_testbed/devices/.gitignore create mode 100644 smart_home_testbed/devices/__init__.py create mode 100644 smart_home_testbed/devices/cameras/DLinkCamera/DLinkCamera.py create mode 100644 smart_home_testbed/devices/cameras/DLinkCamera/__init__.py create mode 100644 smart_home_testbed/devices/cameras/TapoCamera/TapoCamera.py create mode 100644 smart_home_testbed/devices/cameras/TapoCamera/__init__.py create mode 100644 smart_home_testbed/devices/cameras/TapoCameraSmartThings/TapoCameraSmartThings.py create mode 100644 smart_home_testbed/devices/cameras/TapoCameraSmartThings/__init__.py create mode 100644 smart_home_testbed/devices/cameras/XiaomiCamera/XiaomiCamera.py create mode 100644 smart_home_testbed/devices/cameras/XiaomiCamera/__init__.py create mode 100644 smart_home_testbed/devices/cameras/__init__.py create mode 100644 smart_home_testbed/devices/init_device.py create mode 100644 smart_home_testbed/devices/lights/HueLight/HueLight.py create mode 100644 smart_home_testbed/devices/lights/HueLight/__init__.py create mode 100644 smart_home_testbed/devices/lights/HueLightEssentials/HueLightEssentials.py create mode 100644 smart_home_testbed/devices/lights/HueLightEssentials/__init__.py create mode 100644 smart_home_testbed/devices/lights/HueLightSmartThings/HueLightSmartThings.py create mode 100644 smart_home_testbed/devices/lights/HueLightSmartThings/__init__.py create mode 100644 smart_home_testbed/devices/lights/TapoLight/TapoLight.py create mode 100644 smart_home_testbed/devices/lights/TapoLight/__init__.py create mode 100644 smart_home_testbed/devices/lights/TapoLightSmartThings/TapoLightSmartThings.py create mode 100644 smart_home_testbed/devices/lights/TapoLightSmartThings/__init__.py create mode 100644 smart_home_testbed/devices/lights/TuyaLight/TuyaLight.py create mode 100644 smart_home_testbed/devices/lights/TuyaLight/__init__.py create mode 100644 smart_home_testbed/devices/lights/__init__.py create mode 100644 smart_home_testbed/devices/plugs/SmartThingsOutlet/SmartThingsOutlet.py create mode 100644 smart_home_testbed/devices/plugs/SmartThingsOutlet/__init__.py create mode 100644 smart_home_testbed/devices/plugs/TapoPlug/TapoPlug.py create mode 100644 smart_home_testbed/devices/plugs/TapoPlug/__init__.py create mode 100644 smart_home_testbed/devices/plugs/TapoPlugSmartThings/TapoPlugSmartThings.py create mode 100644 smart_home_testbed/devices/plugs/TapoPlugSmartThings/__init__.py create mode 100644 smart_home_testbed/devices/plugs/TpLinkPlug/TpLinkPlug.py create mode 100644 smart_home_testbed/devices/plugs/TpLinkPlug/__init__.py create mode 100644 smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/TpLinkPlugSmartThings.py create mode 100644 smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/__init__.py create mode 100644 smart_home_testbed/devices/plugs/TpLinkPlugTapo/TpLinkPlugTapo.py create mode 100644 smart_home_testbed/devices/plugs/TpLinkPlugTapo/__init__.py create mode 100644 smart_home_testbed/devices/plugs/TuyaPlug/TuyaPlug.py create mode 100644 smart_home_testbed/devices/plugs/TuyaPlug/__init__.py create mode 100644 smart_home_testbed/devices/plugs/__init__.py diff --git a/.gitignore b/.gitignore index 2853266..b996f63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ # Config folders .venv +.vscode + +# Cache folders +__pycache__ + +# Local test files +test.py diff --git a/pyproject.toml b/pyproject.toml index bda1d18..dc882d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,5 +59,5 @@ dependencies = [ ] [project.urls] -"Homepage" = "https://forge.uclouvain.be/smart-home-network-security/signature-extraction" -"Source" = "https://forge.uclouvain.be/smart-home-network-security/signature-extraction" +"Homepage" = "https://forge.uclouvain.be/smart-home-network-security/smart-home-testbed" +"Source" = "https://forge.uclouvain.be/smart-home-network-security/smart-home-testbed" diff --git a/smart_home_testbed/DeviceControl/CameraControl.py b/smart_home_testbed/DeviceControl/CameraControl.py new file mode 100644 index 0000000..284bc68 --- /dev/null +++ b/smart_home_testbed/DeviceControl/CameraControl.py @@ -0,0 +1,46 @@ +import time +from .DeviceControl import DeviceControl + + +class CameraControl(DeviceControl): + """ + Class to control a camera device. + """ + + # Stream event duration + stream_duration = 7 + + + def stream(self) -> None: + """ + Perform the `stream` event. + """ + self.start_stream() + time.sleep(self.stream_duration) + self.stop_stream() + + + def start_stream(self) -> None: + """ + Start the camera's video stream. + """ + self.get_phone().shell(f"input tap {self.x_start} {self.y_start}") + + + def do_stop_stream(self) -> None: + """ + Stop the camera's video stream. + """ + self.get_phone().shell(f"input tap {self.x_stop} {self.y_stop}") + + + def stop_stream(self) -> None: + """ + Check if the stream was successful, + then stop the stream. + """ + # Check if stream was successful + self._was_last_stream_successful = self.get_state() + + if self._was_last_stream_successful: + self.do_stop_stream() diff --git a/smart_home_testbed/DeviceControl/DeviceControl.py b/smart_home_testbed/DeviceControl/DeviceControl.py new file mode 100644 index 0000000..a6cb0db --- /dev/null +++ b/smart_home_testbed/DeviceControl/DeviceControl.py @@ -0,0 +1,62 @@ +from ppadb.device import Device as AdbDevice +from ppadb.client import Client as AdbClient +import cv2 +import numpy as np + + +class DeviceControl: + """ + Abstract class which provides methods to control a device. + """ + + ADB_SERVER_IP = "127.0.0.1" + ADB_SERVER_PORT = 5037 + + + def get_phone(self) -> AdbDevice: + """ + Get the adb device object. + + Returns: + ppadb.device.Device: The adb device object. + Raises: + IndexError: If no adb device is found. + """ + adb = AdbClient(host=DeviceControl.ADB_SERVER_IP, port=DeviceControl.ADB_SERVER_PORT) + return adb.devices()[0] + + + def start_app(self) -> None: + """ + Start the device's app on the phone. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + phone.shell(f"monkey -p {self.android_package} -c android.intent.category.LAUNCHER 1") + + + def close_app(self) -> None: + """ + Close the device's app on the device. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + phone.shell(f"am force-stop {self.android_package}") + + + def screenshot(self) -> np.ndarray: + """ + Take a screenshot of the device's screen. + + Returns: + numpy.ndarray: The screenshot as a numpy array. + Raises: + IndexError: If no adb device is found. + """ + screenshot_bytes = self.get_phone().screencap() + screenshot_array = np.frombuffer(screenshot_bytes, dtype=np.uint8) + return cv2.imdecode(screenshot_array, cv2.IMREAD_UNCHANGED) diff --git a/smart_home_testbed/DeviceControl/LightControl.py b/smart_home_testbed/DeviceControl/LightControl.py new file mode 100644 index 0000000..4ed4fa7 --- /dev/null +++ b/smart_home_testbed/DeviceControl/LightControl.py @@ -0,0 +1,67 @@ +import random +from .DeviceControl import DeviceControl + + +class LightControl(DeviceControl): + """ + Class to control a light bulb device. + """ + + + def toggle(self) -> None: + """ + Perform the toggle event on the light. + """ + self.get_phone().shell(f"input tap {self.x} {self.y}") + + + def _set_light_attr(self, y: int) -> None: + """ + Randomly set an attribute of the light + (brightness or color). + + Args: + y (int): The y-coordinate of the corresponding gauge. + """ + x = random.randint(self.x_left_gauge, self.x_right_gauge) + self.get_phone().shell(f"input tap {x} {y}") + + + def do_set_brightness(self) -> None: + """ + Randomly set the brightness of the light. + """ + self._set_light_attr(self.y_brightness) + + + def set_brightness(self) -> None: + """ + Template method to randomly set the brightness of the light. + The child class must implement the concrete method `is_on`, + which checks if the light is on. + """ + # If light is off, turn it on + if self.is_off(): + self.toggle() + + self.do_set_brightness() + + + def do_set_color(self) -> None: + """ + Randomly set the color of the light. + """ + self._set_light_attr(self.y_color) + + + def set_color(self) -> None: + """ + Template method to randomly set the color of the light. + The child class must implement the concrete method `is_on`, + which checks if the light is on. + """ + # If light is off, turn it on + if self.is_off(): + self.toggle() + + self.do_set_color() diff --git a/smart_home_testbed/DeviceControl/PlugControl.py b/smart_home_testbed/DeviceControl/PlugControl.py new file mode 100644 index 0000000..f4e2462 --- /dev/null +++ b/smart_home_testbed/DeviceControl/PlugControl.py @@ -0,0 +1,14 @@ +from .DeviceControl import DeviceControl + + +class PlugControl(DeviceControl): + """ + Class to control a plug device. + """ + + + def toggle(self) -> None: + """ + Perform the toggle action on the plug. + """ + self.get_phone().shell(f"input tap {self.x} {self.y}") diff --git a/smart_home_testbed/DeviceControl/SmartThingsControl.py b/smart_home_testbed/DeviceControl/SmartThingsControl.py new file mode 100644 index 0000000..5ea6419 --- /dev/null +++ b/smart_home_testbed/DeviceControl/SmartThingsControl.py @@ -0,0 +1,34 @@ +import time +from .DeviceControl import DeviceControl + + +class SmartThingsControl(DeviceControl): + """ + Abstract class to control a generic device through SmartThings. + """ + + ## Class variables + # Android package name + android_package = "com.samsung.android.oneconnect" + # Devices tab button screen coordinates + devices_x = 223.2 + devices_y = 1281.6 + + + def start_app(self) -> None: + """ + Start the SmartThings app on the phone, + and open the device controls. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + # Start SmartThings app + phone.shell(f"monkey -p {SmartThingsControl.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Open "Devices" tab + phone.shell(f"input tap {SmartThingsControl.devices_x} {SmartThingsControl.devices_y}") + time.sleep(5) + # Open device controls + phone.shell(f"input tap {self.device_x} {self.device_y}") diff --git a/smart_home_testbed/DeviceControl/SmartThingsLightControl.py b/smart_home_testbed/DeviceControl/SmartThingsLightControl.py new file mode 100644 index 0000000..f95d077 --- /dev/null +++ b/smart_home_testbed/DeviceControl/SmartThingsLightControl.py @@ -0,0 +1,20 @@ +from .SmartThingsControl import SmartThingsControl +from .LightControl import LightControl + + +class SmartThingsLightControl(SmartThingsControl, LightControl): + """ + Abstract class to control a generic light bulb through SmartThings. + """ + + ## Screen event coordinates + # Toggle + x = 626.4 + y = 273.6 + # Brightness and color gauges + x_left_gauge = 21 + x_right_gauge = 692 + # Brightness + y_brightness = 504 + # Color + y_color = 1036.8 diff --git a/smart_home_testbed/DeviceControl/SmartThingsPlugControl.py b/smart_home_testbed/DeviceControl/SmartThingsPlugControl.py new file mode 100644 index 0000000..2efe14a --- /dev/null +++ b/smart_home_testbed/DeviceControl/SmartThingsPlugControl.py @@ -0,0 +1,12 @@ +from .SmartThingsControl import SmartThingsControl +from .PlugControl import PlugControl + + +class SmartThingsPlugControl(SmartThingsControl, PlugControl): + """ + Abstract class to control a generic plug through SmartThings. + """ + + # Toggle coordinates + x = 626.4 + y = 273.6 diff --git a/smart_home_testbed/DeviceControl/TapoControl.py b/smart_home_testbed/DeviceControl/TapoControl.py new file mode 100644 index 0000000..a216fab --- /dev/null +++ b/smart_home_testbed/DeviceControl/TapoControl.py @@ -0,0 +1,26 @@ +import time +from .DeviceControl import DeviceControl + + +class TapoControl(DeviceControl): + """ + Abstract class to control a generic device through the Tapo app. + """ + + # Android package name + android_package = "com.tplink.iot" + + + def start_app(self) -> None: + """ + Start the device's app on the phone. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + # Start Tapo app + phone.shell(f"monkey -p {TapoControl.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Open device controls + phone.shell(f"input tap {self.device_x} {self.device_y}") diff --git a/smart_home_testbed/DeviceControl/TapoPlugControl.py b/smart_home_testbed/DeviceControl/TapoPlugControl.py new file mode 100644 index 0000000..06f29c2 --- /dev/null +++ b/smart_home_testbed/DeviceControl/TapoPlugControl.py @@ -0,0 +1,12 @@ +from .TapoControl import TapoControl +from .PlugControl import PlugControl + + +class TapoPlugControl(TapoControl, PlugControl): + """ + Abstract class to control a generic plug through the Tapo app. + """ + + # Toggle coordinates + x = 612 + y = 619.2 diff --git a/smart_home_testbed/DeviceControl/TuyaControl.py b/smart_home_testbed/DeviceControl/TuyaControl.py new file mode 100644 index 0000000..b9b7b6e --- /dev/null +++ b/smart_home_testbed/DeviceControl/TuyaControl.py @@ -0,0 +1,27 @@ +import time +from .DeviceControl import DeviceControl + + +class TuyaControl(DeviceControl): + """ + Abstract class to control a generic device through the Tuya app. + """ + + # Metadata + android_package = "com.tuya.smart" + version = "3.3" + + + def start_app(self) -> None: + """ + Start the Tuya app on the phone. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + # Start Tapo app + phone.shell(f"monkey -p {TuyaControl.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Open device controls + phone.shell(f"input tap {self.device_x} {self.device_y}") diff --git a/smart_home_testbed/DeviceControl/__init__.py b/smart_home_testbed/DeviceControl/__init__.py new file mode 100644 index 0000000..6022ca2 --- /dev/null +++ b/smart_home_testbed/DeviceControl/__init__.py @@ -0,0 +1,10 @@ +from .DeviceControl import DeviceControl +from .PlugControl import PlugControl +from .LightControl import LightControl +from .CameraControl import CameraControl +from .TapoControl import TapoControl +from .TapoPlugControl import TapoPlugControl +from .SmartThingsControl import SmartThingsControl +from .SmartThingsPlugControl import SmartThingsPlugControl +from .SmartThingsLightControl import SmartThingsLightControl +from .TuyaControl import TuyaControl diff --git a/smart_home_testbed/DeviceState/CameraScreenshotState.py b/smart_home_testbed/DeviceState/CameraScreenshotState.py new file mode 100644 index 0000000..be57395 --- /dev/null +++ b/smart_home_testbed/DeviceState/CameraScreenshotState.py @@ -0,0 +1,51 @@ +import cv2 +from skimage.metrics import structural_similarity as ssim +from .CameraState import CameraState + + +class CameraScreenshotState(CameraState): + """ + Abstract class for the state of a camera device, + which uses screenshot-based event validation. + """ + + FILENAME_SCREENSHOT_STREAM = "stream.png" + + + def __init__(self, ipv4: str, **kwargs) -> None: + """ + Constructor. + Initialize the camera device with its IP address. + + Args: + ipv4 (str): The device's IPv4 address. + kwargs (dict): device-specific additional parameters, + including the path to the streaming screenshot. + """ + # Call parent constructor + super().__init__(ipv4, **kwargs) + + # Compute gray array of streaming screenshot + stream_image = cv2.imread(kwargs.get("path_screenshot_stream", CameraScreenshotState.FILENAME_SCREENSHOT_STREAM)) + self.gray_stream_image = cv2.cvtColor(stream_image, cv2.COLOR_BGR2GRAY) + + + def get_state(self) -> bool: + """ + Get the state of the camera device, + i.e. if it is currently streaming, + by taking a screenshot of its app. + + Returns: + bool: True if the camera is currently streaming, False otherwise. + """ + # Take screenshot and convert to grayscale + screenshot_array = self.screenshot() + gray_screenshot = cv2.cvtColor(screenshot_array, cv2.COLOR_BGR2GRAY) + + # Compute SSIM + try: + score = ssim(self.gray_stream_image, gray_screenshot, full=False) + return score > self.SSIM_DIFF_THRESHOLD + except ValueError: + return False diff --git a/smart_home_testbed/DeviceState/CameraState.py b/smart_home_testbed/DeviceState/CameraState.py new file mode 100644 index 0000000..697caba --- /dev/null +++ b/smart_home_testbed/DeviceState/CameraState.py @@ -0,0 +1,30 @@ +from .DeviceState import DeviceState + + +class CameraState(DeviceState): + """ + Abstract class for the state of a camera device. + """ + + + def __init__(self, ipv4: str, **kwargs) -> None: + """ + Constructor. + Initialize the camera device with its IP address. + + Args: + ipv4 (str): The device's IPv4 address. + kwargs (dict): device-specific additional parameters. + """ + self.ipv4 = ipv4 + self._was_last_stream_successful = False + + + def is_event_successful(self, _) -> bool: + """ + Check if the last stream event was successful. + + Returns: + bool: True if the event was successful, False otherwise. + """ + return self._was_last_stream_successful diff --git a/smart_home_testbed/DeviceState/DeviceState.py b/smart_home_testbed/DeviceState/DeviceState.py new file mode 100644 index 0000000..2033ea5 --- /dev/null +++ b/smart_home_testbed/DeviceState/DeviceState.py @@ -0,0 +1,51 @@ +from typing import Any + + +class DeviceState: + """ + Abstract class which represent a device state. + """ + + def __init__(self, ipv4: str, **kwargs) -> None: + """ + Constructor. + Initialize the device with its IP address. + + Args: + ipv4 (str): The device's IPv4 address. + kwargs (dict): device-specific additional parameters. + """ + self.ipv4 = ipv4 + + # Set the device-specific parameters + for key, value in kwargs.items(): + setattr(self, key, value) + + + def get_state(self) -> Any: + """ + Get the state of the device. + + Returns: + Any: current state of the device. + """ + raise NotImplementedError + + + def is_event_successful(self, previous_state: Any) -> bool: + """ + Check if an event was successful, + i.e. if the device is still reachable + and its state has changed compared to the previous state. + + Args: + previous_state (Any): The state of the device before the event. + Returns: + bool: True if the event was successful, False otherwise. + """ + try: + state = self.get_state() + except Exception: + return False + else: + return state != previous_state diff --git a/smart_home_testbed/DeviceState/HueState.py b/smart_home_testbed/DeviceState/HueState.py new file mode 100644 index 0000000..ae51e68 --- /dev/null +++ b/smart_home_testbed/DeviceState/HueState.py @@ -0,0 +1,77 @@ +from typing import Awaitable +from enum import Enum +import contextlib +import asyncio +from aiohue import HueBridgeV2, create_app_key +from .LightState import LightState + + +class HueState(LightState): + """ + Class to represent the state of a Philips Hue light bulb. + """ + + + class StateKeys(Enum): + """ + Enum for the keys in the dictionary representing the state of the Philips Hue device. + """ + IS_ON = "is_on" + BRIGHTNESS = "brightness" + COLOR_X = "color_x" + COLOR_Y = "color_y" + + + @staticmethod + async def _get_app_key(bridge_ip: str) -> Awaitable[str]: + """ + Asynchronously get the app key for the Philips Hue bridge. + + Args: + bridge_ip (str): IP address of the Philips Hue bridge. + Returns: + Awaitable[str]: async function which returns the app key. + """ + input("Press the link button on the bridge and press enter to continue...") + return await create_app_key(bridge_ip, "authentication_example") + + + @classmethod + def get_app_key(cls, bridge_ip: str) -> str: + """ + Get the app key for the Philips Hue bridge. + + Args: + bridge_ip (str): IP address of the Philips Hue bridge. + Returns: + str: app key. + """ + with contextlib.suppress(KeyboardInterrupt): + asyncio.run(cls._get_app_key(bridge_ip)) + + + async def _async_get_state(self) -> Awaitable[dict]: + """ + Get the state of the Philips Hue light asynchronously. + + Returns: + Awaitable[dict]: async function which returns the status of the Philips Hue light. + """ + async with HueBridgeV2(self.ipv4, self.appkey) as bridge: + light = next(l for l in bridge.lights.items) + return { + self.StateKeys.IS_ON: light.is_on, + self.StateKeys.BRIGHTNESS: light.brightness, + self.StateKeys.COLOR_X: light.color.xy.x, + self.StateKeys.COLOR_Y: light.color.xy.y + } + + + def get_state(self) -> dict: + """ + Get the state of the Philips Hue light. + + Returns: + dict: The status of the Philips Hue light. + """ + return asyncio.run(self._async_get_state()) diff --git a/smart_home_testbed/DeviceState/LightState.py b/smart_home_testbed/DeviceState/LightState.py new file mode 100644 index 0000000..dfe7f24 --- /dev/null +++ b/smart_home_testbed/DeviceState/LightState.py @@ -0,0 +1,27 @@ +from enum import Enum +from .DeviceState import DeviceState + + +class LightState(DeviceState): + """ + Abstract class for the state of a light device. + """ + + class StateKeys(Enum): + """ + Enum for the keys in the dictionary representing the state of a light device. + """ + IS_ON = "is_on" + BRIGHTNESS = "brightness" + HUE = "hue" + SATURATION = "saturation" + + + def is_off(self) -> bool: + """ + Check if the light is on. + + Returns: + bool: True if the light is on, False otherwise. + """ + return not self.get_state()[self.StateKeys.IS_ON] diff --git a/smart_home_testbed/DeviceState/ScreenshotState.py b/smart_home_testbed/DeviceState/ScreenshotState.py new file mode 100644 index 0000000..4e6cf28 --- /dev/null +++ b/smart_home_testbed/DeviceState/ScreenshotState.py @@ -0,0 +1,46 @@ +import numpy as np +import cv2 +from skimage.metrics import structural_similarity as ssim +from .DeviceState import DeviceState + + +class ScreenshotState(DeviceState): + """ + Device state using app screenshot. + """ + + # SSIM threshold below which images are considered different + SSIM_DIFF_THRESHOLD = 0.95 + + + def get_state(self) -> np.ndarray: + """ + Get the state of the device, + by taking a screenshot of its app. + + Returns: + numpy.ndarray: The screenshot as a numpy array. + """ + return self.screenshot() + + + def is_event_successful(self, previous_state: np.ndarray) -> bool: + """ + Check if an event was successful, + i.e. if the current state has changed compared to the given previous state. + This method uses the SSIM metric to compare the screenshots. + + Args: + previous_state (numpy.ndarray): app screenshot, as a numpy array, which represents the previous state. + Returns: + bool: True if the event was successful, False otherwise. + """ + current_state = self.get_state() + + # Load images, and convert to grayscale + gray_previous = cv2.cvtColor(previous_state, cv2.COLOR_BGR2GRAY) + gray_current = cv2.cvtColor(current_state, cv2.COLOR_BGR2GRAY) + + # Compute SSIM + score = ssim(gray_previous, gray_current, full=False) + return score < self.SSIM_DIFF_THRESHOLD diff --git a/smart_home_testbed/DeviceState/SmartThingsState.py b/smart_home_testbed/DeviceState/SmartThingsState.py new file mode 100644 index 0000000..d97ee2c --- /dev/null +++ b/smart_home_testbed/DeviceState/SmartThingsState.py @@ -0,0 +1,72 @@ +from typing import List, Any, Awaitable +import asyncio +import aiohttp +from pysmartthings import SmartThings, DeviceEntity +from .DeviceState import DeviceState + + +class SmartThingsState(DeviceState): + """ + Abstract class which represents a generic SmartThings device's state. + """ + + + @staticmethod + async def _get_all_devices(token: str) -> Awaitable[List[DeviceEntity]]: + """ + Asynchronously get all the devices connected to SmartThings. + + Args: + token (str): SmartThings API token. + Returns: + Awaitable[List]: async function which returns the list of all devices. + """ + async with aiohttp.ClientSession() as session: + api = SmartThings(session, token) + devices = await api.devices() + return devices + + + @classmethod + def get_all_devices(cls) -> List[DeviceEntity]: + """ + Get all the devices connected to SmartThings. + + Returns: + List: list of all devices + """ + return asyncio.run(cls._get_all_devices()) + + + @classmethod + def print_all_devices(cls) -> None: + """ + Print the label, name and id of all devices connected to SmartThings. + """ + for device in cls.get_all_devices(): + print(device.label, device.name, device.device_id) + + + async def _async_get_state(self) -> Awaitable[Any]: + """ + Asynchronously get the SmartThings device status. + + Returns: + Awaitable[Any]: async function which returns the device status. + """ + async with aiohttp.ClientSession() as session: + api = SmartThings(session, self.token) + devices = await api.devices(device_ids=[self.id]) + device = devices[0] + await device.status.refresh() + return device.status + + + def get_state(self) -> Any: + """ + Get the device status. + + Returns: + Any: device status + """ + return asyncio.run(self._async_get_state()) diff --git a/smart_home_testbed/DeviceState/TapoLightState.py b/smart_home_testbed/DeviceState/TapoLightState.py new file mode 100644 index 0000000..b512191 --- /dev/null +++ b/smart_home_testbed/DeviceState/TapoLightState.py @@ -0,0 +1,26 @@ +from typing import Awaitable +from .TapoState import TapoState +from .LightState import LightState + + +class TapoLightState(TapoState, LightState): + """ + Class to represent the state of a Tapo light bulb. + """ + + + async def _async_get_state(self) -> Awaitable[dict]: + """ + Get the state of the light asynchronously. + + Returns: + Awaitable[dict]: async function which returns the state of the light. + """ + dev = await self.client.l530(self.ipv4) + info = await dev.get_device_info() + return { + self.StateKeys.IS_ON: info.device_on, + self.StateKeys.BRIGHTNESS: info.brightness, + self.StateKeys.HUE: info.hue, + self.StateKeys.SATURATION: info.saturation + } diff --git a/smart_home_testbed/DeviceState/TapoPlugState.py b/smart_home_testbed/DeviceState/TapoPlugState.py new file mode 100644 index 0000000..2e75596 --- /dev/null +++ b/smart_home_testbed/DeviceState/TapoPlugState.py @@ -0,0 +1,19 @@ +from typing import Awaitable +from .TapoState import TapoState + + +class TapoPlugState(TapoState): + """ + Class to represent the state of a Tapo plug. + """ + + async def _async_get_state(self) -> Awaitable[bool]: + """ + Asynchronously get the state of the Tapo plug. + + Returns: + Awaitable[bool]: async function which returns True if the plug is on, False otherwise. + """ + dev = await self.client.p110(self.ipv4) + info = await dev.get_device_info() + return info.device_on diff --git a/smart_home_testbed/DeviceState/TapoState.py b/smart_home_testbed/DeviceState/TapoState.py new file mode 100644 index 0000000..08f44b8 --- /dev/null +++ b/smart_home_testbed/DeviceState/TapoState.py @@ -0,0 +1,50 @@ +from typing import Any, Awaitable +import asyncio +from tapo import ApiClient +from .DeviceState import DeviceState + + +class TapoState(DeviceState): + """ + Class to represent the state of a generic Tapo device. + """ + + + def __init__(self, ipv4: str, **kwargs) -> None: + """ + Constructor. + Initializes the Tapo device with its IP address, + and creates a Tapo API client. + + Args: + ipv4 (str): The Tapo device's IPv4 address. + kwargs (dict): device-specific additional parameters, + including user's credentials. + """ + self.ipv4 = ipv4 + self.client = ApiClient( + kwargs.get("username", ""), + kwargs.get("password", "") + ) + + + async def _async_get_state(self) -> Awaitable[Any]: + """ + Asynchronously get the status of the generic Tapo device. + + Returns: + Awaitable[Any]: async function which returns the Tapo device's status. + """ + dev = await self.client.generic_device(self.ipv4) + info = await dev.get_device_info() + return info + + + def get_state(self) -> Any: + """ + Get the state of the Tapo device. + + Returns: + Any: Tapo device status + """ + return asyncio.run(self._async_get_state()) diff --git a/smart_home_testbed/DeviceState/TpLinkPlugState.py b/smart_home_testbed/DeviceState/TpLinkPlugState.py new file mode 100644 index 0000000..ff07a05 --- /dev/null +++ b/smart_home_testbed/DeviceState/TpLinkPlugState.py @@ -0,0 +1,22 @@ +import asyncio +from kasa import Discover +from .DeviceState import DeviceState + + +class TpLinkPlugState(DeviceState): + """ + Class to represent the state of a TP-Link plug. + """ + + + def get_state(self) -> bool: + """ + Get the state of the plug. + + Returns: + bool: True if the plug is on, False otherwise. + Raises: + TimeoutError: If the plug is not reachable. + """ + dev = asyncio.run(Discover.discover_single(self.ipv4)) + return dev.is_on diff --git a/smart_home_testbed/DeviceState/__init__.py b/smart_home_testbed/DeviceState/__init__.py new file mode 100644 index 0000000..1f3f6f6 --- /dev/null +++ b/smart_home_testbed/DeviceState/__init__.py @@ -0,0 +1,17 @@ +from .DeviceState import DeviceState +from .ScreenshotState import ScreenshotState +from .TapoState import TapoState +from .SmartThingsState import SmartThingsState + +# Plugs +from .TpLinkPlugState import TpLinkPlugState +from .TapoPlugState import TapoPlugState + +# Lights +from .LightState import LightState +from .HueState import HueState +from .TapoLightState import TapoLightState + +# Cameras +from .CameraState import CameraState +from .CameraScreenshotState import CameraScreenshotState diff --git a/smart_home_testbed/__init__.py b/smart_home_testbed/__init__.py new file mode 100644 index 0000000..6d400e9 --- /dev/null +++ b/smart_home_testbed/__init__.py @@ -0,0 +1,28 @@ +# Expose only concrete devices +from .devices import ( + init_device, + + # Plugs + TpLinkPlug, + TpLinkPlugTapo, + TpLinkPlugSmartThings, + TapoPlug, + TapoPlugSmartThings, + SmartThingsOutlet, + TuyaPlug, + + # Lights + TapoLight, + TapoLightSmartThings, + HueLight, + HueLightEssentials, + HueLightSmartThings, + TuyaLight, + + # Cameras + XiaomiCamera, + TapoCamera, + TapoCameraSmartThings, + DLinkCamera + +) diff --git a/smart_home_testbed/devices/.gitignore b/smart_home_testbed/devices/.gitignore new file mode 100644 index 0000000..b1542dd --- /dev/null +++ b/smart_home_testbed/devices/.gitignore @@ -0,0 +1,8 @@ +# All experimental output and results +*.png +*.nft +*.c +*.txt +*.pcap +*.csv +*.json diff --git a/smart_home_testbed/devices/__init__.py b/smart_home_testbed/devices/__init__.py new file mode 100644 index 0000000..cdb86ad --- /dev/null +++ b/smart_home_testbed/devices/__init__.py @@ -0,0 +1,27 @@ +from .plugs import ( + TpLinkPlug, + TpLinkPlugTapo, + TpLinkPlugSmartThings, + TapoPlug, + TapoPlugSmartThings, + SmartThingsOutlet, + TuyaPlug +) + +from .lights import ( + TapoLight, + TapoLightSmartThings, + HueLight, + HueLightEssentials, + HueLightSmartThings, + TuyaLight +) + +from .cameras import ( + XiaomiCamera, + TapoCamera, + TapoCameraSmartThings, + DLinkCamera +) + +from .init_device import init_device diff --git a/smart_home_testbed/devices/cameras/DLinkCamera/DLinkCamera.py b/smart_home_testbed/devices/cameras/DLinkCamera/DLinkCamera.py new file mode 100644 index 0000000..3c2b9ac --- /dev/null +++ b/smart_home_testbed/devices/cameras/DLinkCamera/DLinkCamera.py @@ -0,0 +1,35 @@ +import os +from ....DeviceState import CameraScreenshotState +from ....DeviceControl import CameraControl + + +class DLinkCamera(CameraScreenshotState, CameraControl): + """ + DLink camera (DCS-8000LH). + """ + + ### Class attributes + # Android package + android_package = "com.dlink.mydlinkunified" + # SSIM threshold below which images are considered different + SSIM_DIFF_THRESHOLD = 0.9 + ## Screen coordinates + # Stream event + x_start = 424.8 + y_start = 907.2 + x_stop = 57.6 + y_stop = 1008 + # Error message + x_error = 360 + y_error = 806.4 + + + def start_stream(self) -> None: + """ + Dismiss the potential error message, + then start the camera's video stream. + Overwrites the method from the parent class CameraControl. + """ + phone = self.get_phone() + phone.shell(f"input tap {self.x_error} {self.y_error}") + phone.shell(f"input tap {self.x_start} {self.y_start}") diff --git a/smart_home_testbed/devices/cameras/DLinkCamera/__init__.py b/smart_home_testbed/devices/cameras/DLinkCamera/__init__.py new file mode 100644 index 0000000..6e9d82f --- /dev/null +++ b/smart_home_testbed/devices/cameras/DLinkCamera/__init__.py @@ -0,0 +1 @@ +from .DLinkCamera import DLinkCamera diff --git a/smart_home_testbed/devices/cameras/TapoCamera/TapoCamera.py b/smart_home_testbed/devices/cameras/TapoCamera/TapoCamera.py new file mode 100644 index 0000000..0d26b3e --- /dev/null +++ b/smart_home_testbed/devices/cameras/TapoCamera/TapoCamera.py @@ -0,0 +1,20 @@ +from ....DeviceState import CameraScreenshotState +from ....DeviceControl import CameraControl + + +class TapoCamera(CameraScreenshotState, CameraControl): + """ + Tapo camera (C200). + """ + + ### Class attributes + # Android package + android_package = "com.tplink.iot" + # SSIM threshold below which images are considered different + SSIM_DIFF_THRESHOLD = 0.95 + ## Screen coordinates + # Stream event + x_start = 187.2 + y_start = 446.4 + x_stop = 50.4 + y_stop = 100.8 diff --git a/smart_home_testbed/devices/cameras/TapoCamera/__init__.py b/smart_home_testbed/devices/cameras/TapoCamera/__init__.py new file mode 100644 index 0000000..79c1e41 --- /dev/null +++ b/smart_home_testbed/devices/cameras/TapoCamera/__init__.py @@ -0,0 +1 @@ +from .TapoCamera import TapoCamera diff --git a/smart_home_testbed/devices/cameras/TapoCameraSmartThings/TapoCameraSmartThings.py b/smart_home_testbed/devices/cameras/TapoCameraSmartThings/TapoCameraSmartThings.py new file mode 100644 index 0000000..74f97db --- /dev/null +++ b/smart_home_testbed/devices/cameras/TapoCameraSmartThings/TapoCameraSmartThings.py @@ -0,0 +1,59 @@ +import time +from ....DeviceState import CameraScreenshotState +from ....DeviceControl import CameraControl, SmartThingsControl + + +class TapoCameraSmartThings(CameraScreenshotState, CameraControl, SmartThingsControl): + """ + Tapo camera (C200), + controlled through the SmartThings app. + """ + + ### Class attributes + # SSIM threshold below which images are considered different + SSIM_DIFF_THRESHOLD = 0.975 + ## Screen coordinates + # Device controls button + device_x = 532.8 + device_y = 792 + # Expand stream + x_expand = 525.6 + y_expand = 561.6 + # Toggle stream + x_start = 720 + y_start = 360 + + + def start_app(self) -> None: + """ + Start the SmartThings app on the phone, + and open the camera stream. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + # Start SmartThings app + phone.shell(f"monkey -p {SmartThingsControl.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Open "Devices" tab + phone.shell(f"input tap {SmartThingsControl.devices_x} {SmartThingsControl.devices_y}") + time.sleep(5) + # Open device controls + phone.shell(f"input tap {self.device_x} {self.device_y}") + time.sleep(15) + # Expand stream to fullscreen + phone.shell(f"input tap {self.x_expand} {self.y_expand}") + phone.shell(f"input tap {self.x_expand} {self.y_expand}") + time.sleep(3) + # Pause stream + phone.shell(f"input tap {self.x_start} {self.y_start}") + + + def do_stop_stream(self) -> None: + """ + Stop the camera's video stream. + """ + phone = self.get_phone() + phone.shell(f"input tap {self.x_start} {self.y_start}") + phone.shell(f"input tap {self.x_start} {self.y_start}") diff --git a/smart_home_testbed/devices/cameras/TapoCameraSmartThings/__init__.py b/smart_home_testbed/devices/cameras/TapoCameraSmartThings/__init__.py new file mode 100644 index 0000000..b9ace01 --- /dev/null +++ b/smart_home_testbed/devices/cameras/TapoCameraSmartThings/__init__.py @@ -0,0 +1 @@ +from .TapoCameraSmartThings import TapoCameraSmartThings diff --git a/smart_home_testbed/devices/cameras/XiaomiCamera/XiaomiCamera.py b/smart_home_testbed/devices/cameras/XiaomiCamera/XiaomiCamera.py new file mode 100644 index 0000000..4a8e641 --- /dev/null +++ b/smart_home_testbed/devices/cameras/XiaomiCamera/XiaomiCamera.py @@ -0,0 +1,82 @@ +import time +from miio import ChuangmiCamera +from ....DeviceControl import CameraControl + + +class XiaomiCamera(CameraControl, ChuangmiCamera): + """ + Xiaomi camera (Chuangmi IPC019). + """ + + ### Class variables + # Android package name + android_package = "com.xiaomi.smarthome" + ## Screen coordinates + # Zone button + x_zone = 345.6 + y_zone = 201.6 + # Camera control button + x_camera = 619.2 + y_camera = 331.2 + # Stream event + x_start = 100.8 + y_start = 489.6 + + + def __init__(self, ipv4: str, **kwargs) -> None: + """ + Constructor. + Initialize the Xiaomi camera (Chuangmi IPC019) with its IP address. + + Args: + ipv4 (str): The device's IPv4 address. + kwargs (dict): device-specific additional parameters, including device API token. + """ + ChuangmiCamera.__init__(self, ip=ipv4, token=kwargs.get("token", "")) + self._was_last_stream_successful = False + + + def start_app(self) -> None: + """ + Start the Mi Home app on the phone, + then switch to the zone control screen, + then to the camera's stream. + + Raises: + IndexError: If no adb device is found. + """ + # Start the Mi Home app + phone = self.get_phone() + phone.shell(f"monkey -p {XiaomiCamera.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Switch to the camera control screen + phone.shell(f"input tap {self.x_zone} {self.y_zone}") + phone.shell(f"input tap {self.x_camera} {self.y_camera}") + + + def start_stream(self) -> None: + """ + Start the camera's video stream. + Overrides the method from the `CameraControl` class. + """ + phone = self.get_phone() + phone.shell(f"input tap {self.x_start} {self.y_start}") + phone.shell(f"input tap {self.x_start} {self.y_start}") + + + def do_stop_stream(self) -> None: + """ + Stop the camera's video stream. + Overrides the method from the `CameraControl` class. + """ + self.start_stream() + + + def get_state(self) -> bool: + """ + Get the state of the camera. + + Returns: + bool: True if the camera is on, False otherwise. + """ + return self.status().power diff --git a/smart_home_testbed/devices/cameras/XiaomiCamera/__init__.py b/smart_home_testbed/devices/cameras/XiaomiCamera/__init__.py new file mode 100644 index 0000000..23c032b --- /dev/null +++ b/smart_home_testbed/devices/cameras/XiaomiCamera/__init__.py @@ -0,0 +1 @@ +from .XiaomiCamera import XiaomiCamera diff --git a/smart_home_testbed/devices/cameras/__init__.py b/smart_home_testbed/devices/cameras/__init__.py new file mode 100644 index 0000000..6f48c9a --- /dev/null +++ b/smart_home_testbed/devices/cameras/__init__.py @@ -0,0 +1,4 @@ +from .XiaomiCamera import XiaomiCamera +from .TapoCamera import TapoCamera +from .TapoCameraSmartThings import TapoCameraSmartThings +from .DLinkCamera import DLinkCamera diff --git a/smart_home_testbed/devices/init_device.py b/smart_home_testbed/devices/init_device.py new file mode 100644 index 0000000..41508b6 --- /dev/null +++ b/smart_home_testbed/devices/init_device.py @@ -0,0 +1,20 @@ +from typing import Any +import importlib + + +def init_device(name: str, ipv4: str, **kwargs) -> Any: + """ + Factory method to create a device object. + + Args: + name (str): The name of the device. + ipv4 (str): The device's IPv4 address. + kwargs (dict): device-specific additional parameters. + Returns: + Any: The device object. + """ + package_parts = importlib.import_module(__name__).__name__.split(".") + package_name = f"{package_parts[0]}.{package_parts[1]}" + package = importlib.import_module(package_name) + cls = getattr(package, name) + return cls(ipv4, **kwargs) diff --git a/smart_home_testbed/devices/lights/HueLight/HueLight.py b/smart_home_testbed/devices/lights/HueLight/HueLight.py new file mode 100644 index 0000000..6e9cf34 --- /dev/null +++ b/smart_home_testbed/devices/lights/HueLight/HueLight.py @@ -0,0 +1,46 @@ +import time +from ....DeviceState import HueState +from ....DeviceControl import LightControl + + +class HueLight(HueState, LightControl): + """ + Philips Hue light bulb. + """ + + ### Class variables + # Android package + android_package = "com.philips.lighting.hue2" + ## Screen coordinates + # Zone button + x_zone = 360 + y_zone = 446.4 + # Toggle button + x = 136.8 + y = 1022.4 + + + def start_app(self) -> None: + """ + Start the Philips Hue app on the phone, + and open the light control screen. + + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + # Open app + phone.shell(f"monkey -p {self.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Open zone controls + phone.shell(f"input tap {self.x_zone} {self.y_zone}") + + + def set_brightness(self) -> None: + raise NotImplementedError("Brightness cannot be set randomly for Philips Hue lights controlled through the Hue app.") + + + def set_color(self) -> None: + raise NotImplementedError("Color cannot be set randomly for Philips Hue lights controlled through the Hue app.") + + diff --git a/smart_home_testbed/devices/lights/HueLight/__init__.py b/smart_home_testbed/devices/lights/HueLight/__init__.py new file mode 100644 index 0000000..41d22d2 --- /dev/null +++ b/smart_home_testbed/devices/lights/HueLight/__init__.py @@ -0,0 +1 @@ +from .HueLight import HueLight diff --git a/smart_home_testbed/devices/lights/HueLightEssentials/HueLightEssentials.py b/smart_home_testbed/devices/lights/HueLightEssentials/HueLightEssentials.py new file mode 100644 index 0000000..266b13c --- /dev/null +++ b/smart_home_testbed/devices/lights/HueLightEssentials/HueLightEssentials.py @@ -0,0 +1,74 @@ +import time +import math +import random +from ....DeviceState import HueState +from ....DeviceControl import LightControl + + +class HueLightEssentials(HueState, LightControl): + """ + Philips Hue light bulb, + controlled by the Hue Essentials app. + """ + + ### Class variables + # Android package + android_package = "com.superthomaslab.hueessentials" + ## Screen coordinates + # Zone button + x_zone = 360 + y_zone = 374.4 + # Lamp button + x_lamp = 360 + y_lamp = 475.2 + # Toggle button + x = 626.4 + y = 446.4 + # Brightness + x_left_gauge = 67 + x_right_gauge = 676 + y_brightness = 230.4 + # Color + x_color_center = 360 + y_color_center = 792 + y_color_top = 472 + + + def start_app(self, open_lamp_control: bool = False) -> None: + """ + Start the Hue Essentials app on the phone, + and open the light control screen. + + Args: + open_lamp_control (bool): Whether to additionally open the lamp control screen after having opened the app. + Optional, default is False. + Raises: + IndexError: If no adb device is found. + """ + phone = self.get_phone() + # Open app + phone.shell(f"monkey -p {self.android_package} -c android.intent.category.LAUNCHER 1") + time.sleep(10) + # Open zone controls + phone.shell(f"input tap {self.x_zone} {self.y_zone}") + # If needed, open lamp controls + if open_lamp_control: + time.sleep(3) + phone.shell(f"input tap {self.x_lamp} {self.y_lamp}") + + + def do_set_color(self) -> None: + """ + Randomly set the color of the Philips Hue light + through the Hue Essentials app. + """ + # Compute color wheel's radius + radius = self.y_color_center - self.y_color_top + # Generate random position on the color wheel + distance = random.randint(0, radius) + angle_deg = random.randint(0, 360) + angle_rad = angle_deg * math.pi / 180 + x = self.x_color_center + distance * math.cos(angle_rad) + y = self.y_color_center + distance * math.sin(angle_rad) + # Set the color + self.get_phone().shell(f"input tap {x} {y}") diff --git a/smart_home_testbed/devices/lights/HueLightEssentials/__init__.py b/smart_home_testbed/devices/lights/HueLightEssentials/__init__.py new file mode 100644 index 0000000..dd5761e --- /dev/null +++ b/smart_home_testbed/devices/lights/HueLightEssentials/__init__.py @@ -0,0 +1 @@ +from .HueLightEssentials import HueLightEssentials diff --git a/smart_home_testbed/devices/lights/HueLightSmartThings/HueLightSmartThings.py b/smart_home_testbed/devices/lights/HueLightSmartThings/HueLightSmartThings.py new file mode 100644 index 0000000..cc01707 --- /dev/null +++ b/smart_home_testbed/devices/lights/HueLightSmartThings/HueLightSmartThings.py @@ -0,0 +1,14 @@ +from ....DeviceState import HueState +from ....DeviceControl import SmartThingsLightControl + + +class HueLightSmartThings(HueState, SmartThingsLightControl): + """ + Philips Hue light bulb, + controlled by the SmartThings app. + """ + + ## Class variables + # Screen coordinates to open the device controls + device_x = 187.2 + device_y = 1051.2 diff --git a/smart_home_testbed/devices/lights/HueLightSmartThings/__init__.py b/smart_home_testbed/devices/lights/HueLightSmartThings/__init__.py new file mode 100644 index 0000000..e68bf0c --- /dev/null +++ b/smart_home_testbed/devices/lights/HueLightSmartThings/__init__.py @@ -0,0 +1 @@ +from .HueLightSmartThings import HueLightSmartThings diff --git a/smart_home_testbed/devices/lights/TapoLight/TapoLight.py b/smart_home_testbed/devices/lights/TapoLight/TapoLight.py new file mode 100644 index 0000000..d8a2f24 --- /dev/null +++ b/smart_home_testbed/devices/lights/TapoLight/TapoLight.py @@ -0,0 +1,34 @@ +from ....DeviceState import TapoLightState +from ....DeviceControl import TapoControl, LightControl + + +class TapoLight(TapoLightState, TapoControl, LightControl): + """ + Tapo L530E light. + """ + + ## Class variables + # Device controls coordinates + device_x = 187.2 + device_y = 705.6 + # Toggle coordinates + x = 612 + y = 619.2 + # Brightness and color gauges + x_left_gauge = 103 + x_right_gauge = 640 + # Brightness + y_brightness = 1108.8 + # Color + x_color_tab = 230.4 + y_color_tab = 1238.4 + y_color = 1310.4 + + + def do_set_color(self) -> None: + """ + Ensure the "Color" tab is selected, + then randomly set the color of the light. + """ + self.get_phone().shell(f"input tap {self.x_color_tab} {self.y_color_tab}") + self._set_light_attr(self.y_color) diff --git a/smart_home_testbed/devices/lights/TapoLight/__init__.py b/smart_home_testbed/devices/lights/TapoLight/__init__.py new file mode 100644 index 0000000..7eaedee --- /dev/null +++ b/smart_home_testbed/devices/lights/TapoLight/__init__.py @@ -0,0 +1 @@ +from .TapoLight import TapoLight diff --git a/smart_home_testbed/devices/lights/TapoLightSmartThings/TapoLightSmartThings.py b/smart_home_testbed/devices/lights/TapoLightSmartThings/TapoLightSmartThings.py new file mode 100644 index 0000000..4b04c7a --- /dev/null +++ b/smart_home_testbed/devices/lights/TapoLightSmartThings/TapoLightSmartThings.py @@ -0,0 +1,14 @@ +from ....DeviceState import TapoLightState +from ....DeviceControl import SmartThingsLightControl + + +class TapoLightSmartThings(TapoLightState, SmartThingsLightControl): + """ + Tapo light bulb, + controlled by the SmartThings app. + """ + + ## Class variables + # Screen coordinates to open the device controls + device_x = 532.8 + device_y = 1051.2 diff --git a/smart_home_testbed/devices/lights/TapoLightSmartThings/__init__.py b/smart_home_testbed/devices/lights/TapoLightSmartThings/__init__.py new file mode 100644 index 0000000..b670d8d --- /dev/null +++ b/smart_home_testbed/devices/lights/TapoLightSmartThings/__init__.py @@ -0,0 +1 @@ +from .TapoLightSmartThings import TapoLightSmartThings diff --git a/smart_home_testbed/devices/lights/TuyaLight/TuyaLight.py b/smart_home_testbed/devices/lights/TuyaLight/TuyaLight.py new file mode 100644 index 0000000..56d3f12 --- /dev/null +++ b/smart_home_testbed/devices/lights/TuyaLight/TuyaLight.py @@ -0,0 +1,84 @@ +import math +import random +import cv2 +from skimage.metrics import structural_similarity as ssim +from ....DeviceState import ScreenshotState +from ....DeviceControl import TuyaControl, LightControl + + +class TuyaLight(ScreenshotState, TuyaControl, LightControl): + """ + Generic Tuya light bulb. + """ + + # Default filename for the OFF screenshot + FILENAME_SCREENSHOT_OFF = "off.png" + # SSIM thresholds + SSIM_DIFF_THRESHOLD = 0.99 # State change + OFF_SSIM_DIFF_THRESHOLD = 0.95 # Check if light is off + + ### Screen coordinates + # Device controls + device_x = 345.6 + device_y = 590.4 + ## Events + # Toggle + x = 360 + y = 1281.6 + # Brightness + x_left_gauge = 164 + x_right_gauge = 580 + y_brightness = 936 + # Color + x_color_center = 360 + y_color_center = 590.4 + y_color_ring = 400 + + + def __init__(self, ipv4: str, **kwargs) -> None: + """ + Constructor. + Initializes the Tuya light bulb with its IP address, + and computes the grayscale array of the bulb's OFF screenshot. + + Args: + ipv4 (str): IP address of the Tuya light bulb. + kwargs (dict): device-specific additional parameters, + including the path to the OFF screenshot. + """ + # Call parent constructor + super().__init__(ipv4, **kwargs) + + # Compute gray array of streaming screenshot + image_off = cv2.imread(kwargs.get("path_screenshot_off", TuyaLight.FILENAME_SCREENSHOT_OFF)) + self.image_off_gray = cv2.cvtColor(image_off, cv2.COLOR_BGR2GRAY) + + + def do_set_color(self) -> None: + """ + Randomly set the color of the Tuya light. + """ + # Generate random position on the color ring + radius = self.y_color_center - self.y_color_ring + angle_deg = random.randint(0, 360) + angle_rad = angle_deg * math.pi / 180 + x = self.x_color_center + radius * math.cos(angle_rad) + y = self.y_color_center + radius * math.sin(angle_rad) + # Set the color + self.get_phone().shell(f"input tap {x} {y}") + + + def is_off(self): + """ + Check if the light is off. + """ + # Take screenshot and convert to grayscale + state = self.get_state() + screenshot_gray = cv2.cvtColor(state, cv2.COLOR_BGR2GRAY) + + # Compute SSIM with OFF screenshot + try: + score = ssim(self.image_off_gray, screenshot_gray, full=False) + return score > self.OFF_SSIM_DIFF_THRESHOLD + except ValueError: + return False diff --git a/smart_home_testbed/devices/lights/TuyaLight/__init__.py b/smart_home_testbed/devices/lights/TuyaLight/__init__.py new file mode 100644 index 0000000..9d1c1e4 --- /dev/null +++ b/smart_home_testbed/devices/lights/TuyaLight/__init__.py @@ -0,0 +1 @@ +from .TuyaLight import TuyaLight diff --git a/smart_home_testbed/devices/lights/__init__.py b/smart_home_testbed/devices/lights/__init__.py new file mode 100644 index 0000000..fc5a6d1 --- /dev/null +++ b/smart_home_testbed/devices/lights/__init__.py @@ -0,0 +1,6 @@ +from .TapoLight import TapoLight +from .TapoLightSmartThings import TapoLightSmartThings +from .HueLight import HueLight +from .HueLightEssentials import HueLightEssentials +from .HueLightSmartThings import HueLightSmartThings +from .TuyaLight import TuyaLight diff --git a/smart_home_testbed/devices/plugs/SmartThingsOutlet/SmartThingsOutlet.py b/smart_home_testbed/devices/plugs/SmartThingsOutlet/SmartThingsOutlet.py new file mode 100644 index 0000000..fa217b6 --- /dev/null +++ b/smart_home_testbed/devices/plugs/SmartThingsOutlet/SmartThingsOutlet.py @@ -0,0 +1,25 @@ +from typing import Awaitable +from ....DeviceState import SmartThingsState +from ....DeviceControl import SmartThingsPlugControl + + +class SmartThingsOutlet(SmartThingsState, SmartThingsPlugControl): + """ + SmartThings Outlet. + """ + + ## Class attributes + # Screen to open the device controls from the "Devices" tab + device_x = 187.2 + device_y = 532.8 + + + async def _async_get_state(self) -> Awaitable[bool]: + """ + Asynchronously get the state of the plug. + + Returns: + Awaitable[bool]: async function which returns True if the plug is on, False otherwise. + """ + state = await super()._async_get_state() + return state.switch diff --git a/smart_home_testbed/devices/plugs/SmartThingsOutlet/__init__.py b/smart_home_testbed/devices/plugs/SmartThingsOutlet/__init__.py new file mode 100644 index 0000000..530bb4e --- /dev/null +++ b/smart_home_testbed/devices/plugs/SmartThingsOutlet/__init__.py @@ -0,0 +1 @@ +from .SmartThingsOutlet import SmartThingsOutlet diff --git a/smart_home_testbed/devices/plugs/TapoPlug/TapoPlug.py b/smart_home_testbed/devices/plugs/TapoPlug/TapoPlug.py new file mode 100644 index 0000000..4944bc5 --- /dev/null +++ b/smart_home_testbed/devices/plugs/TapoPlug/TapoPlug.py @@ -0,0 +1,12 @@ +from ....DeviceState import TapoPlugState +from ....DeviceControl import TapoPlugControl + + +class TapoPlug(TapoPlugState, TapoPlugControl): + """ + Tapo P110 plug. + """ + + # Screen coordinates for device controls + device_x = 532.8 + device_y = 446.4 diff --git a/smart_home_testbed/devices/plugs/TapoPlug/__init__.py b/smart_home_testbed/devices/plugs/TapoPlug/__init__.py new file mode 100644 index 0000000..2be0b89 --- /dev/null +++ b/smart_home_testbed/devices/plugs/TapoPlug/__init__.py @@ -0,0 +1 @@ +from .TapoPlug import TapoPlug diff --git a/smart_home_testbed/devices/plugs/TapoPlugSmartThings/TapoPlugSmartThings.py b/smart_home_testbed/devices/plugs/TapoPlugSmartThings/TapoPlugSmartThings.py new file mode 100644 index 0000000..da0a7ca --- /dev/null +++ b/smart_home_testbed/devices/plugs/TapoPlugSmartThings/TapoPlugSmartThings.py @@ -0,0 +1,14 @@ +from ....DeviceState import TapoPlugState +from ....DeviceControl import SmartThingsPlugControl + + +class TapoPlugSmartThings(TapoPlugState, SmartThingsPlugControl): + """ + Tapo P110 plug, + controlled by the SmartThings app. + """ + + ## Class attributes + # Screen coordinates to open the device controls + device_x = 187.2 + device_y = 792 diff --git a/smart_home_testbed/devices/plugs/TapoPlugSmartThings/__init__.py b/smart_home_testbed/devices/plugs/TapoPlugSmartThings/__init__.py new file mode 100644 index 0000000..158e84c --- /dev/null +++ b/smart_home_testbed/devices/plugs/TapoPlugSmartThings/__init__.py @@ -0,0 +1 @@ +from .TapoPlugSmartThings import TapoPlugSmartThings diff --git a/smart_home_testbed/devices/plugs/TpLinkPlug/TpLinkPlug.py b/smart_home_testbed/devices/plugs/TpLinkPlug/TpLinkPlug.py new file mode 100644 index 0000000..a91e915 --- /dev/null +++ b/smart_home_testbed/devices/plugs/TpLinkPlug/TpLinkPlug.py @@ -0,0 +1,15 @@ +from ....DeviceState import TpLinkPlugState +from ....DeviceControl import PlugControl + + +class TpLinkPlug(TpLinkPlugState, PlugControl): + """ + TP-Link HS110 plug. + """ + + ## Class variables + # Android package name + android_package = "com.tplink.kasa_android" + # Toggle coordinates + x = 620 + y = 455 diff --git a/smart_home_testbed/devices/plugs/TpLinkPlug/__init__.py b/smart_home_testbed/devices/plugs/TpLinkPlug/__init__.py new file mode 100644 index 0000000..69691ea --- /dev/null +++ b/smart_home_testbed/devices/plugs/TpLinkPlug/__init__.py @@ -0,0 +1 @@ +from .TpLinkPlug import TpLinkPlug diff --git a/smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/TpLinkPlugSmartThings.py b/smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/TpLinkPlugSmartThings.py new file mode 100644 index 0000000..3f5125c --- /dev/null +++ b/smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/TpLinkPlugSmartThings.py @@ -0,0 +1,14 @@ +from ....DeviceState import TpLinkPlugState +from ....DeviceControl import SmartThingsPlugControl + + +class TpLinkPlugSmartThings(TpLinkPlugState, SmartThingsPlugControl): + """ + TP-Link HS110 plug, + controlled through SmartThings. + """ + + ## Class attributes + # Screen to open the device controls + device_x = 532.8 + device_y = 532.8 diff --git a/smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/__init__.py b/smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/__init__.py new file mode 100644 index 0000000..92c0f0b --- /dev/null +++ b/smart_home_testbed/devices/plugs/TpLinkPlugSmartThings/__init__.py @@ -0,0 +1 @@ +from .TpLinkPlugSmartThings import TpLinkPlugSmartThings diff --git a/smart_home_testbed/devices/plugs/TpLinkPlugTapo/TpLinkPlugTapo.py b/smart_home_testbed/devices/plugs/TpLinkPlugTapo/TpLinkPlugTapo.py new file mode 100644 index 0000000..bb34228 --- /dev/null +++ b/smart_home_testbed/devices/plugs/TpLinkPlugTapo/TpLinkPlugTapo.py @@ -0,0 +1,13 @@ +from ....DeviceState import TpLinkPlugState +from ....DeviceControl import TapoPlugControl + + +class TpLinkPlugTapo(TpLinkPlugState, TapoPlugControl): + """ + TP-Link HS110 plug, + controlled through the Tapo app. + """ + + # Screen coordinates for device controls + device_x = 532.8 + device_y = 705.6 diff --git a/smart_home_testbed/devices/plugs/TpLinkPlugTapo/__init__.py b/smart_home_testbed/devices/plugs/TpLinkPlugTapo/__init__.py new file mode 100644 index 0000000..b3f343d --- /dev/null +++ b/smart_home_testbed/devices/plugs/TpLinkPlugTapo/__init__.py @@ -0,0 +1 @@ +from .TpLinkPlugTapo import TpLinkPlugTapo diff --git a/smart_home_testbed/devices/plugs/TuyaPlug/TuyaPlug.py b/smart_home_testbed/devices/plugs/TuyaPlug/TuyaPlug.py new file mode 100644 index 0000000..9ba9de9 --- /dev/null +++ b/smart_home_testbed/devices/plugs/TuyaPlug/TuyaPlug.py @@ -0,0 +1,16 @@ +from ....DeviceState import ScreenshotState +from ....DeviceControl import TuyaControl, PlugControl + + +class TuyaPlug(ScreenshotState, TuyaControl, PlugControl): + """ + Tuya generic plug. + """ + + ## Screen coordinates + # Device controls screen coordinates + device_x = 345.6 + device_y = 360 + # Toggle coordinates + x = 360 + y = 561.6 diff --git a/smart_home_testbed/devices/plugs/TuyaPlug/__init__.py b/smart_home_testbed/devices/plugs/TuyaPlug/__init__.py new file mode 100644 index 0000000..03ff64b --- /dev/null +++ b/smart_home_testbed/devices/plugs/TuyaPlug/__init__.py @@ -0,0 +1 @@ +from .TuyaPlug import TuyaPlug diff --git a/smart_home_testbed/devices/plugs/__init__.py b/smart_home_testbed/devices/plugs/__init__.py new file mode 100644 index 0000000..0f38dc4 --- /dev/null +++ b/smart_home_testbed/devices/plugs/__init__.py @@ -0,0 +1,7 @@ +from .TpLinkPlug import TpLinkPlug +from .TpLinkPlugTapo import TpLinkPlugTapo +from .TpLinkPlugSmartThings import TpLinkPlugSmartThings +from .TapoPlug import TapoPlug +from .TapoPlugSmartThings import TapoPlugSmartThings +from .SmartThingsOutlet import SmartThingsOutlet +from .TuyaPlug import TuyaPlug -- GitLab