tuya-cloudcutter

A tool that disconnects Tuya IoT devices from the cloud, allowing them to run completely locally.

import colorsys
from dataclasses import dataclass
from typing import Tuple, List, Literal

import tinytuya

HSV = Tuple[float, float, float]

SCENE_CHANGE_MODES = {'static': '00', 'flash': '01', 'breath': '02'}
SCENE_NAMES = {'sleep': '04', 'romantic': '05', 'party': '06', 'relaxing': '07'}
SCENE_SPEED_MIN, SCENE_SPEED_MAX = 0x2828, 0x6464


class GalaxyProjector:
    """
    Works with the Galaxy Projector from galaxylamps.co:
    https://eu.galaxylamps.co/collections/all/products/galaxy-projector
    """

    def __init__(self, tuya_device_id: str, device_ip_addr: str, tuya_secret_key: str):
        self.device = tinytuya.BulbDevice(tuya_device_id, device_ip_addr, tuya_secret_key)
        self.device.set_version(3.3)
        self.state = GalaxyProjectorState()
        self.update_state()

    def set_device_power(self, *, on: bool):
        self.state.update(self.device.set_status(switch=20, on=on))

    def set_stars_power(self, *, on: bool):
        self.state.update(self.device.set_status(switch=102, on=on))

    def set_nebula_power(self, *, on: bool):
        self.state.update(self.device.set_status(switch=103, on=on))

    def set_rotation_speed(self, *, percent: float):
        value = int(10 + (1000 - 10) * min(max(percent, 0), 100) / 100)
        self.state.update(self.device.set_value(101, value))

    def set_stars_brightness(self, *, percent: float):
        self.state.update(self.device.set_white_percentage(percent))

    def set_nebula_color(self, *, hsv: HSV):
        """scene mode needs to be off to set static nebula color"""
        self.state.update(self.device.set_hsv(*hsv))

    def set_scene_mode(self, *, on: bool):
        self.state.update(self.device.set_mode('scene' if on else 'colour'))
        # differentiation between 'white' and 'colour' not relevant for this device

    def set_scene(self, parts: List["SceneTransition"]):
        """scene mode needs to be on"""
        output = SCENE_NAMES['party']  # scene name doesn't seem to matter
        for part in parts:
            output += hex(int(
                part.change_speed_percent / 100 * (SCENE_SPEED_MAX - SCENE_SPEED_MIN) + SCENE_SPEED_MIN))[2:]
            output += str(SCENE_CHANGE_MODES[part.change_mode])
            output += hsv2tuyahex(*part.nebula_hsv) + '00000000'
        self.device.set_value(25, output)
        self.update_state()  # return value of previous command is truncated and not usable for state update

    def update_state(self):
        self.state.update(self.device.status())


@dataclass
class SceneTransition:
    change_speed_percent: int
    change_mode: Literal['static', 'flash', 'breath']
    nebula_hsv: HSV


class GalaxyProjectorState:
    """
    Data Points (dps):
    20 device on/off
    21 work_mode: white(stars), colour (nebula), scene, music
    22 stars brightness 10-1000
    24 nebula hsv
    25 scene value
    26 shutdown timer
    101 stars speed 10-1000
    102 stars on/off
    103 nebula on/off
    """

    def __init__(self, dps=None):
        self.dps = dps or {}

    def update(self, payload):
        payload = payload or {'dps': {}}
        if 'Err' in payload:
            raise Exception(payload)
        self.dps.update(payload['dps'])

    @property
    def device_on(self) -> bool:
        return self.dps['20']

    @property
    def stars_on(self) -> bool:
        return self.dps['102']

    @property
    def nebula_on(self) -> bool:
        return self.dps['103']

    @property
    def scene_mode(self) -> bool:
        return self.dps['21'] == 'scene'

    @property
    def scene(self) -> List["SceneTransition"]:
        output = []
        hex_scene = self.dps['25']
        hex_scene_name = hex_scene[0:2]  # scene name doesn't seem to matter
        i = 2
        while i < len(hex_scene):
            hex_scene_speed = int(hex_scene[i:i + 4], 16)
            hex_scene_change = hex_scene[i + 4:i + 6]
            hex_scene_color = hex_scene[i + 6:i + 18]
            for k, v in SCENE_CHANGE_MODES.items():
                if v == hex_scene_change:
                    scene_change = k
                    break
            else:
                raise Exception(f'unknown scene change value: {hex_scene_change}')

            output.append(SceneTransition(
                change_speed_percent=round(
                    (hex_scene_speed - SCENE_SPEED_MIN) * 100 / (SCENE_SPEED_MAX - SCENE_SPEED_MIN)),
                change_mode=scene_change,
                nebula_hsv=tuyahex2hsv(hex_scene_color)
            ))
            i += 26
        return output

    @property
    def stars_brightness_percent(self):
        return int((self.dps['22'] - 10) * 100 / (1000 - 10))

    @property
    def rotation_speed_percent(self):
        return int((self.dps['101'] - 10) * 100 / (1000 - 10))

    @property
    def nebula_hsv(self) -> HSV:
        return tuyahex2hsv(self.dps['24'])

    def __repr__(self):
        return f'GalaxyProjectorState<{self.parsed_value}>'

    @property
    def parsed_value(self):
        return {k: getattr(self, k) for k in (
            'device_on', 'stars_on', 'nebula_on', 'scene_mode', 'scene', 'stars_brightness_percent',
            'rotation_speed_percent', 'nebula_hsv')}


def tuyahex2hsv(val: str):
    return tinytuya.BulbDevice._hexvalue_to_hsv(val, bulb="B")


def hsv2tuyahex(h: float, s: float, v: float):
    (r, g, b) = colorsys.hsv_to_rgb(h, s, v)
    hexvalue = tinytuya.BulbDevice._rgb_to_hexvalue(
        r * 255.0, g * 255.0, b * 255.0, bulb='B'
    )
    return hexvalue


if __name__ == '__main__':
    proj = GalaxyProjector(tuya_device_id=input('Tuya Device ID: '), device_ip_addr=input('Device IP Addr: '),
                           tuya_secret_key=input('Tuya Device Secret/Local Key: '))
    print()
    print('Current state:', proj.state.parsed_value)
    print()

    print('Press enter to continue')
    print()

    input('Turn stars off')
    proj.set_device_power(on=True)
    proj.set_stars_power(on=False)

    input('Turn stars on')
    proj.set_stars_power(on=True)

    input('Set stars brightness to 100%')
    proj.set_stars_brightness(percent=100)

    input('Set stars brightness to 0% (minimal)')
    proj.set_stars_brightness(percent=0)

    input('Set rotation speed to 100%')
    proj.set_rotation_speed(percent=100)

    input('Set rotation speed to 0%')
    proj.set_rotation_speed(percent=0)

    input('Set nebula color to red')
    proj.set_nebula_color(hsv=(0, 1, 1))

    input('Reduce nebula brightness')
    proj.set_nebula_color(hsv=(0, 1, .3))

    input('Show Scene')
    proj.set_scene([SceneTransition(change_speed_percent=80, change_mode='breath', nebula_hsv=(.5, 1, 1)),
                    SceneTransition(change_speed_percent=80, change_mode='breath', nebula_hsv=(0, 0, 1))])
    proj.set_scene_mode(on=True)

    input('Turn device off')
    proj.set_device_power(on=False)